@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.
Files changed (170) hide show
  1. package/README.md +16 -8
  2. package/bin/m3l-groundwork.mjs +36 -2
  3. package/dist/assets.d.ts +10 -0
  4. package/dist/assets.js +14 -0
  5. package/dist/baseline-stage.d.ts +173 -0
  6. package/dist/baseline-stage.js +215 -0
  7. package/dist/caps.d.ts +4 -1
  8. package/dist/caps.js +17 -3
  9. package/dist/conflicts.d.ts +23 -2
  10. package/dist/conflicts.js +89 -11
  11. package/dist/customize-paths.d.ts +92 -0
  12. package/dist/customize-paths.js +115 -0
  13. package/dist/emit.d.ts +50 -1
  14. package/dist/emit.js +121 -13
  15. package/dist/fatal.d.ts +70 -0
  16. package/dist/fatal.js +132 -0
  17. package/dist/format-error.d.ts +113 -0
  18. package/dist/format-error.js +553 -0
  19. package/dist/fs-guard.d.ts +142 -0
  20. package/dist/fs-guard.js +222 -0
  21. package/dist/git.js +10 -1
  22. package/dist/harness/conformance.js +2 -0
  23. package/dist/harness/frontmatter.js +2 -13
  24. package/dist/harness/grade.js +37 -8
  25. package/dist/harness/rules.d.ts +20 -3
  26. package/dist/harness/rules.js +108 -6
  27. package/dist/harness/types.d.ts +13 -0
  28. package/dist/harness/types.js +3 -0
  29. package/dist/inventory.d.ts +77 -3
  30. package/dist/inventory.js +103 -12
  31. package/dist/jsonc.d.ts +49 -2
  32. package/dist/jsonc.js +140 -7
  33. package/dist/main.d.ts +62 -3
  34. package/dist/main.js +538 -67
  35. package/dist/merge-json.d.ts +47 -4
  36. package/dist/merge-json.js +120 -8
  37. package/dist/mode.js +12 -2
  38. package/dist/pack-stage.d.ts +210 -0
  39. package/dist/pack-stage.js +287 -0
  40. package/dist/packs.d.ts +19 -13
  41. package/dist/packs.js +231 -28
  42. package/dist/palette.d.ts +23 -0
  43. package/dist/palette.js +22 -0
  44. package/dist/plugin.d.ts +122 -6
  45. package/dist/plugin.js +687 -47
  46. package/dist/report.d.ts +21 -2
  47. package/dist/report.js +93 -5
  48. package/dist/staging.d.ts +176 -0
  49. package/dist/staging.js +375 -0
  50. package/dist/survey/fs-walk.d.ts +34 -2
  51. package/dist/survey/fs-walk.js +70 -5
  52. package/dist/survey/internal/blocked-path.d.ts +27 -0
  53. package/dist/survey/internal/blocked-path.js +129 -0
  54. package/dist/survey/internal/package-json.d.ts +13 -0
  55. package/dist/survey/internal/package-json.js +37 -0
  56. package/dist/survey/internal/read-guard.d.ts +178 -0
  57. package/dist/survey/internal/read-guard.js +276 -0
  58. package/dist/survey/survey-docs.d.ts +17 -2
  59. package/dist/survey/survey-docs.js +61 -21
  60. package/dist/survey/survey-harness.d.ts +20 -2
  61. package/dist/survey/survey-harness.js +87 -46
  62. package/dist/survey/survey-shape.d.ts +17 -2
  63. package/dist/survey/survey-shape.js +60 -46
  64. package/dist/survey/survey-toolchain.d.ts +16 -1
  65. package/dist/survey/survey-toolchain.js +69 -66
  66. package/dist/survey/survey.js +6 -4
  67. package/dist/survey/types.d.ts +79 -0
  68. package/dist/survey/types.js +2 -6
  69. package/dist/term.d.ts +80 -0
  70. package/dist/term.js +145 -0
  71. package/dist/tokens.js +2 -0
  72. package/dist/toolchain/conformance.js +2 -0
  73. package/dist/toolchain/grade.js +26 -8
  74. package/dist/toolchain/rules.d.ts +13 -4
  75. package/dist/toolchain/rules.js +16 -0
  76. package/dist/toolchain/tsconfig-chain.d.ts +2 -0
  77. package/dist/toolchain/tsconfig-chain.js +32 -8
  78. package/dist/toolchain/types.d.ts +16 -4
  79. package/dist/toolchain/types.js +3 -0
  80. package/package.json +4 -3
  81. package/plugin/skills/customize/SKILL.md +292 -18
  82. package/plugin/src/domain-map.ts +39 -14
  83. package/plugin/src/index.ts +5 -1
  84. package/plugin/src/kind-facet-map.ts +25 -8
  85. package/plugin/src/pack-map.ts +147 -25
  86. package/plugin/src/plugin-map.ts +236 -0
  87. package/templates/core/.claude/agents/Explore.md +0 -1
  88. package/templates/core/.claude/agents/code-implementer.md +4 -4
  89. package/templates/core/.claude/agents/code-reviewer.md +4 -4
  90. package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
  91. package/templates/core/.claude/agents/test-author.md +7 -5
  92. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
  93. package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
  94. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
  95. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
  96. package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
  97. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
  98. package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
  99. package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
  100. package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
  101. package/templates/core/.claude/rules/agent-dispatch.md +7 -0
  102. package/templates/core/.claude/rules/tests.md +2 -2
  103. package/templates/core/.claude/settings.json +5 -0
  104. package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
  105. package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
  106. package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
  107. package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
  108. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
  109. package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
  110. package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
  111. package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
  112. package/templates/core/.github/dependabot.yml +18 -0
  113. package/templates/core/.github/workflows/ci.yml +15 -15
  114. package/templates/core/.github/workflows/dependency-review.yml +2 -2
  115. package/templates/core/.github/workflows/security-audit.yml +10 -4
  116. package/templates/core/.prettierignore +4 -0
  117. package/templates/core/CLAUDE.md +53 -3
  118. package/templates/core/README.md +30 -10
  119. package/templates/core/_gitignore +17 -0
  120. package/templates/core/bin/check-exports.mjs +11 -5
  121. package/templates/core/bin/lib/agent-roster.mjs +1 -1
  122. package/templates/core/bin/lib/frontmatter.mjs +4 -2
  123. package/templates/core/bin/lib/harness-rules.mjs +169 -20
  124. package/templates/core/bin/lib/protected-paths.mjs +93 -5
  125. package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
  126. package/templates/core/bin/lib/verify-steps.mjs +29 -11
  127. package/templates/core/bin/verify.mjs +12 -0
  128. package/templates/core/eslint.config.js +5 -0
  129. package/templates/core/package.json +7 -7
  130. package/templates/core/vitest.config.ts +8 -1
  131. package/templates/packs/README.md +35 -24
  132. package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
  133. package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
  134. package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
  135. package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
  136. package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
  137. package/templates/packs/github/pack.json +19 -0
  138. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
  139. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
  140. package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
  141. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
  142. package/templates/packs/harness-extras/pack.json +18 -13
  143. package/templates/packs/publishing/files/.changeset/README.md +25 -0
  144. package/templates/packs/publishing/files/.changeset/config.json +7 -0
  145. package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
  146. package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
  147. package/templates/packs/publishing/files/REUSE.toml +28 -0
  148. package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
  149. package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
  150. package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
  151. package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
  152. package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
  153. package/templates/packs/publishing/pack.json +35 -0
  154. package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
  155. package/templates/packs/quality/pack.json +29 -0
  156. package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
  157. package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
  158. package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
  159. package/templates/packs/supply-chain/pack.json +19 -0
  160. package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
  161. package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
  162. package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
  163. package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
  164. package/templates/packs/worktrees/files/.worktreeinclude +11 -0
  165. package/templates/packs/worktrees/pack.json +64 -0
  166. package/templates/packs/statusline/pack.json +0 -31
  167. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
  168. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
  169. /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
  170. /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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
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
- * Copies `skills/customize/SKILL.md` and its backing data
18
- * (`src/kind-facet-map.ts`, `src/domain-map.ts`, `src/pack-map.ts`) from
19
- * `sourceDir` into `destDir`. A missing source file is a broken install,
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
- function copyCustomizeSkillFiles(destDir, sourceDir) {
23
- const skillSourceDir = join(sourceDir, "skills", "customize");
24
- const dataSourceDir = join(sourceDir, "src");
25
- mkdirSync(destDir, { recursive: true });
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 copyInto = (from, toName) => {
28
- if (!existsSync(from)) {
29
- throw new Error(`the /customize skill's source file is missing: ${from}`);
433
+ for (const { name, bytes } of payload) {
434
+ if (alreadyCurrent.has(name)) {
435
+ continue;
30
436
  }
31
- writeFileSync(join(destDir, toName), readFileSync(from, "utf8"));
32
- filesWritten.push(toName);
33
- };
34
- copyInto(join(skillSourceDir, "SKILL.md"), "SKILL.md");
35
- copyInto(join(dataSourceDir, "kind-facet-map.ts"), "kind-facet-map.ts");
36
- copyInto(join(dataSourceDir, "domain-map.ts"), "domain-map.ts");
37
- copyInto(join(dataSourceDir, "pack-map.ts"), "pack-map.ts");
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 always new.
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 = pluginDir()) {
45
- const destDir = join(targetDir, ".claude", "skills", "customize");
46
- const result = copyCustomizeSkillFiles(destDir, sourceDir);
47
- return {
48
- filesWritten: result.filesWritten.map((name) => join(".claude", "skills", "customize", name)),
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
- * Adopt-mode install: purely additive, never overwrites. If the project
53
- * already has its own `.claude/skills/customize/SKILL.md` and its content
54
- * differs from what this CLI ships, the skill is written to
55
- * `.groundwork/customize/` instead -- reported in the adoption report
56
- * rather than silently overwriting whatever the project already had there.
57
- */
58
- export function installCustomizeSkillGuarded(targetDir, sourceDir = pluginDir()) {
59
- const existingSkillMdPath = join(targetDir, ".claude", "skills", "customize", "SKILL.md");
60
- const sourceSkillMdPath = join(sourceDir, "skills", "customize", "SKILL.md");
61
- if (existsSync(existingSkillMdPath)) {
62
- const existingContent = readFileSync(existingSkillMdPath, "utf8");
63
- const sourceContent = existsSync(sourceSkillMdPath)
64
- ? readFileSync(sourceSkillMdPath, "utf8")
65
- : undefined;
66
- if (sourceContent !== undefined && existingContent === sourceContent) {
67
- return { filesWritten: [], location: "already-present" };
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
- const destDir = join(targetDir, ".groundwork", "customize");
70
- const result = copyCustomizeSkillFiles(destDir, sourceDir);
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
- filesWritten: result.filesWritten.map((name) => join(".groundwork", "customize", name)),
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
- const result = installCustomizeSkill(targetDir, sourceDir);
77
- return { filesWritten: result.filesWritten, location: "claude" };
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