@andromarces/agent-loops 0.2.1 → 0.3.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 (37) hide show
  1. package/README.md +194 -114
  2. package/docs/orchestrator-instructions.md +25 -22
  3. package/package.json +2 -2
  4. package/src/agents/agy.mjs +2 -11
  5. package/src/agents/codex.mjs +3 -20
  6. package/src/agents/copilot.mjs +8 -16
  7. package/src/agents/opencode.mjs +2 -7
  8. package/src/agents/shared.mjs +29 -0
  9. package/src/cli.mjs +46 -25
  10. package/src/entrypoints/copilot.mjs +6 -1
  11. package/src/hook/antigravity-parent-guard.mjs +28 -0
  12. package/src/hook/copilot-parent-guard.mjs +5 -30
  13. package/src/hook/decision.mjs +59 -6
  14. package/src/hook/opencode-plugin.mjs +92 -0
  15. package/src/hook/parent-guard.mjs +8 -33
  16. package/src/install/commands.mjs +268 -0
  17. package/src/install/fsutil.mjs +159 -0
  18. package/src/install/harnesses.mjs +170 -0
  19. package/src/install/installer.mjs +688 -0
  20. package/src/install/manifest.mjs +222 -0
  21. package/src/install/settings.mjs +217 -0
  22. package/src/install/templates/antigravity/agent-loop-antigravity-parent-guard.mjs +14 -0
  23. package/src/install/templates/antigravity/hooks.json +16 -0
  24. package/src/install/templates/antigravity/skills/agent-loop/SKILL.md +24 -0
  25. package/src/install/templates/claude/skills/agent-loop/SKILL.md +33 -0
  26. package/src/install/templates/codex/skills/agent-loop/SKILL.md +30 -0
  27. package/src/install/templates/codex/skills/agent-loop/agents/openai.yaml +2 -0
  28. package/src/install/templates/copilot/hooks/parent-guard.json +15 -0
  29. package/src/install/templates/opencode/plugins/parent-guard.ts +11 -0
  30. package/src/lib/args.mjs +23 -0
  31. package/src/lib/hash.mjs +9 -0
  32. package/src/lib/log.mjs +18 -3
  33. package/src/lib/process-ancestry.mjs +104 -0
  34. package/src/lib/runstate.mjs +133 -38
  35. package/src/lib/snapshot.mjs +3 -7
  36. package/src/role.mjs +71 -87
  37. package/src/runtime.mjs +9 -9
@@ -0,0 +1,688 @@
1
+ // `agent-loop install` / `agent-loop uninstall` (#139). User-scope only: each
2
+ // harness gets its entry point and its guard, rendered from the shipped
3
+ // templates against the installed package. The manifest records one baseline
4
+ // per target; a second install is a no-op, an upgrade replaces only the
5
+ // recorded entry, and uninstall restores the pre-install bytes when the file is
6
+ // unchanged since install. Guards are applied before entry points, and a failed
7
+ // write persists the manifest for the writes that completed and keeps the
8
+ // previous record for every target it did not complete, so uninstall can recover
9
+ // a partial install or an interrupted upgrade (#156). An exclusive lock outside
10
+ // the home serializes install and uninstall, so concurrent read-modify-write of
11
+ // the manifest cannot drop a record (#193).
12
+ import { access } from "node:fs/promises";
13
+ import { delimiter, dirname, join } from "node:path";
14
+ import { logWarn } from "../lib/log.mjs";
15
+ import { withStateLock } from "../lib/runstate.mjs";
16
+ import {
17
+ backupPathFor,
18
+ ensureDir,
19
+ fileMode,
20
+ pruneEmptyDirs,
21
+ readTextOrNull,
22
+ removeFileQuiet,
23
+ sha256,
24
+ writeTextAtomic,
25
+ } from "./fsutil.mjs";
26
+ import { buildTargets, HARNESS_META, HARNESS_ORDER } from "./harnesses.mjs";
27
+ import {
28
+ manifestLockFile,
29
+ readManifest,
30
+ removeManifest,
31
+ resolveHome,
32
+ writeManifest,
33
+ } from "./manifest.mjs";
34
+ import {
35
+ findEntryIndex,
36
+ insertEntry,
37
+ manualSnippet,
38
+ missingLocatorIndex,
39
+ parseSettings,
40
+ pruneEmptyLocator,
41
+ removeEntry,
42
+ replaceEntry,
43
+ serializeSettings,
44
+ validateLocator,
45
+ } from "./settings.mjs";
46
+
47
+ // `npx` installs the package under `<npm-cache>/_npx/<hash>/node_modules` and
48
+ // `pnpm dlx` under `<pnpm-home>/dlx/<hash>/<work>/node_modules`. npm or pnpm
49
+ // can delete either directory at any time. A global install, a project
50
+ // `node_modules`, and a linked clone all keep a stable package root.
51
+ const EPHEMERAL_CACHE_DIRS = new Set(["_npx", "dlx"]);
52
+ const EPHEMERAL_ROOT_MESSAGE =
53
+ "Refusing to install from an npx or pnpm dlx cache: npm or pnpm can delete it, " +
54
+ "and the entry points and guards written here would then point at missing " +
55
+ "files. Install globally first (npm install -g @andromarces/agent-loops or " +
56
+ "pnpm add -g @andromarces/agent-loops), then run install again.";
57
+
58
+ /**
59
+ * True when the package root resolves inside an npx or pnpm dlx cache. The
60
+ * cache layout is a `_npx` or `dlx` directory segment, a longer hex hash, and a
61
+ * `node_modules` segment below both, so a path that merely names `dlx` does not
62
+ * match (#205).
63
+ */
64
+ export function isEphemeralPackageRoot(packageRoot) {
65
+ const segments = packageRoot.split(/[\\/]+/).filter(Boolean);
66
+ for (let i = 0; i < segments.length - 2; i++) {
67
+ if (!EPHEMERAL_CACHE_DIRS.has(segments[i])) continue;
68
+ if (!/^[0-9a-f]{16,}$/.test(segments[i + 1])) continue;
69
+ if (segments.slice(i + 2).includes("node_modules")) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ function planBackup(target, previous, current) {
75
+ const existedBefore = previous ? previous.existedBefore : current !== null;
76
+ const backupPath = previous?.backupPath ?? null;
77
+ const backup =
78
+ existedBefore && !backupPath && current !== null
79
+ ? { path: backupPathFor(target.path), content: current }
80
+ : null;
81
+ return { existedBefore, backupPath, backup };
82
+ }
83
+
84
+ async function planFileWrite(target, previous) {
85
+ const current = await readTextOrNull(target.path);
86
+ const currentSha = current === null ? null : sha256(current);
87
+ const desiredSha = sha256(target.content);
88
+
89
+ if (previous && currentSha !== null && currentSha !== previous.shaAfter) {
90
+ if (currentSha === desiredSha) {
91
+ return { kind: "file", action: "noop", path: target.path, record: previous };
92
+ }
93
+ return {
94
+ kind: "file",
95
+ action: "skip",
96
+ path: target.path,
97
+ detail: "owned file changed since install; left unchanged",
98
+ record: previous,
99
+ };
100
+ }
101
+
102
+ if (currentSha === desiredSha) {
103
+ const record = previous ?? {
104
+ kind: "file",
105
+ path: target.path,
106
+ existedBefore: current !== null,
107
+ shaBefore: currentSha,
108
+ shaAfter: desiredSha,
109
+ backupPath: null,
110
+ };
111
+ return { kind: "file", action: "noop", path: target.path, record };
112
+ }
113
+
114
+ const { existedBefore, backupPath, backup } = planBackup(target, previous, current);
115
+ return {
116
+ kind: "file",
117
+ action: existedBefore ? "update" : "create",
118
+ path: target.path,
119
+ content: target.content,
120
+ backup,
121
+ record: {
122
+ kind: "file",
123
+ path: target.path,
124
+ existedBefore,
125
+ shaBefore: previous ? previous.shaBefore : currentSha,
126
+ shaAfter: desiredSha,
127
+ backupPath: backup ? backup.path : backupPath,
128
+ },
129
+ };
130
+ }
131
+
132
+ async function planSettingsWrite(target, previous) {
133
+ const current = await readTextOrNull(target.path);
134
+ const currentSha = current === null ? null : sha256(current);
135
+ const snippet = manualSnippet(target.path, target.locator, target.entry);
136
+
137
+ let settings;
138
+ try {
139
+ settings = current === null ? {} : parseSettings(current, target.path);
140
+ } catch (err) {
141
+ return { kind: "settings", action: "refuse", path: target.path, detail: err.message, snippet };
142
+ }
143
+
144
+ const shape = validateLocator(settings, target.locator);
145
+ if (!shape.ok) {
146
+ return {
147
+ kind: "settings",
148
+ action: "refuse",
149
+ path: target.path,
150
+ detail: `settings file has an unexpected shape: ${shape.reason}`,
151
+ snippet,
152
+ };
153
+ }
154
+
155
+ let createdFrom = previous?.createdFrom;
156
+ if (previous) {
157
+ if (findEntryIndex(settings, previous.locator, previous.entry) === -1) {
158
+ return {
159
+ kind: "settings",
160
+ action: "skip",
161
+ path: target.path,
162
+ detail: "recorded entry not found; the user changed or removed it",
163
+ snippet,
164
+ record: previous,
165
+ };
166
+ }
167
+ replaceEntry(settings, target.locator, previous.entry, target.entry);
168
+ } else {
169
+ createdFrom = missingLocatorIndex(settings, target.locator);
170
+ const result = insertEntry(settings, target.locator, target.entry);
171
+ if (result.status === "conflict") {
172
+ return {
173
+ kind: "settings",
174
+ action: "skip",
175
+ path: target.path,
176
+ detail: "the named hook group already exists with different content",
177
+ snippet,
178
+ };
179
+ }
180
+ if (result.status === "duplicate") {
181
+ // The exact guard entry is already present but unowned. The guard is
182
+ // installed, so do not block the entry point; leave the entry unrecorded
183
+ // and uninstall never removes it.
184
+ return {
185
+ kind: "settings",
186
+ action: "noop",
187
+ path: target.path,
188
+ detail: "an identical guard entry is already present; left unowned",
189
+ };
190
+ }
191
+ }
192
+
193
+ const text = serializeSettings(settings, current);
194
+ const desiredSha = sha256(text);
195
+
196
+ if (currentSha === desiredSha) {
197
+ // No write: the current bytes already carry the desired entry. Keep the
198
+ // previous record, because its post-install hash may not match a file the
199
+ // user edited since install; advancing the hash would make uninstall treat
200
+ // those edits as installer-owned and restore the backup over them.
201
+ return {
202
+ kind: "settings",
203
+ action: "noop",
204
+ path: target.path,
205
+ record: previous ?? undefined,
206
+ };
207
+ }
208
+
209
+ const { existedBefore, backupPath, backup } = planBackup(target, previous, current);
210
+ // A write that starts from bytes other than the recorded post-install hash
211
+ // includes user edits. Mark the record so uninstall removes only the entry
212
+ // instead of restoring the backup over them.
213
+ const userEdited =
214
+ Boolean(previous && previous.shaAfter !== currentSha) || Boolean(previous?.userEdited);
215
+ const record = {
216
+ kind: "settings",
217
+ path: target.path,
218
+ locator: target.locator,
219
+ entry: target.entry,
220
+ existedBefore,
221
+ shaBefore: previous ? previous.shaBefore : currentSha,
222
+ shaAfter: desiredSha,
223
+ backupPath: backup ? backup.path : backupPath,
224
+ userEdited,
225
+ createdFrom: createdFrom ?? 0,
226
+ };
227
+
228
+ return {
229
+ kind: "settings",
230
+ action: existedBefore ? "update" : "create",
231
+ path: target.path,
232
+ content: text,
233
+ backup,
234
+ record,
235
+ };
236
+ }
237
+
238
+ async function applyWrite(plan, { dryRun, dirs, write }) {
239
+ if (dryRun || !plan.content) {
240
+ return;
241
+ }
242
+ await ensureDir(dirname(plan.path), dirs);
243
+ const mode = (await fileMode(plan.path)) ?? undefined;
244
+ if (plan.backup) {
245
+ await write(plan.backup.path, plan.backup.content, { mode });
246
+ }
247
+ try {
248
+ await write(plan.path, plan.content, { mode });
249
+ } catch (err) {
250
+ // The target kept its original bytes, so the backup just written is
251
+ // unneeded. Remove it before the error propagates.
252
+ if (plan.backup) {
253
+ await removeFileQuiet(plan.backup.path);
254
+ }
255
+ throw err;
256
+ }
257
+ }
258
+
259
+ function report(harness, plan) {
260
+ return {
261
+ harness,
262
+ kind: plan.kind,
263
+ action: plan.action,
264
+ path: plan.path,
265
+ ...(plan.detail ? { detail: plan.detail } : {}),
266
+ ...(plan.snippet ? { snippet: plan.snippet } : {}),
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Adds the previous record for every target without a record yet. A failed
272
+ * install leaves the bytes those targets held before, so uninstall must keep
273
+ * owning them (#156).
274
+ */
275
+ function keepPriorRecords(records, targets, priorRecords) {
276
+ for (const target of targets) {
277
+ if (records.some((record) => record.path === target.path)) {
278
+ continue;
279
+ }
280
+ const prior = priorRecords?.find((record) => record.path === target.path);
281
+ if (prior) {
282
+ records.push(prior);
283
+ }
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Installs one or more harnesses at user scope under the manifest lock. A dry
289
+ * run takes no lock and writes nothing.
290
+ * @returns {Promise<Array<{harness: string, kind: string, action: string, path: string, detail?: string, snippet?: string}>>}
291
+ */
292
+ export async function install(options = {}) {
293
+ const home = options.home ?? resolveHome();
294
+ if (typeof options.packageRoot === "string" && isEphemeralPackageRoot(options.packageRoot)) {
295
+ throw new Error(EPHEMERAL_ROOT_MESSAGE);
296
+ }
297
+ if (options.dryRun) {
298
+ return runInstall(options);
299
+ }
300
+ return withStateLock(manifestLockFile(home), () => runInstall(options), {
301
+ label: "The install manifest",
302
+ noun: "install manifest",
303
+ });
304
+ }
305
+
306
+ /** Runs the install once the caller owns the manifest lock, or for a dry run. */
307
+ async function runInstall({
308
+ harnesses,
309
+ home = resolveHome(),
310
+ packageRoot,
311
+ copilotHome = process.env.COPILOT_HOME,
312
+ dryRun = false,
313
+ write = writeTextAtomic,
314
+ } = {}) {
315
+ const manifest = await readManifest(home);
316
+ const plans = [];
317
+ let refusal = null;
318
+
319
+ // Plan every write before any write. An unparseable settings file stops the
320
+ // command with no write at all.
321
+ for (const harness of harnesses) {
322
+ const targets = await buildTargets(harness, { home, packageRoot, copilotHome });
323
+ const previous = manifest.harnesses[harness] ?? null;
324
+ const entry = {
325
+ harness,
326
+ previous,
327
+ dirs: new Set(previous?.dirs ?? []),
328
+ files: [],
329
+ settings: [],
330
+ };
331
+ for (const file of targets.files) {
332
+ const prior = previous?.files?.find((record) => record.path === file.path) ?? null;
333
+ entry.files.push({ path: file.path, plan: await planFileWrite(file, prior) });
334
+ }
335
+ for (const target of targets.settings) {
336
+ const prior = previous?.settings?.find((record) => record.path === target.path) ?? null;
337
+ const plan = await planSettingsWrite(target, prior);
338
+ if (plan.action === "refuse" && !refusal) {
339
+ refusal = plan;
340
+ }
341
+ entry.settings.push({ path: target.path, plan });
342
+ }
343
+
344
+ // No harness installs an entry point without its guard. When a settings
345
+ // target cannot be merged (a conflicting key, or a recorded entry the user
346
+ // removed), leave every entry-point file for that harness unchanged.
347
+ const guardBlocked = entry.settings.some(
348
+ ({ plan }) => plan.action === "skip" || plan.action === "refuse",
349
+ );
350
+ if (guardBlocked) {
351
+ entry.files = entry.files.map(({ path }) => {
352
+ const prior = previous?.files?.find((record) => record.path === path) ?? null;
353
+ return {
354
+ path,
355
+ plan: {
356
+ kind: "file",
357
+ action: "skip",
358
+ path,
359
+ detail: "guard settings were not installed; entry point left unchanged",
360
+ record: prior ?? undefined,
361
+ },
362
+ };
363
+ });
364
+ }
365
+ plans.push(entry);
366
+ }
367
+
368
+ if (refusal && !dryRun) {
369
+ const error = new Error(refusal.detail);
370
+ error.path = refusal.path;
371
+ error.snippet = refusal.snippet;
372
+ throw error;
373
+ }
374
+
375
+ const reports = [];
376
+ let active = null;
377
+ try {
378
+ for (const entry of plans) {
379
+ active = entry;
380
+ const record = { files: [], settings: [], dirs: entry.previous?.dirs ?? [] };
381
+ // Record the harness before its first write. A failure mid-harness then
382
+ // still leaves a manifest record that uninstall can act on.
383
+ if (!dryRun) {
384
+ manifest.harnesses[entry.harness] = record;
385
+ }
386
+ // Guard settings first: an entry point never lands without its guard,
387
+ // even when a later write fails.
388
+ for (const { plan } of entry.settings) {
389
+ await applyWrite(plan, { dryRun, dirs: entry.dirs, write });
390
+ if (plan.record) {
391
+ record.settings.push(plan.record);
392
+ }
393
+ reports.push(report(entry.harness, plan));
394
+ }
395
+ for (const { plan } of entry.files) {
396
+ await applyWrite(plan, { dryRun, dirs: entry.dirs, write });
397
+ if (plan.record) {
398
+ record.files.push(plan.record);
399
+ }
400
+ reports.push(report(entry.harness, plan));
401
+ }
402
+ record.dirs = [...entry.dirs];
403
+ active = null;
404
+ }
405
+ } catch (err) {
406
+ if (!dryRun) {
407
+ // Persist the completed writes before the error propagates, so uninstall
408
+ // restores every file this partial install touched, including the
409
+ // completed writes of earlier harnesses.
410
+ if (active) {
411
+ const record = manifest.harnesses[active.harness];
412
+ if (record) {
413
+ record.dirs = [...active.dirs];
414
+ // A target this install did not complete, including one an upgrade
415
+ // never reached, keeps its previous record so uninstall still owns
416
+ // the bytes the failed install left in place.
417
+ keepPriorRecords(record.settings, active.settings, active.previous?.settings);
418
+ keepPriorRecords(record.files, active.files, active.previous?.files);
419
+ }
420
+ }
421
+ await writeManifest(home, manifest).catch((saveError) => {
422
+ logWarn(
423
+ `install failed and the manifest could not be saved (${saveError.message}); ` +
424
+ "uninstall cannot undo the completed writes",
425
+ );
426
+ });
427
+ }
428
+ throw err;
429
+ }
430
+
431
+ if (harnesses.includes("codex")) {
432
+ reports.push({
433
+ harness: "codex",
434
+ kind: "settings",
435
+ action: "note",
436
+ path: join(home, ".codex", "hooks.json"),
437
+ detail:
438
+ "Codex requires review and trust of each non-managed hook through /hooks. " +
439
+ "A changed hook command needs a new trust step.",
440
+ });
441
+ if (!dryRun) {
442
+ reports.push({
443
+ harness: "codex",
444
+ kind: "settings",
445
+ action: "note",
446
+ path: join(home, ".agents", "skills"),
447
+ detail:
448
+ "The Codex skill lives in the shared ~/.agents/skills directory, which " +
449
+ "GitHub Copilot CLI and OpenCode also discover. The skill sets " +
450
+ "`metadata.opencode/autoinvoke: false`, so OpenCode drops it from the " +
451
+ "model's skill list and the OpenCode plugin command owns /agent-loop. " +
452
+ "The skill also runs the installed CLI by absolute path with " +
453
+ "`harness-check codex` and refuses to start from a Copilot or OpenCode " +
454
+ "session.",
455
+ });
456
+ }
457
+ }
458
+
459
+ if (harnesses.includes("claude") && !dryRun) {
460
+ reports.push({
461
+ harness: "claude",
462
+ kind: "settings",
463
+ action: "note",
464
+ path: join(home, ".claude", "skills"),
465
+ detail:
466
+ "OpenCode also discovers ~/.claude/skills, so it lists the Claude skill " +
467
+ "to the model. The skill sets `metadata.opencode/autoinvoke: false`, so " +
468
+ "OpenCode drops it from the model's skill list and the OpenCode plugin " +
469
+ "command owns /agent-loop. The skill also runs the installed CLI with " +
470
+ "`harness-check claude` and refuses to start from a foreign session.",
471
+ });
472
+ }
473
+
474
+ if (!dryRun) {
475
+ await writeManifest(home, manifest);
476
+ }
477
+ return reports;
478
+ }
479
+
480
+ async function restoreOrDelete(record, kind, dryRun) {
481
+ if (record.existedBefore) {
482
+ const backupFile = record.backupPath ?? backupPathFor(record.path);
483
+ const backup = await readTextOrNull(backupFile);
484
+ if (backup === null) {
485
+ return {
486
+ harness: record.harness,
487
+ kind,
488
+ action: "skip",
489
+ path: record.path,
490
+ detail: "backup missing; left unchanged",
491
+ };
492
+ }
493
+ if (!dryRun) {
494
+ await writeTextAtomic(record.path, backup, {
495
+ mode: (await fileMode(backupFile)) ?? undefined,
496
+ });
497
+ await removeFileQuiet(backupFile);
498
+ }
499
+ return { harness: record.harness, kind, action: "restore", path: record.path };
500
+ }
501
+ if (!dryRun) {
502
+ await removeFileQuiet(record.path);
503
+ }
504
+ return { harness: record.harness, kind, action: "delete", path: record.path };
505
+ }
506
+
507
+ async function planSettingsRestore(record, dryRun) {
508
+ const current = await readTextOrNull(record.path);
509
+ if (current === null) {
510
+ return { harness: record.harness, kind: "settings", action: "missing", path: record.path };
511
+ }
512
+ const currentSha = sha256(current);
513
+
514
+ if (currentSha === record.shaAfter && !record.userEdited) {
515
+ return restoreOrDelete(record, "settings", dryRun);
516
+ }
517
+
518
+ let settings;
519
+ try {
520
+ settings = parseSettings(current, record.path);
521
+ } catch {
522
+ return {
523
+ harness: record.harness,
524
+ kind: "settings",
525
+ action: "skip",
526
+ path: record.path,
527
+ detail: "settings do not parse; left unchanged",
528
+ };
529
+ }
530
+ if (!removeEntry(settings, record.locator, record.entry)) {
531
+ return {
532
+ harness: record.harness,
533
+ kind: "settings",
534
+ action: "skip",
535
+ path: record.path,
536
+ detail: "recorded entry not found; left unchanged",
537
+ };
538
+ }
539
+ pruneEmptyLocator(settings, record.locator, record.createdFrom ?? 0);
540
+ if (!record.existedBefore && Object.keys(settings).length === 0) {
541
+ // Install created this file and the user edited nothing else, so removing
542
+ // the entry empties it. Delete it instead of leaving `{}`.
543
+ if (!dryRun) {
544
+ await removeFileQuiet(record.path);
545
+ }
546
+ return { harness: record.harness, kind: "settings", action: "delete", path: record.path };
547
+ }
548
+ if (!dryRun) {
549
+ await writeTextAtomic(record.path, serializeSettings(settings, current));
550
+ }
551
+ return {
552
+ harness: record.harness,
553
+ kind: "settings",
554
+ action: "remove-entry",
555
+ path: record.path,
556
+ detail:
557
+ "the file changed after install; removed only the recorded entry, so the result is not byte-identical",
558
+ };
559
+ }
560
+
561
+ async function planFileRestore(record, dryRun) {
562
+ const current = await readTextOrNull(record.path);
563
+ if (current === null) {
564
+ return { harness: record.harness, kind: "file", action: "missing", path: record.path };
565
+ }
566
+ if (sha256(current) !== record.shaAfter) {
567
+ return {
568
+ harness: record.harness,
569
+ kind: "file",
570
+ action: "skip",
571
+ path: record.path,
572
+ detail: "owned file changed since install; left unchanged",
573
+ };
574
+ }
575
+ return restoreOrDelete(record, "file", dryRun);
576
+ }
577
+
578
+ /**
579
+ * Removes every target the manifest records for the selected harnesses under
580
+ * the manifest lock. A dry run takes no lock and writes nothing.
581
+ */
582
+ export async function uninstall(options = {}) {
583
+ const home = options.home ?? resolveHome();
584
+ if (options.dryRun) {
585
+ return runUninstall(options);
586
+ }
587
+ return withStateLock(manifestLockFile(home), () => runUninstall(options), {
588
+ label: "The install manifest",
589
+ noun: "install manifest",
590
+ });
591
+ }
592
+
593
+ /** Runs the uninstall once the caller owns the manifest lock, or for a dry run. */
594
+ async function runUninstall({ harnesses, home = resolveHome(), dryRun = false } = {}) {
595
+ const manifest = await readManifest(home);
596
+ const selected = (harnesses ?? HARNESS_ORDER).filter((harness) => manifest.harnesses[harness]);
597
+ const reports = [];
598
+
599
+ for (const harness of selected) {
600
+ const record = manifest.harnesses[harness];
601
+ for (const settings of record.settings ?? []) {
602
+ reports.push(await planSettingsRestore({ ...settings, harness }, dryRun));
603
+ }
604
+ for (const file of record.files ?? []) {
605
+ reports.push(await planFileRestore({ ...file, harness }, dryRun));
606
+ }
607
+ if (!dryRun) {
608
+ let removedDirs;
609
+ try {
610
+ removedDirs = await pruneEmptyDirs(record.dirs ?? []);
611
+ } catch (err) {
612
+ // The file and settings targets above were already restored or
613
+ // deleted, so keep only the directory list. Their stale records would
614
+ // make the next install read a missing recorded entry and skip the
615
+ // whole harness, while the directory still needs a later retry.
616
+ manifest.harnesses[harness] = { dirs: record.dirs ?? [] };
617
+ reports.push({
618
+ harness,
619
+ kind: "dir",
620
+ action: "failed",
621
+ path: err.path,
622
+ detail:
623
+ `the directory could not be removed (${err.code}); ` +
624
+ "kept for a later uninstall to retry",
625
+ });
626
+ continue;
627
+ }
628
+ // A record kept only for its directories has no file or settings report,
629
+ // so name each directory this retry removed instead of printing nothing.
630
+ if (
631
+ removedDirs.length > 0 &&
632
+ (record.files ?? []).length === 0 &&
633
+ (record.settings ?? []).length === 0
634
+ ) {
635
+ for (const dir of removedDirs) {
636
+ reports.push({ harness, kind: "dir", action: "delete", path: dir });
637
+ }
638
+ }
639
+ }
640
+ delete manifest.harnesses[harness];
641
+ }
642
+
643
+ if (!dryRun) {
644
+ if (Object.keys(manifest.harnesses).length === 0) {
645
+ await removeManifest(home);
646
+ } else {
647
+ await writeManifest(home, manifest);
648
+ }
649
+ }
650
+ return reports;
651
+ }
652
+
653
+ function executableCandidates(command) {
654
+ if (process.platform !== "win32") {
655
+ return [command];
656
+ }
657
+ const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";");
658
+ return [command, ...extensions.map((extension) => `${command}${extension.toLowerCase()}`)];
659
+ }
660
+
661
+ async function onPath(command) {
662
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
663
+ for (const dir of dirs) {
664
+ for (const candidate of executableCandidates(command)) {
665
+ try {
666
+ await access(join(dir, candidate));
667
+ return true;
668
+ } catch {
669
+ // Try the next candidate.
670
+ }
671
+ }
672
+ }
673
+ return false;
674
+ }
675
+
676
+ /** Harnesses whose CLI is found on PATH, in registry order. */
677
+ export async function detectHarnesses() {
678
+ const detected = [];
679
+ for (const harness of HARNESS_ORDER) {
680
+ for (const command of HARNESS_META[harness].commands) {
681
+ if (await onPath(command)) {
682
+ detected.push(harness);
683
+ break;
684
+ }
685
+ }
686
+ }
687
+ return detected;
688
+ }