@monte3l/groundwork 0.1.0-next.1 → 1.0.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -8
- package/bin/m3l-groundwork.mjs +36 -2
- package/dist/assets.d.ts +10 -0
- package/dist/assets.js +14 -0
- package/dist/baseline-stage.d.ts +173 -0
- package/dist/baseline-stage.js +215 -0
- package/dist/caps.d.ts +4 -1
- package/dist/caps.js +17 -3
- package/dist/conflicts.d.ts +23 -2
- package/dist/conflicts.js +89 -11
- package/dist/customize-paths.d.ts +92 -0
- package/dist/customize-paths.js +115 -0
- package/dist/emit.d.ts +50 -1
- package/dist/emit.js +121 -13
- package/dist/fatal.d.ts +70 -0
- package/dist/fatal.js +132 -0
- package/dist/format-error.d.ts +113 -0
- package/dist/format-error.js +553 -0
- package/dist/fs-guard.d.ts +142 -0
- package/dist/fs-guard.js +222 -0
- package/dist/git.js +10 -1
- package/dist/harness/conformance.js +2 -0
- package/dist/harness/frontmatter.js +2 -13
- package/dist/harness/grade.js +37 -8
- package/dist/harness/rules.d.ts +20 -3
- package/dist/harness/rules.js +108 -6
- package/dist/harness/types.d.ts +13 -0
- package/dist/harness/types.js +3 -0
- package/dist/inventory.d.ts +77 -3
- package/dist/inventory.js +103 -12
- package/dist/jsonc.d.ts +49 -2
- package/dist/jsonc.js +140 -7
- package/dist/main.d.ts +62 -3
- package/dist/main.js +538 -67
- package/dist/merge-json.d.ts +47 -4
- package/dist/merge-json.js +120 -8
- package/dist/mode.js +12 -2
- package/dist/pack-stage.d.ts +210 -0
- package/dist/pack-stage.js +287 -0
- package/dist/packs.d.ts +19 -13
- package/dist/packs.js +231 -28
- package/dist/palette.d.ts +23 -0
- package/dist/palette.js +22 -0
- package/dist/plugin.d.ts +122 -6
- package/dist/plugin.js +687 -47
- package/dist/report.d.ts +21 -2
- package/dist/report.js +93 -5
- package/dist/staging.d.ts +176 -0
- package/dist/staging.js +375 -0
- package/dist/survey/fs-walk.d.ts +34 -2
- package/dist/survey/fs-walk.js +70 -5
- package/dist/survey/internal/blocked-path.d.ts +27 -0
- package/dist/survey/internal/blocked-path.js +129 -0
- package/dist/survey/internal/package-json.d.ts +13 -0
- package/dist/survey/internal/package-json.js +37 -0
- package/dist/survey/internal/read-guard.d.ts +178 -0
- package/dist/survey/internal/read-guard.js +276 -0
- package/dist/survey/survey-docs.d.ts +17 -2
- package/dist/survey/survey-docs.js +61 -21
- package/dist/survey/survey-harness.d.ts +20 -2
- package/dist/survey/survey-harness.js +87 -46
- package/dist/survey/survey-shape.d.ts +17 -2
- package/dist/survey/survey-shape.js +60 -46
- package/dist/survey/survey-toolchain.d.ts +16 -1
- package/dist/survey/survey-toolchain.js +69 -66
- package/dist/survey/survey.js +6 -4
- package/dist/survey/types.d.ts +79 -0
- package/dist/survey/types.js +2 -6
- package/dist/term.d.ts +80 -0
- package/dist/term.js +145 -0
- package/dist/tokens.js +2 -0
- package/dist/toolchain/conformance.js +2 -0
- package/dist/toolchain/grade.js +26 -8
- package/dist/toolchain/rules.d.ts +13 -4
- package/dist/toolchain/rules.js +16 -0
- package/dist/toolchain/tsconfig-chain.d.ts +2 -0
- package/dist/toolchain/tsconfig-chain.js +32 -8
- package/dist/toolchain/types.d.ts +16 -4
- package/dist/toolchain/types.js +3 -0
- package/package.json +4 -3
- package/plugin/skills/customize/SKILL.md +292 -18
- package/plugin/src/domain-map.ts +39 -14
- package/plugin/src/index.ts +5 -1
- package/plugin/src/kind-facet-map.ts +25 -8
- package/plugin/src/pack-map.ts +147 -25
- package/plugin/src/plugin-map.ts +236 -0
- package/templates/core/.claude/agents/Explore.md +0 -1
- package/templates/core/.claude/agents/code-implementer.md +4 -4
- package/templates/core/.claude/agents/code-reviewer.md +4 -4
- package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
- package/templates/core/.claude/agents/test-author.md +7 -5
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
- package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
- package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
- package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
- package/templates/core/.claude/rules/agent-dispatch.md +7 -0
- package/templates/core/.claude/rules/tests.md +2 -2
- package/templates/core/.claude/settings.json +5 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
- package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
- package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
- package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
- package/templates/core/.github/dependabot.yml +18 -0
- package/templates/core/.github/workflows/ci.yml +15 -15
- package/templates/core/.github/workflows/dependency-review.yml +2 -2
- package/templates/core/.github/workflows/security-audit.yml +10 -4
- package/templates/core/.prettierignore +4 -0
- package/templates/core/CLAUDE.md +53 -3
- package/templates/core/README.md +30 -10
- package/templates/core/_gitignore +17 -0
- package/templates/core/bin/check-exports.mjs +11 -5
- package/templates/core/bin/lib/agent-roster.mjs +1 -1
- package/templates/core/bin/lib/frontmatter.mjs +4 -2
- package/templates/core/bin/lib/harness-rules.mjs +169 -20
- package/templates/core/bin/lib/protected-paths.mjs +93 -5
- package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
- package/templates/core/bin/lib/verify-steps.mjs +29 -11
- package/templates/core/bin/verify.mjs +12 -0
- package/templates/core/eslint.config.js +5 -0
- package/templates/core/package.json +7 -7
- package/templates/core/vitest.config.ts +8 -1
- package/templates/packs/README.md +35 -24
- package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
- package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
- package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
- package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
- package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
- package/templates/packs/github/pack.json +19 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
- package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
- package/templates/packs/harness-extras/pack.json +18 -13
- package/templates/packs/publishing/files/.changeset/README.md +25 -0
- package/templates/packs/publishing/files/.changeset/config.json +7 -0
- package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
- package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
- package/templates/packs/publishing/files/REUSE.toml +28 -0
- package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
- package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
- package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
- package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
- package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
- package/templates/packs/publishing/pack.json +35 -0
- package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
- package/templates/packs/quality/pack.json +29 -0
- package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
- package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
- package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
- package/templates/packs/supply-chain/pack.json +19 -0
- package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
- package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
- package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
- package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
- package/templates/packs/worktrees/files/.worktreeinclude +11 -0
- package/templates/packs/worktrees/pack.json +64 -0
- package/templates/packs/statusline/pack.json +0 -31
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
- /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
- /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
package/dist/plugin.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
1
3
|
/**
|
|
2
4
|
* Installs the `/customize` skill into a bootstrapped or adopted project.
|
|
3
5
|
* Claude Code's marketplace-based plugin installation is an interactive,
|
|
@@ -6,74 +8,712 @@
|
|
|
6
8
|
* destination directory, so `/customize` works immediately with no further
|
|
7
9
|
* setup step.
|
|
8
10
|
*/
|
|
9
|
-
import {
|
|
11
|
+
import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
10
12
|
import { join } from "node:path";
|
|
11
13
|
import { resolveAsset } from "./assets.js";
|
|
14
|
+
import { CLAUDE_DEST_SEGMENTS, CUSTOMIZE_SKILL_ENTRY_FILE, CUSTOMIZE_SKILL_WRITE_ORDER, GROUNDWORK_DEST_SEGMENTS, } from "./customize-paths.js";
|
|
15
|
+
import { assertNotSymlink, endsWithRerunAdvice, FIX_AND_RERUN_ADVICE, FRESH_RETRY, FRESH_SYMLINK_ADVICE, } from "./fs-guard.js";
|
|
12
16
|
/** Resolves the plugin payload for a source checkout (`packages/plugin`) or a published tarball (`plugin/`). */
|
|
13
17
|
function pluginDir() {
|
|
14
18
|
return resolveAsset({ repo: "packages/plugin", local: "plugin" });
|
|
15
19
|
}
|
|
16
20
|
/**
|
|
17
|
-
*
|
|
18
|
-
* (
|
|
19
|
-
*
|
|
20
|
-
* not something to degrade past silently -- it throws.
|
|
21
|
+
* The remediation an install failure ends with, chosen by the public entry
|
|
22
|
+
* point that owns the run ({@link withRemediation}) -- appended once, never
|
|
23
|
+
* twice.
|
|
21
24
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
const FRESH_REMEDIATION = {
|
|
26
|
+
advice: `fix the cause, then ${FRESH_RETRY}`,
|
|
27
|
+
isAdvised: (message) => message.endsWith(FRESH_RETRY),
|
|
28
|
+
};
|
|
29
|
+
const ADOPT_REMEDIATION = {
|
|
30
|
+
advice: FIX_AND_RERUN_ADVICE,
|
|
31
|
+
isAdvised: endsWithRerunAdvice,
|
|
32
|
+
};
|
|
33
|
+
/** The prefix every install failure's message starts with -- stated once, never twice. */
|
|
34
|
+
const INSTALL_ERROR_PREFIX = "could not install the /customize skill: ";
|
|
35
|
+
/**
|
|
36
|
+
* An already-built install failure. Private: it exists only so
|
|
37
|
+
* {@link installError} can recognise one it is asked to re-wrap and fold it
|
|
38
|
+
* in rather than nest a second prefix around it.
|
|
39
|
+
*/
|
|
40
|
+
class CustomizeInstallError extends Error {
|
|
41
|
+
/** The message without {@link INSTALL_ERROR_PREFIX}. */
|
|
42
|
+
body;
|
|
43
|
+
constructor(body, cause) {
|
|
44
|
+
super(`${INSTALL_ERROR_PREFIX}${body}`, { cause });
|
|
45
|
+
this.body = body;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Builds the one error shape every install failure surfaces as, keeping the
|
|
50
|
+
* underlying message and chaining the raw error as `cause`. It adds no
|
|
51
|
+
* remediation: {@link withRemediation} appends the caller's once, at the
|
|
52
|
+
* public boundary. A cause that is itself an install failure is folded in:
|
|
53
|
+
* its body follows `detail` under a single prefix, and its own raw `cause`
|
|
54
|
+
* (not the wrapper) is chained.
|
|
55
|
+
*/
|
|
56
|
+
function installError(detail, cause) {
|
|
57
|
+
if (cause instanceof CustomizeInstallError) {
|
|
58
|
+
return new CustomizeInstallError(`${detail}: ${cause.body}`, cause.cause);
|
|
59
|
+
}
|
|
60
|
+
const causeMessage = cause instanceof Error ? cause.message : String(cause);
|
|
61
|
+
return new CustomizeInstallError(`${detail}: ${causeMessage}`, cause);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Runs one public install entry point, appending `remediation.advice` to any
|
|
65
|
+
* install failure it throws -- unless the message already ENDS with this
|
|
66
|
+
* mode's advice (e.g. adopt mode's symlink refusal, "remove it and re-run
|
|
67
|
+
* the CLI"), so the advice appears once. The check is end-anchored and
|
|
68
|
+
* mode-specific: a phrase elsewhere in the message (inside a path) or the
|
|
69
|
+
* other mode's advice never suppresses it. Anything else is rethrown
|
|
70
|
+
* unchanged.
|
|
71
|
+
*/
|
|
72
|
+
function withRemediation(remediation, run) {
|
|
73
|
+
try {
|
|
74
|
+
return run();
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error instanceof CustomizeInstallError &&
|
|
78
|
+
!remediation.isAdvised(error.body)) {
|
|
79
|
+
throw new CustomizeInstallError(`${error.body} -- ${remediation.advice}`, error.cause);
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** The plugin payload's location, resolved inside a public entry point's run so a failure is wrapped like any other. */
|
|
85
|
+
function resolveSourceDir(sourceDir) {
|
|
86
|
+
return (sourceDir ??
|
|
87
|
+
wrapFs("could not locate the /customize skill's source", pluginDir));
|
|
88
|
+
}
|
|
89
|
+
/** Wording for a pre-write probe failure: nothing has been touched yet. */
|
|
90
|
+
function untouched(action, path) {
|
|
91
|
+
return `${action} ${path}; nothing was removed or written`;
|
|
92
|
+
}
|
|
93
|
+
/** Runs one fallible fs probe/setup step, rethrowing any failure as an {@link installError}. */
|
|
94
|
+
function wrapFs(detail, step) {
|
|
95
|
+
try {
|
|
96
|
+
return step();
|
|
97
|
+
}
|
|
98
|
+
catch (cause) {
|
|
99
|
+
throw installError(detail, cause);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** A string-valued errno field (`code`, `syscall`) of a thrown value, if it has one. */
|
|
103
|
+
function errnoField(error, field) {
|
|
104
|
+
if (typeof error !== "object" || error === null) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const value = Reflect.get(error, field);
|
|
108
|
+
return typeof value === "string" ? value : undefined;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Reads every payload file from the plugin source, in
|
|
112
|
+
* {@link CUSTOMIZE_SKILL_WRITE_ORDER} (`SKILL.md` last), before anything is
|
|
113
|
+
* written. A missing or unreadable source file is a broken install, not
|
|
114
|
+
* something to degrade past silently: it throws, with the raw read error
|
|
115
|
+
* (its errno intact) as `cause`, and a message saying "is missing" only for
|
|
116
|
+
* `ENOENT`.
|
|
117
|
+
*/
|
|
118
|
+
function readCustomizeSkillPayload(sourceDir) {
|
|
119
|
+
return CUSTOMIZE_SKILL_WRITE_ORDER.map((name) => {
|
|
120
|
+
const from = name === CUSTOMIZE_SKILL_ENTRY_FILE
|
|
121
|
+
? join(sourceDir, "skills", "customize", name)
|
|
122
|
+
: join(sourceDir, "src", name);
|
|
123
|
+
try {
|
|
124
|
+
return { name, bytes: readFileSync(from) };
|
|
125
|
+
}
|
|
126
|
+
catch (cause) {
|
|
127
|
+
const detail = errnoField(cause, "code") === "ENOENT"
|
|
128
|
+
? `the /customize skill's source file is missing: ${from}`
|
|
129
|
+
: `could not read ${from}`;
|
|
130
|
+
throw installError(detail, cause);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const FRESH_DESTINATION = {
|
|
135
|
+
segments: CLAUDE_DEST_SEGMENTS,
|
|
136
|
+
policy: "overwrite",
|
|
137
|
+
symlinkAdvice: FRESH_SYMLINK_ADVICE,
|
|
138
|
+
};
|
|
139
|
+
const ADOPT_CLAUDE_DESTINATION = {
|
|
140
|
+
segments: CLAUDE_DEST_SEGMENTS,
|
|
141
|
+
policy: "additive",
|
|
142
|
+
symlinkAdvice: undefined,
|
|
143
|
+
};
|
|
144
|
+
const CLI_OWNED_DESTINATION = {
|
|
145
|
+
segments: GROUNDWORK_DEST_SEGMENTS,
|
|
146
|
+
policy: "cli-owned",
|
|
147
|
+
symlinkAdvice: undefined,
|
|
148
|
+
};
|
|
149
|
+
/** Whether a policy removes an existing entry at a payload name before its `"wx"` write. */
|
|
150
|
+
function replacesExistingEntries(policy) {
|
|
151
|
+
switch (policy) {
|
|
152
|
+
case "overwrite":
|
|
153
|
+
case "cli-owned":
|
|
154
|
+
return true;
|
|
155
|
+
case "additive":
|
|
156
|
+
return false;
|
|
157
|
+
default: {
|
|
158
|
+
const exhaustive = policy;
|
|
159
|
+
throw new Error(`unhandled write policy: ${String(exhaustive)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Pre-flight for every policy: `lstat`s each payload name in `destDir` that
|
|
165
|
+
* this run will write (names in `alreadyCurrent` are skipped) and throws,
|
|
166
|
+
* naming it, if any is a directory -- before anything is removed or written,
|
|
167
|
+
* so a destination a non-recursive remove-then-`"wx"` could never complete
|
|
168
|
+
* is refused with every existing entry untouched. A name whose own `lstat`
|
|
169
|
+
* fails cannot be judged here: under a policy that replaces existing
|
|
170
|
+
* entries that refuses the install too ("could not inspect <path>; nothing
|
|
171
|
+
* was removed or written", the raw error as `cause`), since the run would
|
|
172
|
+
* otherwise remove an entry it never judged; under `"additive"` (which never
|
|
173
|
+
* removes anything) it is left to its own `"wx"` write, which surfaces the
|
|
174
|
+
* real error (rolled back as usual). A directory that appears after this
|
|
175
|
+
* check (a race) is likewise left to the write.
|
|
176
|
+
*/
|
|
177
|
+
function assertNoDirectoryAtPayloadNames(destDir, payload, alreadyCurrent, policy) {
|
|
178
|
+
for (const { name } of payload) {
|
|
179
|
+
if (alreadyCurrent.has(name)) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const dest = join(destDir, name);
|
|
183
|
+
let isDirectory;
|
|
184
|
+
try {
|
|
185
|
+
isDirectory =
|
|
186
|
+
lstatSync(dest, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
187
|
+
}
|
|
188
|
+
catch (cause) {
|
|
189
|
+
if (replacesExistingEntries(policy)) {
|
|
190
|
+
throw installError(untouched("could not inspect", dest), cause);
|
|
191
|
+
}
|
|
192
|
+
// Additive: unjudgeable, not refused -- the "wx" write reports the
|
|
193
|
+
// real error, and nothing is ever removed on this path.
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (isDirectory) {
|
|
197
|
+
throw installError(`could not write ${dest}`, new Error(`${dest} is a directory, not a file this install can replace; nothing was removed or written`));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* For every policy that replaces existing entries: removes an existing
|
|
203
|
+
* `SKILL.md` entry (a file or a symlink, unlinked, never followed) BEFORE any
|
|
204
|
+
* data file is rewritten, so a later failure never leaves a stale `SKILL.md`
|
|
205
|
+
* loadable beside missing or half-rewritten data. Runs after
|
|
206
|
+
* {@link assertNoDirectoryAtPayloadNames}, so a directory there has already
|
|
207
|
+
* been refused (one raced in since makes `rmSync` throw). Returns the
|
|
208
|
+
* removed path, if any; throws (wrapped) before anything is written when the
|
|
209
|
+
* removal fails. `label` is how the entry is described: `"existing"` for a
|
|
210
|
+
* project's own `.claude/` copy, `"stale"` for the CLI-owned staging.
|
|
211
|
+
*/
|
|
212
|
+
function removeStaleSkillEntry(destDir, label) {
|
|
213
|
+
const staleEntry = join(destDir, CUSTOMIZE_SKILL_ENTRY_FILE);
|
|
214
|
+
return wrapFs(`could not remove the ${label} ${staleEntry}`, () => {
|
|
215
|
+
if (lstatSync(staleEntry, { throwIfNoEntry: false }) === undefined) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
rmSync(staleEntry, { force: true });
|
|
219
|
+
return staleEntry;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Whether a failed `"wx"` write had already created `dest` itself. An `open`
|
|
224
|
+
* failure (`EEXIST` included) creates nothing, so only a failure after the
|
|
225
|
+
* exclusive open succeeded -- e.g. `ENOSPC` mid-write -- leaves a file this
|
|
226
|
+
* call owns. `"unknown"` when the `lstat` probing `dest` itself fails: the
|
|
227
|
+
* entry is then neither removed (its origin is unknown) nor silently
|
|
228
|
+
* treated as absent -- the caller names it in the error.
|
|
229
|
+
*/
|
|
230
|
+
function createdByFailedWrite(dest, cause) {
|
|
231
|
+
if (errnoField(cause, "syscall") === "open" ||
|
|
232
|
+
errnoField(cause, "code") === "EEXIST") {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return lstatSync(dest, { throwIfNoEntry: false })?.isFile() === true;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return "unknown";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Writes one payload file under `policy`. A replacing policy `lstat`s `dest`
|
|
244
|
+
* before removing it, so the caller learns whether a pre-existing entry was
|
|
245
|
+
* removed (and so is gone even if the run later fails).
|
|
246
|
+
*/
|
|
247
|
+
function writePayloadFile(dest, bytes, policy) {
|
|
248
|
+
let replaced = false;
|
|
249
|
+
try {
|
|
250
|
+
if (replacesExistingEntries(policy)) {
|
|
251
|
+
const existed = lstatSync(dest, { throwIfNoEntry: false }) !== undefined;
|
|
252
|
+
// Remove, then "wx": a symlink at dest is replaced, never followed.
|
|
253
|
+
rmSync(dest, { force: true });
|
|
254
|
+
replaced = existed;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (cause) {
|
|
258
|
+
return { replaced: false, failure: { cause, created: false } };
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
writeFileSync(dest, bytes, { flag: "wx" });
|
|
262
|
+
return { replaced, failure: undefined };
|
|
263
|
+
}
|
|
264
|
+
catch (cause) {
|
|
265
|
+
return {
|
|
266
|
+
replaced,
|
|
267
|
+
failure: { cause, created: createdByFailedWrite(dest, cause) },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Removes every path in `written` (best effort), returning how many were actually removed and which were not, each with its errno code. */
|
|
272
|
+
function rollBack(written) {
|
|
273
|
+
const leftBehind = [];
|
|
274
|
+
for (const path of written) {
|
|
275
|
+
try {
|
|
276
|
+
rmSync(path, { force: true });
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
// Best effort: the write failure is the error that matters; a path
|
|
280
|
+
// this rollback could not remove is named in that error instead.
|
|
281
|
+
leftBehind.push({ path, code: errnoField(error, "code") ?? "unknown" });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { removed: written.length - leftBehind.length, leftBehind };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The clauses a failed write's error adds after its rollback count: paths
|
|
288
|
+
* rollback could not remove, a write whose own creation is unknown, a
|
|
289
|
+
* left-behind `SKILL.md`, and the pre-existing entries this run removed and
|
|
290
|
+
* cannot restore. Each is `""` when it does not apply.
|
|
291
|
+
*/
|
|
292
|
+
function failureClauses(args) {
|
|
293
|
+
const { dest, destDir, policy, leftBehind, createdUnknown } = args;
|
|
294
|
+
const notRemoved = leftBehind.length > 0
|
|
295
|
+
? ` (could not remove: ${leftBehind.map((l) => `${l.path} (${l.code})`).join(", ")})`
|
|
296
|
+
: "";
|
|
297
|
+
const skillPath = join(destDir, CUSTOMIZE_SKILL_ENTRY_FILE);
|
|
298
|
+
const skillLeftBehind = leftBehind.some((l) => l.path === skillPath);
|
|
299
|
+
const unknownIsSkill = createdUnknown && dest === skillPath;
|
|
300
|
+
// Either way a possibly-truncated SKILL.md sits at the destination, and
|
|
301
|
+
// its own clause says what to do -- the generic "not loadable" clause
|
|
302
|
+
// would contradict it.
|
|
303
|
+
const skillMaybeLeft = skillLeftBehind || unknownIsSkill;
|
|
304
|
+
const unknown = createdUnknown
|
|
305
|
+
? `; ${dest} was left in place; whether this run created it is unknown${unknownIsSkill
|
|
306
|
+
? " -- it may be a truncated copy; delete it by hand"
|
|
307
|
+
: ""}`
|
|
308
|
+
: "";
|
|
309
|
+
const skillLeft = skillLeftBehind
|
|
310
|
+
? `; ${skillPath} was left behind -- delete it by hand; ${policy === "cli-owned"
|
|
311
|
+
? "it is a truncated copy"
|
|
312
|
+
: "Claude Code will load a truncated skill"}`
|
|
313
|
+
: "";
|
|
314
|
+
return `${notRemoved}${unknown}${skillLeft}${replacedClause(policy, args.skillRemoved, args.replaced, skillMaybeLeft)}`;
|
|
315
|
+
}
|
|
316
|
+
/** The "removed and not restored" clause for a replacing policy's pre-existing entries; `""` when none were removed. */
|
|
317
|
+
function replacedClause(policy, skillRemoved, replaced, skillMaybeLeft) {
|
|
318
|
+
const others = replaced.length > 0
|
|
319
|
+
? `the previous ${replaced.join(", ")} ${replaced.length === 1 ? "was" : "were"} removed and NOT restored`
|
|
320
|
+
: "";
|
|
321
|
+
switch (policy) {
|
|
322
|
+
case "overwrite": {
|
|
323
|
+
if (skillRemoved === undefined && others === "") {
|
|
324
|
+
return "";
|
|
325
|
+
}
|
|
326
|
+
// One sentence for SKILL.md and the data files alike: every one of
|
|
327
|
+
// them was removed and none is restored.
|
|
328
|
+
const removedPaths = [
|
|
329
|
+
...(skillRemoved === undefined
|
|
330
|
+
? []
|
|
331
|
+
: [
|
|
332
|
+
`the existing SKILL.md (${skillRemoved}, removed before any data file was rewritten)`,
|
|
333
|
+
]),
|
|
334
|
+
...(replaced.length > 0 ? [`the previous ${replaced.join(", ")}`] : []),
|
|
335
|
+
];
|
|
336
|
+
const count = (skillRemoved === undefined ? 0 : 1) + replaced.length;
|
|
337
|
+
const notLoadable = skillMaybeLeft
|
|
338
|
+
? ""
|
|
339
|
+
: " -- the skill is not loadable until a successful re-run";
|
|
340
|
+
return `; ${removedPaths.join(" and ")} ${count === 1 ? "was" : "were"} removed and NOT restored${notLoadable}`;
|
|
341
|
+
}
|
|
342
|
+
case "cli-owned": {
|
|
343
|
+
const stale = skillRemoved === undefined
|
|
344
|
+
? ""
|
|
345
|
+
: `; the stale ${skillRemoved} was removed before any data file was rewritten`;
|
|
346
|
+
return `${stale}${others === "" ? "" : `; ${others}`}`;
|
|
347
|
+
}
|
|
348
|
+
case "additive":
|
|
349
|
+
return "";
|
|
350
|
+
default: {
|
|
351
|
+
const exhaustive = policy;
|
|
352
|
+
throw new Error(`unhandled write policy: ${String(exhaustive)}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Writes `payload` into `<targetDir>/<destination.segments>`, skipping any
|
|
358
|
+
* name in `alreadyCurrent` (files an interrupted install already wrote
|
|
359
|
+
* byte-identically), and returns the written paths relative to `targetDir`.
|
|
360
|
+
*
|
|
361
|
+
* Before any `mkdir`/`rm`/write, every directory component from `targetDir`
|
|
362
|
+
* down to the destination is `lstat`-checked ({@link assertNotSymlink}), so
|
|
363
|
+
* a symlinked `.claude`/`.groundwork` (or any level below it) is refused
|
|
364
|
+
* rather than followed out of the project. Every payload file is created
|
|
365
|
+
* with `"wx"`, so a symlink at the file itself is never written through:
|
|
366
|
+
* under `"overwrite"`/`"cli-owned"` it is removed first and replaced; under
|
|
367
|
+
* `"additive"` nothing is removed and the write fails `EEXIST`. A directory
|
|
368
|
+
* component swapped for a symlink between the check and the write (a TOCTOU
|
|
369
|
+
* race) is not covered.
|
|
370
|
+
*
|
|
371
|
+
* Under `"overwrite"`, the destination is classified first
|
|
372
|
+
* ({@link classifyExistingSkill}); when every payload file there is already
|
|
373
|
+
* a byte-identical regular file, nothing is removed or written and
|
|
374
|
+
* `filesWritten` is empty. A failing `lstat` probe there throws before
|
|
375
|
+
* anything is touched; a regular file that cannot be read counts as not
|
|
376
|
+
* current, so it is removed and rewritten like any other stale copy.
|
|
377
|
+
*
|
|
378
|
+
* Before anything is removed or written, a directory at any payload name
|
|
379
|
+
* this run writes is refused ({@link assertNoDirectoryAtPayloadNames}), as
|
|
380
|
+
* is -- under a replacing policy -- a payload name whose `lstat` fails,
|
|
381
|
+
* leaving every existing entry untouched. Writes then follow
|
|
382
|
+
* {@link CUSTOMIZE_SKILL_WRITE_ORDER}, `SKILL.md` last; for the policies
|
|
383
|
+
* that replace existing entries, {@link removeStaleSkillEntry} establishes
|
|
384
|
+
* that order's no-`SKILL.md`-yet precondition first. Names in
|
|
385
|
+
* `alreadyCurrent` are not rechecked before the `SKILL.md` write: one
|
|
386
|
+
* changed after the caller classified it (an accepted race window) is not
|
|
387
|
+
* detected.
|
|
388
|
+
*
|
|
389
|
+
* If any write fails, every file THIS call wrote -- including one whose own
|
|
390
|
+
* write created it and then failed part-way -- is removed (best effort), and
|
|
391
|
+
* the error names how many were, any it could not remove (with its errno
|
|
392
|
+
* code; a left-behind `SKILL.md` additionally says to delete it by hand),
|
|
393
|
+
* any whose creation could not be determined (in its own "left in place;
|
|
394
|
+
* whether this run created it is unknown" clause -- for `SKILL.md`, also
|
|
395
|
+
* saying it may be a truncated copy to delete by hand), and the pre-removed
|
|
396
|
+
* `SKILL.md` if there was one. Rollback never removes an entry this call did
|
|
397
|
+
* not write, but under `"overwrite"`/`"cli-owned"` the pre-existing entries
|
|
398
|
+
* this call replaced before the failure (the `SKILL.md`, and each payload
|
|
399
|
+
* file removed ahead of its own write) are not restored -- the error names
|
|
400
|
+
* each of them as "removed and NOT restored". Under `"overwrite"` it adds
|
|
401
|
+
* that the skill is not loadable until a successful re-run, unless a
|
|
402
|
+
* possibly-truncated `SKILL.md` may still sit at the destination, whose own
|
|
403
|
+
* clause already says what to do. Every failure -- a symlink refusal, a
|
|
404
|
+
* directory at a payload name, a raw `lstat`/`mkdir` error such as
|
|
405
|
+
* `ENOTDIR`, a write error -- is thrown as one "could not install the
|
|
406
|
+
* /customize skill" `Error` carrying the underlying message and the raw
|
|
407
|
+
* error as `cause`; the public entry point appends its own remediation
|
|
408
|
+
* ({@link withRemediation}).
|
|
409
|
+
*/
|
|
410
|
+
function copyCustomizeSkillFiles(targetDir, destination, payload, alreadyCurrent = new Set()) {
|
|
411
|
+
const { segments, policy, symlinkAdvice } = destination;
|
|
412
|
+
const destDir = wrapFs(`could not prepare ${join(targetDir, ...segments)}`, () => {
|
|
413
|
+
let dir = targetDir;
|
|
414
|
+
for (const segment of segments) {
|
|
415
|
+
dir = join(dir, segment);
|
|
416
|
+
assertNotSymlink(dir, symlinkAdvice);
|
|
417
|
+
}
|
|
418
|
+
mkdirSync(dir, { recursive: true });
|
|
419
|
+
return dir;
|
|
420
|
+
});
|
|
421
|
+
// Fresh mode's re-run over its own, still-current output touches nothing.
|
|
422
|
+
if (policy === "overwrite" &&
|
|
423
|
+
classifyExistingSkill(destDir, payload).kind === "current") {
|
|
424
|
+
return { filesWritten: [] };
|
|
425
|
+
}
|
|
426
|
+
assertNoDirectoryAtPayloadNames(destDir, payload, alreadyCurrent, policy);
|
|
427
|
+
const skillRemoved = replacesExistingEntries(policy)
|
|
428
|
+
? removeStaleSkillEntry(destDir, policy === "overwrite" ? "existing" : "stale")
|
|
429
|
+
: undefined;
|
|
430
|
+
const written = [];
|
|
431
|
+
const replaced = [];
|
|
26
432
|
const filesWritten = [];
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
-
|
|
433
|
+
for (const { name, bytes } of payload) {
|
|
434
|
+
if (alreadyCurrent.has(name)) {
|
|
435
|
+
continue;
|
|
30
436
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
437
|
+
const dest = join(destDir, name);
|
|
438
|
+
const outcome = writePayloadFile(dest, bytes, policy);
|
|
439
|
+
if (outcome.replaced) {
|
|
440
|
+
replaced.push(dest);
|
|
441
|
+
}
|
|
442
|
+
const { failure } = outcome;
|
|
443
|
+
if (failure !== undefined) {
|
|
444
|
+
if (failure.created === true) {
|
|
445
|
+
written.push(dest);
|
|
446
|
+
}
|
|
447
|
+
const { removed, leftBehind } = rollBack(written);
|
|
448
|
+
const occupied = errnoField(failure.cause, "code") === "EEXIST"
|
|
449
|
+
? `; an entry this run did not create sits there (a project entry appeared during the install, or the name was already taken) and was left untouched`
|
|
450
|
+
: "";
|
|
451
|
+
const clauses = failureClauses({
|
|
452
|
+
dest,
|
|
453
|
+
destDir,
|
|
454
|
+
policy,
|
|
455
|
+
leftBehind,
|
|
456
|
+
createdUnknown: failure.created === "unknown",
|
|
457
|
+
skillRemoved,
|
|
458
|
+
replaced,
|
|
459
|
+
});
|
|
460
|
+
throw installError(`could not write ${dest}${occupied}; removed the ${removed} file(s) written by this run${clauses}`, failure.cause);
|
|
461
|
+
}
|
|
462
|
+
written.push(dest);
|
|
463
|
+
filesWritten.push(join(...segments, name));
|
|
464
|
+
}
|
|
38
465
|
return { filesWritten };
|
|
39
466
|
}
|
|
40
467
|
/**
|
|
41
468
|
* Installs the skill into `<targetDir>/.claude/skills/customize/`. Used by
|
|
42
|
-
* fresh-bootstrap mode, where the directory is
|
|
469
|
+
* fresh-bootstrap mode, where the directory is normally new (`--force` may
|
|
470
|
+
* point it at a non-empty one or an earlier install). An earlier install
|
|
471
|
+
* that is already byte-identical to this payload is left untouched
|
|
472
|
+
* (`filesWritten` is empty); otherwise any existing `SKILL.md` is removed
|
|
473
|
+
* first, then each payload file is removed and recreated. A symlinked
|
|
474
|
+
* `.claude`, `.claude/skills` or `.claude/skills/customize` is refused, not
|
|
475
|
+
* routed around.
|
|
476
|
+
*
|
|
477
|
+
* @throws `Error` ("could not install the /customize skill ...", raw error
|
|
478
|
+
* as `cause`) on a missing or unreadable source file, a directory at any
|
|
479
|
+
* payload name, a payload name or existing file that cannot be `lstat`ed,
|
|
480
|
+
* or an existing regular file whose read fails with anything other than
|
|
481
|
+
* `EACCES`/`EPERM` (all before anything is removed or written), a symlinked
|
|
482
|
+
* or non-directory directory component, any fs failure, or a failed write
|
|
483
|
+
* -- after removing every file this call wrote. An existing regular file
|
|
484
|
+
* whose read fails with `EACCES` or `EPERM` is not a failure: it is replaced
|
|
485
|
+
* like any stale copy. Entries it replaced before the failure are not
|
|
486
|
+
* restored; the error names them. A failure to locate the default
|
|
487
|
+
* `sourceDir` is thrown the same way. The message ends, once, by saying to
|
|
488
|
+
* fix the cause (for a symlink: remove it), then retry
|
|
489
|
+
* the same command with `--fresh --force` added -- a plain re-run would
|
|
490
|
+
* adopt the now-non-empty target -- and nowhere in the error chain gives
|
|
491
|
+
* adopt mode's bare "re-run the CLI" advice.
|
|
492
|
+
*
|
|
493
|
+
* @example
|
|
494
|
+
* ```ts
|
|
495
|
+
* import { installCustomizeSkill } from "./plugin.js";
|
|
496
|
+
*
|
|
497
|
+
* installCustomizeSkill("/work/app").filesWritten.length; // 5
|
|
498
|
+
* ```
|
|
43
499
|
*/
|
|
44
|
-
export function installCustomizeSkill(targetDir, sourceDir
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
500
|
+
export function installCustomizeSkill(targetDir, sourceDir) {
|
|
501
|
+
return withRemediation(FRESH_REMEDIATION, () => copyCustomizeSkillFiles(targetDir, FRESH_DESTINATION, readCustomizeSkillPayload(resolveSourceDir(sourceDir))));
|
|
502
|
+
}
|
|
503
|
+
/** Why `<targetDir>/<segments>` cannot be written into: its first component that is a symlink or not a directory, if any. */
|
|
504
|
+
function firstUnusableComponent(targetDir, segments) {
|
|
505
|
+
let dir = targetDir;
|
|
506
|
+
for (const segment of segments) {
|
|
507
|
+
dir = join(dir, segment);
|
|
508
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
509
|
+
if (stat === undefined) {
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
if (stat.isSymbolicLink()) {
|
|
513
|
+
return `${dir} is a symlink`;
|
|
514
|
+
}
|
|
515
|
+
if (!stat.isDirectory()) {
|
|
516
|
+
return `${dir} is not a directory`;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return undefined;
|
|
50
520
|
}
|
|
521
|
+
/** The read-failure codes that mean "this entry is not ours to read" -- a permission refusal, not a fault. */
|
|
522
|
+
const UNREADABLE_CODES = new Set(["EACCES", "EPERM"]);
|
|
51
523
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
524
|
+
* Whether the regular file at `path` holds exactly `bytes`: `"match"`,
|
|
525
|
+
* `"mismatch"`, or `"unreadable"` (with the errno code) when the read is
|
|
526
|
+
* refused with `EACCES` or `EPERM`. Every caller treats such an entry as not
|
|
527
|
+
* this CLI's current copy, and decides from there what to do with it.
|
|
528
|
+
*
|
|
529
|
+
* @throws `Error` ({@link installError}: "could not read <path>; nothing was
|
|
530
|
+
* removed or written", raw error as `cause`) on any other read failure
|
|
531
|
+
* (`EIO`, `EMFILE`, an `ENOENT`/`ELOOP` race after `lstat`, or a code-less
|
|
532
|
+
* error): a fault is not evidence about the entry, so it is never guessed
|
|
533
|
+
* to be foreign or stale.
|
|
534
|
+
*/
|
|
535
|
+
function regularFileMatches(path, bytes) {
|
|
536
|
+
let existing;
|
|
537
|
+
try {
|
|
538
|
+
existing = readFileSync(path);
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
const code = errnoField(error, "code");
|
|
542
|
+
// Only a permission refusal is a verdict about the entry: fresh mode
|
|
543
|
+
// replaces it like any stale copy, adopt mode leaves it alone and falls
|
|
544
|
+
// back, the code kept so the fallback reason can say why.
|
|
545
|
+
if (code !== undefined && UNREADABLE_CODES.has(code)) {
|
|
546
|
+
return { kind: "unreadable", code };
|
|
547
|
+
}
|
|
548
|
+
throw installError(untouched("could not read", path), error);
|
|
549
|
+
}
|
|
550
|
+
return existing.equals(bytes) ? { kind: "match" } : { kind: "mismatch" };
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Classifies the existing `.claude/skills/customize/` by `lstat` (a symlink,
|
|
554
|
+
* FIFO or directory where a payload file is expected is never read):
|
|
555
|
+
*
|
|
556
|
+
* - `"current"`: every payload file is a regular file matching the payload
|
|
557
|
+
* byte-for-byte.
|
|
558
|
+
* - `"installable"`: no entry at all at `SKILL.md` and every other entry
|
|
559
|
+
* present is a regular file matching the payload byte-for-byte -- nothing
|
|
560
|
+
* there at all, or an install interrupted before its `SKILL.md`.
|
|
561
|
+
* `alreadyCurrent` names the files not to rewrite.
|
|
562
|
+
* - `"foreign"`: anything else -- including any non-regular entry (a
|
|
563
|
+
* directory, symlink or FIFO) under any payload name, `SKILL.md` included;
|
|
564
|
+
* `reason` names the first offending entry.
|
|
565
|
+
*
|
|
566
|
+
* Runs before anything is removed or written, and stops at the first foreign
|
|
567
|
+
* entry: a failing `lstat` on any entry reached before that point throws
|
|
568
|
+
* (wrapped, raw error as `cause`) naming the path and saying so -- an entry
|
|
569
|
+
* whose very nature is unknown is never classified -- while an entry after
|
|
570
|
+
* the first foreign one is never `lstat`ed at all. A regular file `lstat`
|
|
571
|
+
* already confirmed but whose read is refused with `EACCES`/`EPERM` is
|
|
572
|
+
* `"foreign"`, its `reason` saying it could not be read and naming the errno
|
|
573
|
+
* code: this never guesses that an entry it cannot compare is current.
|
|
574
|
+
* Fresh mode's overwrite then replaces it like any stale copy; adopt mode
|
|
575
|
+
* leaves it untouched and falls back. Any other read failure throws the
|
|
576
|
+
* same way a failing `lstat` does ({@link regularFileMatches}).
|
|
577
|
+
*/
|
|
578
|
+
function classifyExistingSkill(existingDir, payload) {
|
|
579
|
+
const alreadyCurrent = new Set();
|
|
580
|
+
let entryLoadable = false;
|
|
581
|
+
for (const { name, bytes } of payload) {
|
|
582
|
+
const installedPath = join(existingDir, name);
|
|
583
|
+
const stat = wrapFs(untouched("could not inspect", installedPath), () => lstatSync(installedPath, { throwIfNoEntry: false }));
|
|
584
|
+
if (stat === undefined) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const verdict = stat.isFile()
|
|
588
|
+
? regularFileMatches(installedPath, bytes)
|
|
589
|
+
: { kind: "mismatch" };
|
|
590
|
+
if (verdict.kind === "unreadable") {
|
|
591
|
+
// No ", so" clause here: installGuarded appends its own.
|
|
592
|
+
return {
|
|
593
|
+
kind: "foreign",
|
|
594
|
+
reason: `${installedPath} already exists and could not be read (${verdict.code}); it cannot be confirmed as this CLI's current copy`,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
if (verdict.kind === "mismatch") {
|
|
598
|
+
return {
|
|
599
|
+
kind: "foreign",
|
|
600
|
+
reason: `${installedPath} already exists and is not this CLI's current copy`,
|
|
601
|
+
};
|
|
68
602
|
}
|
|
69
|
-
|
|
70
|
-
|
|
603
|
+
alreadyCurrent.add(name);
|
|
604
|
+
entryLoadable ||= name === CUSTOMIZE_SKILL_ENTRY_FILE;
|
|
605
|
+
}
|
|
606
|
+
if (alreadyCurrent.size === payload.length) {
|
|
607
|
+
return { kind: "current" };
|
|
608
|
+
}
|
|
609
|
+
if (entryLoadable) {
|
|
71
610
|
return {
|
|
72
|
-
|
|
611
|
+
kind: "foreign",
|
|
612
|
+
reason: `${join(existingDir, CUSTOMIZE_SKILL_ENTRY_FILE)} already exists without the rest of this CLI's current payload`,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
return { kind: "installable", alreadyCurrent };
|
|
616
|
+
}
|
|
617
|
+
/** The body of {@link installCustomizeSkillGuarded}, before its adopt-mode remediation is appended. */
|
|
618
|
+
function installGuarded(targetDir, sourceDir) {
|
|
619
|
+
const payload = readCustomizeSkillPayload(sourceDir);
|
|
620
|
+
const existingDir = join(targetDir, ...CLAUDE_DEST_SEGMENTS);
|
|
621
|
+
const installToGroundwork = (reason, fallbackCause) => {
|
|
622
|
+
let filesWritten;
|
|
623
|
+
try {
|
|
624
|
+
({ filesWritten } = copyCustomizeSkillFiles(targetDir, CLI_OWNED_DESTINATION, payload));
|
|
625
|
+
}
|
|
626
|
+
catch (cause) {
|
|
627
|
+
// Already an installError; this adds only why the fallback
|
|
628
|
+
// destination was being written at all.
|
|
629
|
+
throw installError(`${reason}, so it fell back to .groundwork/customize/, and that install failed too`, cause);
|
|
630
|
+
}
|
|
631
|
+
return {
|
|
632
|
+
filesWritten,
|
|
73
633
|
location: "groundwork",
|
|
634
|
+
fallbackReason: `${reason}, so the /customize skill was installed into .groundwork/customize/ instead`,
|
|
635
|
+
fallbackCause,
|
|
74
636
|
};
|
|
637
|
+
};
|
|
638
|
+
const unusable = wrapFs(`could not inspect ${existingDir}`, () => firstUnusableComponent(targetDir, CLAUDE_DEST_SEGMENTS));
|
|
639
|
+
if (unusable !== undefined) {
|
|
640
|
+
return installToGroundwork(unusable, "component");
|
|
75
641
|
}
|
|
76
|
-
|
|
77
|
-
|
|
642
|
+
// Throws its own wrapped error naming a path it could not lstat or read;
|
|
643
|
+
// a regular file whose read is refused (EACCES/EPERM) comes back
|
|
644
|
+
// "foreign" instead.
|
|
645
|
+
const existing = classifyExistingSkill(existingDir, payload);
|
|
646
|
+
switch (existing.kind) {
|
|
647
|
+
case "current":
|
|
648
|
+
return { filesWritten: [], location: "already-present" };
|
|
649
|
+
case "foreign":
|
|
650
|
+
return installToGroundwork(existing.reason, "entry");
|
|
651
|
+
case "installable": {
|
|
652
|
+
const { filesWritten } = copyCustomizeSkillFiles(targetDir, ADOPT_CLAUDE_DESTINATION, payload, existing.alreadyCurrent);
|
|
653
|
+
return { filesWritten, location: "claude" };
|
|
654
|
+
}
|
|
655
|
+
default: {
|
|
656
|
+
const exhaustive = existing;
|
|
657
|
+
throw new Error(`unhandled skill state: ${JSON.stringify(exhaustive)}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Adopt-mode install: purely additive, never overwrites or removes a project
|
|
663
|
+
* entry under `.claude/skills/customize/`.
|
|
664
|
+
*
|
|
665
|
+
* - If `.claude`, `.claude/skills` or `.claude/skills/customize` is a
|
|
666
|
+
* symlink or not a directory, the skill is written to
|
|
667
|
+
* `.groundwork/customize/` instead, `fallbackReason` names that path and
|
|
668
|
+
* `fallbackCause` is `"component"` -- the entry (and any link target) is
|
|
669
|
+
* left untouched.
|
|
670
|
+
* - Otherwise, if every payload file already there is a regular file
|
|
671
|
+
* matching what this CLI ships byte-for-byte: with all five present the
|
|
672
|
+
* result is `"already-present"` (nothing written); with no `SKILL.md` file
|
|
673
|
+
* (none at all, or an install interrupted before writing it) the missing
|
|
674
|
+
* files are written into `.claude/skills/customize/`, `SKILL.md` last,
|
|
675
|
+
* never rewriting or removing the correct ones.
|
|
676
|
+
* - Anything else under a payload name (a differing file, a regular file
|
|
677
|
+
* that cannot be read, a symlink -- dangling or not -- a directory, a
|
|
678
|
+
* `SKILL.md` without its data; detected by `lstat`) is kept as the
|
|
679
|
+
* project's own: the skill is written to `.groundwork/customize/` instead,
|
|
680
|
+
* `fallbackReason` names that entry (saying so when it could not be read)
|
|
681
|
+
* and `fallbackCause` is `"entry"`. Adopt mode never guesses at a project
|
|
682
|
+
* entry it cannot compare, and the fallback does not guess either: it
|
|
683
|
+
* leaves that entry exactly as it was. Claude Code does not load a skill
|
|
684
|
+
* from there; the caller must say so.
|
|
685
|
+
*
|
|
686
|
+
* Writes into `.claude/skills/customize/` use `"wx"` only, so an entry that
|
|
687
|
+
* appears there mid-install fails the run (rolled back) rather than being
|
|
688
|
+
* replaced. Writes into the CLI-owned `.groundwork/customize/` replace that
|
|
689
|
+
* directory's payload files (any `SKILL.md` removed first, then each file
|
|
690
|
+
* removed and recreated with `"wx"`); a symlinked `.groundwork` or
|
|
691
|
+
* `.groundwork/customize`, or a directory at any payload name there, is
|
|
692
|
+
* refused, never routed around.
|
|
693
|
+
*
|
|
694
|
+
* @throws `Error` ("could not install the /customize skill ...", raw error
|
|
695
|
+
* as `cause`) on a missing or unreadable source file (before anything is
|
|
696
|
+
* written), a payload name under `.claude/skills/customize/` that cannot be
|
|
697
|
+
* `lstat`ed or read (a regular file there whose read is refused with
|
|
698
|
+
* `EACCES`/`EPERM` falls back instead; any other read failure throws before
|
|
699
|
+
* anything is written), any other fs failure while probing or writing, a
|
|
700
|
+
* symlinked `.groundwork`/`.groundwork/customize`, or a directory at a
|
|
701
|
+
* payload name there -- after removing every file this call wrote. A failed
|
|
702
|
+
* `.groundwork/customize/` install also names why the fallback was taken.
|
|
703
|
+
* A failure to locate the default `sourceDir` is thrown the same way. The
|
|
704
|
+
* message ends by
|
|
705
|
+
* saying to fix the cause (for a symlink: remove it) and re-run the CLI,
|
|
706
|
+
* once.
|
|
707
|
+
*
|
|
708
|
+
* @example
|
|
709
|
+
* ```ts
|
|
710
|
+
* import { installCustomizeSkillGuarded } from "./plugin.js";
|
|
711
|
+
*
|
|
712
|
+
* const { location } = installCustomizeSkillGuarded("/work/app");
|
|
713
|
+
* // "claude" | "groundwork" | "already-present"
|
|
714
|
+
* ```
|
|
715
|
+
*/
|
|
716
|
+
export function installCustomizeSkillGuarded(targetDir, sourceDir) {
|
|
717
|
+
return withRemediation(ADOPT_REMEDIATION, () => installGuarded(targetDir, resolveSourceDir(sourceDir)));
|
|
78
718
|
}
|
|
79
719
|
//# sourceMappingURL=plugin.js.map
|