@codedology/code-forge 0.1.0

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 (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Symlink/copy install mechanics and the per-user install ledger, `~/.code-forge/installs.json`
3
+ * (plan §1.2 "Install locations (per user)"; §2.1: "`code-forge list` prints what is linked where
4
+ * and whether the target resolves"). This is the library the `list`/`remove`/`upgrade` verbs (and
5
+ * later, B13's `init` wizard) are built on: nothing here prompts or prints — every function takes
6
+ * explicit paths and returns data.
7
+ *
8
+ * `home` is always an explicit parameter, defaulted to `os.homedir()` at the call site in
9
+ * `src/cli/*.mjs`, never hardcoded here — the only way a test can point this at a fixture HOME
10
+ * without ever touching the real `~/.code-forge` (coder-rules.md rule 10).
11
+ *
12
+ * ## Install record shape
13
+ *
14
+ * `{harness, scope, method, source, target, linked_at}` — `scope` is `"project"` or `"global"`,
15
+ * `method` is `"symlink"` or `"copy"`. `(harness, scope)` is the natural key: installing the same
16
+ * harness at the same scope again replaces its record rather than duplicating it.
17
+ */
18
+
19
+ import { lstatSync, readdirSync, realpathSync } from 'node:fs';
20
+ import { cp, lstat, mkdir, readFile, rename, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { randomUUID } from 'node:crypto';
24
+
25
+ /** @typedef {"symlink"|"copy"} LinkMethod */
26
+ /** @typedef {"project"|"global"} InstallScope */
27
+
28
+ /**
29
+ * @typedef {object} InstallRecord
30
+ * @property {string} harness
31
+ * @property {InstallScope} scope
32
+ * @property {LinkMethod} method
33
+ * @property {string} source - absolute path to the skill directory that was linked/copied.
34
+ * @property {string} target - absolute path to the installed skill directory.
35
+ * @property {string} linked_at - ISO timestamp of the most recent link/upgrade.
36
+ */
37
+
38
+ /**
39
+ * @param {string} [home]
40
+ * @returns {string}
41
+ */
42
+ export function installsPath(home = os.homedir()) {
43
+ return path.join(home, '.code-forge', 'installs.json');
44
+ }
45
+
46
+ /**
47
+ * A record is only ever trusted if every field is present and correctly typed — a partially
48
+ * corrupt file (hand-edited, or truncated by a crash mid-write) drops just the bad rows rather
49
+ * than throwing away, or blindly trusting, the whole ledger.
50
+ * @param {unknown} value
51
+ * @returns {value is InstallRecord}
52
+ */
53
+ function isValidRecord(value) {
54
+ if (!value || typeof value !== 'object') {
55
+ return false;
56
+ }
57
+ const r = /** @type {Record<string, unknown>} */ (value);
58
+ return (
59
+ typeof r.harness === 'string' &&
60
+ r.harness.length > 0 &&
61
+ (r.scope === 'project' || r.scope === 'global') &&
62
+ (r.method === 'symlink' || r.method === 'copy') &&
63
+ typeof r.source === 'string' &&
64
+ r.source.length > 0 &&
65
+ path.isAbsolute(r.source) &&
66
+ typeof r.target === 'string' &&
67
+ r.target.length > 0 &&
68
+ path.isAbsolute(r.target) &&
69
+ typeof r.linked_at === 'string'
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Tolerant read: a missing, corrupt, or wrongly-shaped file yields `[]` rather than throwing —
75
+ * matching `src/config/refresh.mjs`'s `readExistingSeen` convention for this package's other
76
+ * per-user caches. A write later in the same run overwrites the bad file with a valid one.
77
+ * @param {string} file
78
+ * @returns {Promise<InstallRecord[]>}
79
+ */
80
+ export async function readInstalls(file) {
81
+ let raw;
82
+ try {
83
+ raw = await readFile(file, 'utf8');
84
+ } catch {
85
+ return [];
86
+ }
87
+ let parsed;
88
+ try {
89
+ parsed = JSON.parse(raw);
90
+ } catch {
91
+ return [];
92
+ }
93
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(/** @type {any} */ (parsed)?.installs) ? parsed.installs : null;
94
+ if (!list) {
95
+ return [];
96
+ }
97
+ return list.filter(isValidRecord);
98
+ }
99
+
100
+ /**
101
+ * Writes `records` to `file` atomically: write to a sibling temp file, then `rename()` over the
102
+ * target, so a reader never observes a partial write and a crash mid-write leaves the ORIGINAL
103
+ * file intact (same pattern as `src/config/refresh.mjs`'s `writeModelsCacheAtomically`).
104
+ * @param {string} file
105
+ * @param {InstallRecord[]} records
106
+ * @returns {Promise<void>}
107
+ */
108
+ export async function writeInstallsAtomically(file, records) {
109
+ await mkdir(path.dirname(file), { recursive: true });
110
+ const tempFile = path.join(path.dirname(file), `.installs.json.${randomUUID()}.tmp`);
111
+ await writeFile(tempFile, `${JSON.stringify({ version: 1, installs: records }, null, 2)}\n`, 'utf8');
112
+ try {
113
+ await rename(tempFile, file);
114
+ } catch (err) {
115
+ await unlink(tempFile).catch(() => {});
116
+ throw err;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * @param {string} maybeAncestor
122
+ * @param {string} maybeDescendant
123
+ * @returns {boolean} whether `maybeDescendant` is STRICTLY inside `maybeAncestor` (equal paths
124
+ * are not "inside").
125
+ */
126
+ function isAncestorOf(maybeAncestor, maybeDescendant) {
127
+ if (maybeAncestor === maybeDescendant) {
128
+ return false;
129
+ }
130
+ const withSep = maybeAncestor.endsWith(path.sep) ? maybeAncestor : `${maybeAncestor}${path.sep}`;
131
+ return maybeDescendant.startsWith(withSep);
132
+ }
133
+
134
+ /**
135
+ * Resolves `p` to its true on-disk path (following symlinks AND normalizing case on a
136
+ * case-insensitive filesystem, via `fs.realpathSync.native` — the libuv-native realpath, which
137
+ * consults the OS itself rather than doing a literal string walk) via the nearest EXISTING
138
+ * ancestor: walks up from `p` until an ancestor resolves, then re-joins whatever segments below
139
+ * that ancestor don't exist yet, LITERALLY (there is nothing on disk to resolve them against).
140
+ * Falls back to `p` itself, unresolved, only if nothing up to the filesystem root resolves —
141
+ * this is a safety check and must never throw.
142
+ * @param {string} p - an already-absolute path.
143
+ * @returns {string}
144
+ */
145
+ function canonicalizeExistingPrefix(p) {
146
+ let current = p;
147
+ /** @type {string[]} */
148
+ const trailing = [];
149
+ for (;;) {
150
+ try {
151
+ const real = realpathSync.native(current);
152
+ return trailing.length > 0 ? path.join(real, ...trailing.reverse()) : real;
153
+ } catch {
154
+ const parent = path.dirname(current);
155
+ if (parent === current) {
156
+ return p; // reached the root without resolving anything — best effort, never throws.
157
+ }
158
+ trailing.push(path.basename(current));
159
+ current = parent;
160
+ }
161
+ }
162
+ }
163
+
164
+ /**
165
+ * `dir`'s entries' TRUE on-disk casing for whichever one matches `name` case-INsensitively, or
166
+ * `name` itself when no entry matches (nothing on disk to correct against — a case-sensitive
167
+ * filesystem needs no correction, and a genuinely missing entry has no "true case" to find).
168
+ * @param {string} dir
169
+ * @param {string} name
170
+ * @returns {string}
171
+ */
172
+ function trueCaseOf(dir, name) {
173
+ let entries;
174
+ try {
175
+ entries = readdirSync(dir);
176
+ } catch {
177
+ return name;
178
+ }
179
+ const lower = name.toLowerCase();
180
+ return entries.find((entry) => entry.toLowerCase() === lower) ?? name;
181
+ }
182
+
183
+ /**
184
+ * Canonicalizes `target` for a SAFETY comparison — the true on-disk path, with every ancestor
185
+ * AND `target`'s own case corrected, but WITHOUT ever following `target` if it is itself a
186
+ * symlink (deleting a symlink removes the link entry, not its destination — the wrong thing to
187
+ * resolve here is "what `target` refers to", not "where `target` itself sits").
188
+ *
189
+ * When `target` exists and is NOT itself a symlink, a single `realpathSync.native` call resolves
190
+ * ancestor symlinks AND corrects the whole path's case (including its own final component) in
191
+ * one step — nothing is "followed" beyond `target`'s own location, since it isn't a link. When
192
+ * `target` IS a symlink (or does not exist yet), its PARENT chain is resolved instead, and its
193
+ * own basename is case-corrected via a case-insensitive lookup within that resolved parent —
194
+ * never realpath'd directly.
195
+ * @param {string} target - an already-absolute path.
196
+ * @returns {string}
197
+ */
198
+ function canonicalizeTargetLocation(target) {
199
+ let info;
200
+ try {
201
+ info = lstatSync(target);
202
+ } catch {
203
+ info = null;
204
+ }
205
+ if (info && !info.isSymbolicLink()) {
206
+ try {
207
+ return realpathSync.native(target);
208
+ } catch {
209
+ // Raced away (or unreadable) between lstat and realpath: fall through to the parent-chain
210
+ // resolution below — a safety check must never throw.
211
+ }
212
+ }
213
+
214
+ const dir = path.dirname(target);
215
+ const name = path.basename(target);
216
+ const resolvedDir = canonicalizeExistingPrefix(dir);
217
+ return path.join(resolvedDir, trueCaseOf(resolvedDir, name));
218
+ }
219
+
220
+ /**
221
+ * Every canonical form under which a PROTECTED path (`source`, or `$HOME`) must be guarded,
222
+ * deduplicated: where it FULLY resolves to (following the path itself when it is a symlink — an
223
+ * npm global install's link, or a `$HOME` that lives behind a symlink) AND where its own entry
224
+ * SITS (its parents resolved, the path itself not followed, § {@link canonicalizeTargetLocation}).
225
+ * A target is compared against BOTH: the target is located without being followed, so a target
226
+ * spelled exactly like a symlinked protected path only ever matches the un-followed form —
227
+ * comparing against the followed form alone let it through.
228
+ * @param {string} protectedPath - an already-absolute path.
229
+ * @returns {string[]} one entry when `protectedPath` is not a symlink, two when it is.
230
+ */
231
+ function protectedForms(protectedPath) {
232
+ const followed = canonicalizeExistingPrefix(protectedPath);
233
+ const location = canonicalizeTargetLocation(protectedPath);
234
+ return followed === location ? [followed] : [followed, location];
235
+ }
236
+
237
+ /**
238
+ * Refuses a deletion that could be catastrophic: the filesystem root, `$HOME` itself or an
239
+ * ANCESTOR of it (deleting `target` would take `$HOME` down with it), or `source` itself or an
240
+ * ancestor of it (deleting `target` would also destroy the thing being installed FROM). A target legitimately
241
+ * INSIDE `$HOME` — the normal case, e.g. `~/.claude/skills/code-forge` — is always fine; only an
242
+ * ancestor is refused.
243
+ *
244
+ * Every comparison runs on CANONICAL paths (§ {@link canonicalizeTargetLocation}), not raw
245
+ * strings: a case-insensitive filesystem (macOS APFS by default) makes `/users/<name>` and
246
+ * `/Users/<name>` the SAME on-disk entry, and a symlinked ancestor can make two textually
247
+ * unrelated strings resolve to the same real location — either would defeat a plain
248
+ * `path.resolve` + `startsWith` string comparison.
249
+ * @param {string} target
250
+ * @param {string} [source] - when given, also refused if `target` is it or an ancestor of it.
251
+ * @param {string} [home] - defaults to `os.homedir()`.
252
+ * @returns {{safe: boolean, reason: string}} `reason` is `''` when `safe` is `true`.
253
+ */
254
+ function checkSafeToDelete(target, source, home = os.homedir()) {
255
+ const canonicalTarget = canonicalizeTargetLocation(path.resolve(target));
256
+ if (canonicalTarget === path.parse(canonicalTarget).root) {
257
+ return { safe: false, reason: `target is the filesystem root (${canonicalTarget})` };
258
+ }
259
+ for (const canonicalHome of protectedForms(path.resolve(home))) {
260
+ if (canonicalTarget === canonicalHome || isAncestorOf(canonicalTarget, canonicalHome)) {
261
+ return { safe: false, reason: `target is $HOME or an ancestor of it (${canonicalTarget})` };
262
+ }
263
+ }
264
+ for (const canonicalSource of source ? protectedForms(path.resolve(source)) : []) {
265
+ if (canonicalTarget === canonicalSource) {
266
+ return { safe: false, reason: `target is its own source (${canonicalTarget})` };
267
+ }
268
+ if (isAncestorOf(canonicalTarget, canonicalSource)) {
269
+ return { safe: false, reason: `target is an ancestor of its own source (${canonicalTarget})` };
270
+ }
271
+ }
272
+ return { safe: true, reason: '' };
273
+ }
274
+
275
+ /**
276
+ * Removes whatever already exists at `target` (file, directory, or symlink — including a broken
277
+ * one) so a fresh symlink/copy can take its place. A target that never existed is a silent no-op.
278
+ * Refuses (throws) rather than delete anything {@link checkSafeToDelete} flags as catastrophic.
279
+ * @param {string} target
280
+ * @param {object} [opts]
281
+ * @param {string} [opts.source] - passed through to {@link checkSafeToDelete}.
282
+ * @returns {Promise<void>}
283
+ */
284
+ async function clearTarget(target, { source } = {}) {
285
+ const check = checkSafeToDelete(target, source);
286
+ if (!check.safe) {
287
+ throw new Error(`link: refusing to delete "${target}" — ${check.reason}`);
288
+ }
289
+ await rm(target, { recursive: true, force: true });
290
+ }
291
+
292
+ /**
293
+ * Points `target` at `source` using `method` — `"symlink"` creates a directory symlink,
294
+ * `"copy"` recursively copies `source`'s contents. Either way, `target`'s parent is created and
295
+ * anything already at `target` is replaced (never merged: a stale copy from a previous version
296
+ * must not leave orphaned files behind a fresh symlink, or vice versa).
297
+ *
298
+ * `source` is validated (exists, is a directory, is not `target` and does not contain/is not
299
+ * contained by `target` — compared as canonical on-disk paths, with `source` checked both as
300
+ * where it resolves to AND where its own entry sits when it is a symlink) BEFORE `target` is
301
+ * touched — a bad `source` must never destroy a working install by clearing `target` and then
302
+ * failing to create the replacement.
303
+ * @param {object} opts
304
+ * @param {string} opts.source - absolute path to the skill directory.
305
+ * @param {string} opts.target - absolute path to install into.
306
+ * @param {LinkMethod} opts.method
307
+ * @returns {Promise<void>}
308
+ */
309
+ export async function linkSkill({ source, target, method }) {
310
+ if (method !== 'symlink' && method !== 'copy') {
311
+ throw new TypeError(`link: method must be "symlink" or "copy", got ${JSON.stringify(method)}`);
312
+ }
313
+
314
+ let sourceStat;
315
+ try {
316
+ sourceStat = await stat(source);
317
+ } catch (err) {
318
+ const code = /** @type {NodeJS.ErrnoException} */ (err)?.code ?? 'unknown error';
319
+ throw new Error(`link: source "${source}" does not exist or is not readable (${code})`);
320
+ }
321
+ if (!sourceStat.isDirectory()) {
322
+ throw new Error(`link: source "${source}" is not a directory`);
323
+ }
324
+
325
+ // Canonical, not textual: a differently-cased path (case-insensitive APFS) or one reached through
326
+ // a symlinked parent can name the source itself while never matching its string. `target` is
327
+ // located without following it when it is itself a symlink (an existing install's link), so
328
+ // `source` is checked in BOTH its followed and un-followed forms (§ protectedForms).
329
+ const resolvedTarget = canonicalizeTargetLocation(path.resolve(target));
330
+ for (const resolvedSource of protectedForms(path.resolve(source))) {
331
+ if (resolvedSource === resolvedTarget) {
332
+ throw new Error(`link: source and target must not be the same path (${resolvedTarget})`);
333
+ }
334
+ if (isAncestorOf(resolvedSource, resolvedTarget)) {
335
+ throw new Error(`link: target must not be inside source (${resolvedTarget} is inside ${resolvedSource})`);
336
+ }
337
+ if (isAncestorOf(resolvedTarget, resolvedSource)) {
338
+ throw new Error(`link: source must not be inside target (${resolvedSource} is inside ${resolvedTarget})`);
339
+ }
340
+ }
341
+
342
+ await mkdir(path.dirname(target), { recursive: true });
343
+ await clearTarget(target, { source });
344
+ if (method === 'symlink') {
345
+ await symlink(source, target, 'dir');
346
+ } else {
347
+ await cp(source, target, { recursive: true });
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Links/copies `source` into `target` and records the install, replacing any existing record for
353
+ * the same `(harness, scope)` (a re-run of `init` for a harness already installed updates its
354
+ * record in place rather than accumulating duplicates).
355
+ * @param {object} opts
356
+ * @param {string} opts.installsFile
357
+ * @param {string} opts.harness
358
+ * @param {InstallScope} opts.scope
359
+ * @param {LinkMethod} opts.method
360
+ * @param {string} opts.source
361
+ * @param {string} opts.target
362
+ * @returns {Promise<InstallRecord[]>} the full record list after this install.
363
+ */
364
+ export async function install({ installsFile, harness, scope, method, source, target }) {
365
+ await linkSkill({ source, target, method });
366
+ const existing = await readInstalls(installsFile);
367
+ const kept = existing.filter((r) => !(r.harness === harness && r.scope === scope));
368
+ const records = [...kept, { harness, scope, method, source, target, linked_at: new Date().toISOString() }];
369
+ await writeInstallsAtomically(installsFile, records);
370
+ return records;
371
+ }
372
+
373
+ /**
374
+ * Re-points every recorded install at `source` (or, when `source` is omitted, re-applies each
375
+ * record's own previously-recorded source — a refresh with no version change). For a symlink
376
+ * record this re-creates the symlink so it points at the new source; for a copy record it
377
+ * re-copies the new source's contents over the target. Every record's `linked_at` and `source`
378
+ * are updated to reflect the upgrade.
379
+ * @param {object} opts
380
+ * @param {string} opts.installsFile
381
+ * @param {string} [opts.source] - overrides every record's source when given.
382
+ * @returns {Promise<InstallRecord[]>} the updated record list (same length as before).
383
+ */
384
+ export async function upgradeAll({ installsFile, source }) {
385
+ const records = await readInstalls(installsFile);
386
+ const updated = [];
387
+ for (const record of records) {
388
+ const nextSource = source ?? record.source;
389
+ await linkSkill({ source: nextSource, target: record.target, method: record.method });
390
+ updated.push({ ...record, source: nextSource, linked_at: new Date().toISOString() });
391
+ }
392
+ await writeInstallsAtomically(installsFile, updated);
393
+ return updated;
394
+ }
395
+
396
+ /**
397
+ * Removes recorded installs matching the given filter (default: every record) — deletes ONLY the
398
+ * `target` path of each matched record, never a glob or a parent directory scan, so an unrecorded
399
+ * file that happens to sit alongside a real install survives untouched.
400
+ *
401
+ * A record whose `target` {@link checkSafeToDelete} flags as catastrophic (the filesystem root,
402
+ * `$HOME` or an ancestor of it, or an ancestor of its own `source` — only reachable via a
403
+ * hand-edited or corrupted `installs.json`, since every WRITE path here goes through
404
+ * `linkSkill`'s own guards) is SKIPPED, not deleted, and stays in the ledger rather than being
405
+ * silently dropped — one poisoned record must not block removal of the others in the same call.
406
+ * @param {object} opts
407
+ * @param {string} opts.installsFile
408
+ * @param {string} [opts.harness] - when given, only records for this harness match.
409
+ * @param {InstallScope} [opts.scope] - when given, only records at this scope match.
410
+ * @returns {Promise<InstallRecord[]>} the records that were actually removed (excludes any
411
+ * matched-but-refused record).
412
+ */
413
+ export async function removeRecords({ installsFile, harness, scope }) {
414
+ const records = await readInstalls(installsFile);
415
+ const matches = (/** @type {InstallRecord} */ r) =>
416
+ (harness === undefined || r.harness === harness) && (scope === undefined || r.scope === scope);
417
+ const candidates = records.filter(matches);
418
+ const kept = records.filter((r) => !matches(r));
419
+
420
+ const removed = [];
421
+ for (const record of candidates) {
422
+ const check = checkSafeToDelete(record.target, record.source);
423
+ if (!check.safe) {
424
+ kept.push(record);
425
+ continue;
426
+ }
427
+ await rm(record.target, { recursive: true, force: true });
428
+ removed.push(record);
429
+ }
430
+
431
+ await writeInstallsAtomically(installsFile, kept);
432
+ return removed;
433
+ }
434
+
435
+ /**
436
+ * Whether `target` resolves to something that exists — for a symlink this follows the link, so a
437
+ * broken symlink (source removed/moved) reports `false` (plan §2.1: "list ... whether the target
438
+ * resolves").
439
+ * @param {string} target
440
+ * @returns {Promise<boolean>}
441
+ */
442
+ export async function targetResolves(target) {
443
+ try {
444
+ await stat(target);
445
+ return true;
446
+ } catch {
447
+ return false;
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Whether `target` is itself a symlink (as opposed to a real directory from a `copy` install).
453
+ * @param {string} target
454
+ * @returns {Promise<boolean>}
455
+ */
456
+ export async function isSymlink(target) {
457
+ try {
458
+ const info = await lstat(target);
459
+ return info.isSymbolicLink();
460
+ } catch {
461
+ return false;
462
+ }
463
+ }