@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.
- package/README.md +194 -114
- package/docs/orchestrator-instructions.md +25 -22
- package/package.json +2 -2
- package/src/agents/agy.mjs +2 -11
- package/src/agents/codex.mjs +3 -20
- package/src/agents/copilot.mjs +8 -16
- package/src/agents/opencode.mjs +2 -7
- package/src/agents/shared.mjs +29 -0
- package/src/cli.mjs +46 -25
- package/src/entrypoints/copilot.mjs +6 -1
- package/src/hook/antigravity-parent-guard.mjs +28 -0
- package/src/hook/copilot-parent-guard.mjs +5 -30
- package/src/hook/decision.mjs +59 -6
- package/src/hook/opencode-plugin.mjs +92 -0
- package/src/hook/parent-guard.mjs +8 -33
- package/src/install/commands.mjs +268 -0
- package/src/install/fsutil.mjs +159 -0
- package/src/install/harnesses.mjs +170 -0
- package/src/install/installer.mjs +688 -0
- package/src/install/manifest.mjs +222 -0
- package/src/install/settings.mjs +217 -0
- package/src/install/templates/antigravity/agent-loop-antigravity-parent-guard.mjs +14 -0
- package/src/install/templates/antigravity/hooks.json +16 -0
- package/src/install/templates/antigravity/skills/agent-loop/SKILL.md +24 -0
- package/src/install/templates/claude/skills/agent-loop/SKILL.md +33 -0
- package/src/install/templates/codex/skills/agent-loop/SKILL.md +30 -0
- package/src/install/templates/codex/skills/agent-loop/agents/openai.yaml +2 -0
- package/src/install/templates/copilot/hooks/parent-guard.json +15 -0
- package/src/install/templates/opencode/plugins/parent-guard.ts +11 -0
- package/src/lib/args.mjs +23 -0
- package/src/lib/hash.mjs +9 -0
- package/src/lib/log.mjs +18 -3
- package/src/lib/process-ancestry.mjs +104 -0
- package/src/lib/runstate.mjs +133 -38
- package/src/lib/snapshot.mjs +3 -7
- package/src/role.mjs +71 -87
- 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
|
+
}
|