agentlinkops 0.6.2 → 0.6.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.
@@ -1,12 +1,22 @@
1
- import { readFile, writeFile, mkdir, readdir, stat, lstat } from 'node:fs/promises';
2
- import { createHash } from 'node:crypto';
3
- import { resolve, join, dirname, relative, isAbsolute } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { homedir } from 'node:os';
6
- import { parseArgs } from './args.js';
7
- import { ConfigError } from './config.js';
8
- import { AGENT_CLIENTS, clientById } from '../shared/agent-clients.js';
9
- import { NUDGE } from './skill.js';
1
+ import {
2
+ readFile,
3
+ mkdir,
4
+ readdir,
5
+ lstat,
6
+ open,
7
+ rename,
8
+ unlink,
9
+ rm,
10
+ } from "node:fs/promises";
11
+ import { isDeepStrictEqual } from "node:util";
12
+ import { createHash } from "node:crypto";
13
+ import { resolve, join, dirname, relative, isAbsolute } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { homedir } from "node:os";
16
+ import { parseArgs } from "./args.js";
17
+ import { ConfigError } from "./config.js";
18
+ import { AGENT_CLIENTS, clientById } from "../shared/agent-clients.js";
19
+ import { NUDGE } from "./skill.js";
10
20
 
11
21
  // DP-0036-T09: `agentlinkops agent setup` (Stripe's `stripe agent setup` shape). It detects the
12
22
  // agent clients on this machine, installs the skill pack at each client's path with digest
@@ -14,28 +24,105 @@ import { NUDGE } from './skill.js';
14
24
  // client table in shared/agent-clients.js. It stores no credential: OAuth happens in the
15
25
  // client. It is idempotent, never overwrites a file it did not write, and prints exactly what
16
26
  // changed. `agent status` reports what is installed without touching anything.
17
- const USAGE = 'agent setup [--client ID ...] [--scope project|user] [--origin URL] [--pack DIR] [--dry-run] [--json] | agent status [--json]';
18
- export const DEFAULT_ORIGIN = 'https://app.agentlinkops.com';
19
- export const SERVER_NAME = 'agentlinkops';
20
- const RECEIPT = '.agentlinkops/agent-setup.json';
21
- const sha256 = data => createHash('sha256').update(data).digest('hex');
27
+ const USAGE =
28
+ "agent setup [--client ID ...] [--scope project|user] [--origin URL] [--pack DIR] [--dry-run] [--json] | agent status [--json] | agent remove|recover [--scope project|user] [--apply] [--json]";
29
+ export const DEFAULT_ORIGIN = "https://app.agentlinkops.com";
30
+ export const SERVER_NAME = "agentlinkops";
31
+ const RECEIPT = ".agentlinkops/agent-setup.json";
32
+ const SHARED = Symbol("package siblings");
33
+ const ignored = (path) =>
34
+ /(^|\/)(?:\._[^/]*|__pycache__|\.DS_Store)(?:\/|$)|\.(?:py[co]|sqlite(?:3)?(?:-[a-z]+)?|bak)$/u.test(
35
+ path,
36
+ );
37
+ async function safePath(path) {
38
+ if (
39
+ typeof path !== "string" ||
40
+ !isAbsolute(path) ||
41
+ path.split(/[\\/]/u).includes("..")
42
+ )
43
+ throw new ConfigError(
44
+ "Use a canonical absolute path without parent traversal.",
45
+ );
46
+ let current = resolve(path);
47
+ const components = [];
48
+ while (dirname(current) !== current) {
49
+ components.unshift(current);
50
+ current = dirname(current);
51
+ }
52
+ for (const part of components) {
53
+ let info;
54
+ try {
55
+ info = await lstat(part);
56
+ } catch (e) {
57
+ if (e.code === "ENOENT") continue;
58
+ throw e;
59
+ }
60
+ if (info.isSymbolicLink())
61
+ throw new ConfigError("Symlink installation paths are not supported.");
62
+ if (part !== resolve(path) && !info.isDirectory())
63
+ throw new ConfigError("Installation ancestor is not a directory.");
64
+ }
65
+ }
66
+ async function bytesAt(path) {
67
+ await safePath(path);
68
+ try {
69
+ const info = await lstat(path);
70
+ if (!info.isFile())
71
+ throw new ConfigError("Installation target is not a regular file.");
72
+ return await readFile(path);
73
+ } catch (e) {
74
+ if (e.code === "ENOENT") return null;
75
+ throw e;
76
+ }
77
+ }
78
+ const sha256 = (data) => createHash("sha256").update(data).digest("hex");
22
79
 
23
80
  // Where the pack's skills live: beside the packaged CLI (packages/agentlinkops-cli/skills), in
24
81
  // the assembled plugin, or in this repository's toolkit. The first that exists wins.
25
- export async function locatePack(explicit = null, from = fileURLToPath(import.meta.url)) {
26
- const candidates = explicit ? [explicit] : [join(dirname(from), '..', 'skills'), join(dirname(from), '..', 'dist', 'plugin', 'agentlinkops', 'skills'), join(dirname(from), '..', 'toolkit', 'plugin', 'agentlinkops', 'skills')];
82
+ export async function locatePack(
83
+ explicit = null,
84
+ from = fileURLToPath(import.meta.url),
85
+ ) {
86
+ const candidates = explicit
87
+ ? [explicit]
88
+ : [
89
+ join(dirname(from), "..", "skills"),
90
+ join(dirname(from), "..", "dist", "plugin", "agentlinkops", "skills"),
91
+ join(
92
+ dirname(from),
93
+ "..",
94
+ "toolkit",
95
+ "plugin",
96
+ "agentlinkops",
97
+ "skills",
98
+ ),
99
+ ];
27
100
  for (const dir of candidates) {
28
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
29
- if (entries?.some(e => e.isDirectory())) return resolve(dir);
101
+ if (dir.split(/[\\/]/u).includes(".."))
102
+ throw new ConfigError("Package path must not contain parent traversal.");
103
+ await safePath(resolve(dir));
104
+ const entries = await readdir(dir, { withFileTypes: true }).catch(
105
+ () => null,
106
+ );
107
+ if (entries?.some((e) => e.isDirectory())) return resolve(dir);
30
108
  }
31
- throw new ConfigError(explicit ? `No skills found under ${explicit}.` : 'No skill pack found beside this CLI; pass --pack DIR or install the agentlinkops package.');
109
+ throw new ConfigError(
110
+ explicit
111
+ ? `No skills found under ${explicit}.`
112
+ : "No skill pack found beside this CLI; pass --pack DIR or install the agentlinkops package.",
113
+ );
32
114
  }
33
115
 
34
116
  async function walk(dir, base = dir) {
35
117
  const out = [];
36
- for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
118
+ for (const entry of (await readdir(dir, { withFileTypes: true })).sort(
119
+ (a, b) => a.name.localeCompare(b.name),
120
+ )) {
37
121
  const full = join(dir, entry.name);
38
- if (entry.isDirectory()) out.push(...await walk(full, base));
122
+ if (ignored(relative(base, full).replaceAll("\\", "/"))) continue;
123
+ if (entry.isSymbolicLink())
124
+ throw new ConfigError("Package source contains a symlink.");
125
+ if (entry.isDirectory()) out.push(...(await walk(full, base)));
39
126
  else if (entry.isFile()) out.push(relative(base, full));
40
127
  }
41
128
  return out;
@@ -43,26 +130,71 @@ async function walk(dir, base = dir) {
43
130
 
44
131
  // The pack as {skillName: {relPath: {bytes, digest}}}; only directories with a SKILL.md count.
45
132
  export async function readPack(dir) {
133
+ dir = resolve(dir);
134
+ await safePath(dir);
46
135
  const skills = {};
47
- for (const entry of (await readdir(dir, { withFileTypes: true })).filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
136
+ const entries = await readdir(dir, { withFileTypes: true });
137
+ if (entries.some((e) => e.isSymbolicLink() && !ignored(e.name)))
138
+ throw new ConfigError("Package source contains a symlink.");
139
+ for (const entry of entries
140
+ .filter((e) => e.isDirectory())
141
+ .sort((a, b) => a.name.localeCompare(b.name))) {
48
142
  const root = join(dir, entry.name);
49
- if (!(await stat(join(root, 'SKILL.md')).catch(() => null))) continue;
143
+ if (ignored(entry.name)) continue;
144
+ if (!(await bytesAt(join(root, "SKILL.md")))) continue;
50
145
  const files = {};
51
- for (const rel of await walk(root)) { const bytes = await readFile(join(root, rel)); files[rel] = { bytes, digest: sha256(bytes) }; }
146
+ for (const rel of await walk(root)) {
147
+ const bytes = await readFile(join(root, rel));
148
+ files[rel] = { bytes, digest: sha256(bytes) };
149
+ }
52
150
  skills[entry.name] = files;
53
151
  }
54
- if (!Object.keys(skills).length) throw new ConfigError(`No skills with a SKILL.md under ${dir}.`);
152
+ if (!Object.keys(skills).length)
153
+ throw new ConfigError(`No skills with a SKILL.md under ${dir}.`);
154
+ const siblings = {};
155
+ for (const name of ["scripts", "references", "templates"]) {
156
+ const path = join(dir, "..", name);
157
+ await safePath(path);
158
+ const info = await lstat(path).catch((e) => {
159
+ if (e.code === "ENOENT") return null;
160
+ throw e;
161
+ });
162
+ if (!info) continue;
163
+ if (!info.isDirectory())
164
+ throw new ConfigError("Shared package dependency must be a directory.");
165
+ for (const rel of await walk(path)) {
166
+ const bytes = await readFile(join(path, rel));
167
+ siblings[`${name}/${rel}`] = { bytes, digest: sha256(bytes) };
168
+ }
169
+ }
170
+ const files = [
171
+ ...Object.values(skills).flatMap(Object.values),
172
+ ...Object.values(siblings),
173
+ ];
174
+ if (
175
+ files.length > 10000 ||
176
+ files.reduce((n, f) => n + f.bytes.length, 0) > 32 * 1024 * 1024
177
+ )
178
+ throw new ConfigError(
179
+ "Skill package exceeds the 10000-file/32MiB install bound.",
180
+ );
181
+ Object.defineProperty(skills, SHARED, { value: siblings });
55
182
  return skills;
56
183
  }
57
184
 
58
- const expand = (path, home) => path.startsWith('~/') ? join(home, path.slice(2)) : path;
59
- const exists = async path => !!(await lstat(path).catch(() => null));
185
+ const expand = (path, home) =>
186
+ path.startsWith("~/") ? join(home, path.slice(2)) : path;
187
+ const exists = async (path) => !!(await lstat(path).catch(() => null));
60
188
 
61
189
  export async function detectClients(home, only = null) {
62
190
  const found = [];
63
191
  for (const client of AGENT_CLIENTS) {
64
192
  if (only && !only.includes(client.id)) continue;
65
- const detected = only?.includes(client.id) ? true : (await Promise.all(client.detect.map(p => exists(expand(p, home))))).some(Boolean);
193
+ const detected = only?.includes(client.id)
194
+ ? true
195
+ : (
196
+ await Promise.all(client.detect.map((p) => exists(expand(p, home))))
197
+ ).some(Boolean);
66
198
  if (detected) found.push(client);
67
199
  }
68
200
  return found;
@@ -74,130 +206,911 @@ export async function detectClients(home, only = null) {
74
206
  // conflict | printed.
75
207
  function mcpEntry(client, url) {
76
208
  switch (client.mcp.format) {
77
- case 'mcpServers-json': return client.id === 'claude-code' ? { type: 'http', url } : { url };
78
- case 'mcpServers-httpUrl': return { httpUrl: url };
79
- default: return { url };
209
+ case "mcpServers-json":
210
+ return client.id === "claude-code" ? { type: "http", url } : { url };
211
+ case "mcpServers-httpUrl":
212
+ return { httpUrl: url };
213
+ default:
214
+ return { url };
80
215
  }
81
216
  }
82
217
  export async function planMcp(client, { cwd, home, origin, scope }) {
83
218
  const url = `${origin}${client.view}`;
84
219
  const entry = mcpEntry(client, url);
85
- const file = client.mcp.scope === 'user' || scope === 'user' ? expand(client.mcp.file, home) : join(cwd, client.mcp.file);
86
- if (client.mcp.format === 'toml-mcp_servers') {
87
- const text = await readFile(file, 'utf8').catch(() => '');
220
+ const userScope = client.mcp.scope === "user" || scope === "user";
221
+ const expanded = expand(client.mcp.file, home);
222
+ const file = isAbsolute(expanded)
223
+ ? expanded
224
+ : join(userScope ? home : cwd, expanded);
225
+ if (client.mcp.format === "toml-mcp_servers") {
226
+ const prior = await bytesAt(file),
227
+ text = prior?.toString("utf8") ?? "";
88
228
  const table = `[mcp_servers.${SERVER_NAME}]`;
89
- if (text.includes(table)) return { file, action: text.includes(`url = "${url}"`) ? 'unchanged' : 'conflict', entry, url, note: text.includes(`url = "${url}"`) ? undefined : `${table} already exists with a different url; edit it by hand.` };
90
- return { file, action: 'write', entry, url, text: `${text.length && !text.endsWith('\n') ? `${text}\n` : text}\n${table}\nurl = "${url}"\n` };
229
+ if (text.includes(table))
230
+ return {
231
+ file,
232
+ action: text.includes(`url = "${url}"`) ? "unchanged" : "conflict",
233
+ entry,
234
+ url,
235
+ note: text.includes(`url = "${url}"`)
236
+ ? undefined
237
+ : `${table} already exists with a different url; edit it by hand.`,
238
+ };
239
+ return {
240
+ file,
241
+ action: "write",
242
+ entry,
243
+ url,
244
+ beforeHash: prior === null ? null : sha256(prior),
245
+ text: `${text.length && !text.endsWith("\n") ? `${text}\n` : text}\n${table}\nurl = "${url}"\n`,
246
+ };
91
247
  }
92
- if (client.mcp.format === 'yaml-mcp_servers') return { file, action: 'printed', entry, url, snippet: `mcp_servers:\n ${SERVER_NAME}:\n url: ${url}\n` };
93
- if (client.id === 'claude-code' && (client.mcp.scope === 'user' || scope === 'user')) return { file: expand('~/.claude.json', home), action: 'printed', entry, url, snippet: `claude mcp add --transport http --scope user ${SERVER_NAME} ${url}` };
94
- const text = await readFile(file, 'utf8').catch(() => null);
248
+ if (client.mcp.format === "yaml-mcp_servers")
249
+ return {
250
+ file,
251
+ action: "printed",
252
+ entry,
253
+ url,
254
+ snippet: `mcp_servers:\n ${SERVER_NAME}:\n url: ${url}\n`,
255
+ };
256
+ if (
257
+ client.id === "claude-code" &&
258
+ (client.mcp.scope === "user" || scope === "user")
259
+ )
260
+ return {
261
+ file: expand("~/.claude.json", home),
262
+ action: "printed",
263
+ entry,
264
+ url,
265
+ snippet: `claude mcp add --transport http --scope user ${SERVER_NAME} ${url}`,
266
+ };
267
+ const prior = await bytesAt(file),
268
+ text = prior?.toString("utf8") ?? null;
95
269
  let config = {};
96
- if (text !== null) { try { config = JSON.parse(text); } catch { return { file, action: 'conflict', entry, url, note: 'existing file is not valid JSON; edit it by hand.' }; } }
270
+ if (text !== null) {
271
+ try {
272
+ config = JSON.parse(text);
273
+ } catch {
274
+ return {
275
+ file,
276
+ action: "conflict",
277
+ entry,
278
+ url,
279
+ note: "existing file is not valid JSON; edit it by hand.",
280
+ };
281
+ }
282
+ }
97
283
  const servers = config.mcpServers ?? {};
98
284
  const current = servers[SERVER_NAME];
99
- if (current && JSON.stringify(current) === JSON.stringify(entry)) return { file, action: 'unchanged', entry, url };
100
- if (current) return { file, action: 'conflict', entry, url, note: `mcpServers.${SERVER_NAME} already exists with a different entry; edit it by hand.` };
101
- return { file, action: 'write', entry, url, text: `${JSON.stringify({ ...config, mcpServers: { ...servers, [SERVER_NAME]: entry } }, null, 2)}\n` };
285
+ if (current && JSON.stringify(current) === JSON.stringify(entry))
286
+ return { file, action: "unchanged", entry, url };
287
+ if (current)
288
+ return {
289
+ file,
290
+ action: "conflict",
291
+ entry,
292
+ url,
293
+ note: `mcpServers.${SERVER_NAME} already exists with a different entry; edit it by hand.`,
294
+ };
295
+ return {
296
+ file,
297
+ action: "write",
298
+ entry,
299
+ url,
300
+ beforeHash: prior === null ? null : sha256(prior),
301
+ text: `${JSON.stringify({ ...config, mcpServers: { ...servers, [SERVER_NAME]: entry } }, null, 2)}\n`,
302
+ };
303
+ }
304
+
305
+ // Only sections created by this installer are owned. Existing matching entries are
306
+ // deliberately not adopted; older receipts without section ownership leave them alone.
307
+ function configFile(client, cwd, home, scope) {
308
+ const expanded = expand(client.mcp.file, home);
309
+ return isAbsolute(expanded)
310
+ ? expanded
311
+ : join(
312
+ client.mcp.scope === "user" || scope === "user" ? home : cwd,
313
+ expanded,
314
+ );
315
+ }
316
+ function validMcpOwnership(path, record, cwd, home, scope) {
317
+ const client = clientById(record?.client);
318
+ return (
319
+ client &&
320
+ record.format === client.mcp.format &&
321
+ path === configFile(client, cwd, home, scope) &&
322
+ ["mcpServers-json", "mcpServers-httpUrl", "toml-mcp_servers"].includes(
323
+ record.format,
324
+ ) &&
325
+ typeof record.url === "string" &&
326
+ /^https?:\/\//u.test(record.url) &&
327
+ !/[\r\n"\\]/u.test(record.url)
328
+ );
329
+ }
330
+ // Locate the owned JSON property without serializing foreign fields (including
331
+ // large numbers, duplicate foreign keys, whitespace and their exact bytes).
332
+ function removeJsonMcpSection(text) {
333
+ const tokens = [
334
+ ...text.matchAll(/"(?:\\[\s\S]|[^"\\])*"|[{}\[\],:]|[^\s{}\[\],:]+/gu),
335
+ ].map((m) => ({ text: m[0], start: m.index, end: m.index + m[0].length }));
336
+ function members(index) {
337
+ if (tokens[index]?.text !== "{") return null;
338
+ const result = [];
339
+ let i = index + 1;
340
+ while (tokens[i].text !== "}") {
341
+ const key = tokens[i],
342
+ valueIndex = i + 2;
343
+ i = valueIndex;
344
+ if (["{", "["].includes(tokens[i].text)) {
345
+ let depth = 0;
346
+ do {
347
+ if (["{", "["].includes(tokens[i].text)) depth++;
348
+ if (["}", "]"].includes(tokens[i].text)) depth--;
349
+ i++;
350
+ } while (depth);
351
+ } else i++;
352
+ result.push({
353
+ name: JSON.parse(key.text),
354
+ start: key.start,
355
+ end: tokens[i - 1].end,
356
+ valueIndex,
357
+ });
358
+ if (tokens[i].text === ",") i++;
359
+ }
360
+ return result;
361
+ }
362
+ const roots = members(0)?.filter((m) => m.name === "mcpServers");
363
+ if (roots?.length !== 1) return null;
364
+ const servers = members(roots[0].valueIndex);
365
+ const owned = servers?.filter((m) => m.name === SERVER_NAME);
366
+ if (owned?.length !== 1) return null;
367
+ const fields = members(owned[0].valueIndex);
368
+ if (!fields || new Set(fields.map((m) => m.name)).size !== fields.length)
369
+ return null;
370
+ const at = servers.indexOf(owned[0]);
371
+ const start =
372
+ at === servers.length - 1 && at > 0 ? servers[at - 1].end : owned[0].start;
373
+ const end = at < servers.length - 1 ? servers[at + 1].start : owned[0].end;
374
+ return text.slice(0, start) + text.slice(end);
375
+ }
376
+
377
+ async function planMcpRemoval(path, record) {
378
+ const before = await bytesAt(path);
379
+ if (before === null) return { absent: true };
380
+ const text = before.toString("utf8");
381
+ let next;
382
+ if (record.format === "toml-mcp_servers") {
383
+ if (text.includes('"""') || text.includes("'''"))
384
+ return { preserved: true };
385
+ const header = `[mcp_servers.${SERVER_NAME}]`;
386
+ const lines = text.split(/(?<=\n)/u);
387
+ const matches = lines
388
+ .map((line, index) => (line.trim() === header ? index : -1))
389
+ .filter((i) => i >= 0);
390
+ // Nested/quoted/duplicate definitions require manual review. This bounded
391
+ // writer owns only the exact simple table it generated, not arbitrary TOML.
392
+ const related = lines.filter((line) =>
393
+ /^\s*\[.*mcp_servers.*agentlinkops/u.test(line),
394
+ );
395
+ if (!matches.length && !related.length) return { absent: true };
396
+ if (matches.length !== 1 || related.length !== 1)
397
+ return { preserved: true };
398
+ const start = matches[0];
399
+ let end = start + 1;
400
+ while (end < lines.length && !/^\s*\[/u.test(lines[end])) end++;
401
+ const section = lines.slice(start, end).join("");
402
+ if (section.trimEnd() !== `${header}\nurl = "${record.url}"`)
403
+ return { preserved: true };
404
+ next = lines.slice(0, start).join("") + lines.slice(end).join("");
405
+ } else {
406
+ let config;
407
+ try {
408
+ config = JSON.parse(text);
409
+ } catch {
410
+ return { preserved: true };
411
+ }
412
+ if (!config || typeof config !== "object" || Array.isArray(config))
413
+ return { preserved: true };
414
+ const servers = config.mcpServers;
415
+ if (servers === undefined) return { absent: true };
416
+ if (!servers || typeof servers !== "object" || Array.isArray(servers))
417
+ return { preserved: true };
418
+ if (!Object.hasOwn(servers, SERVER_NAME)) return { absent: true };
419
+ if (
420
+ !isDeepStrictEqual(
421
+ servers[SERVER_NAME],
422
+ mcpEntry(clientById(record.client), record.url),
423
+ )
424
+ )
425
+ return { preserved: true };
426
+ next = removeJsonMcpSection(text);
427
+ if (next === null) return { preserved: true };
428
+ }
429
+ return {
430
+ operation: { path, bytes: Buffer.from(next), expected: sha256(before) },
431
+ };
102
432
  }
103
433
 
104
434
  // Skill installation plan for one client: per file, install | unchanged | conflict.
105
435
  export async function planSkills(client, pack, { cwd, home, scope, receipt }) {
106
- const base = scope === 'user' || !client.skills.project ? client.skills.user : client.skills.project;
436
+ const base =
437
+ scope === "user" || !client.skills.project
438
+ ? client.skills.user
439
+ : client.skills.project;
107
440
  const root = expand(base, home);
108
441
  const dir = isAbsolute(root) ? root : join(cwd, root);
109
- const plan = { dir, install: [], unchanged: [], conflicts: [] };
110
- const owned = new Set(Object.keys(receipt?.files ?? {}));
111
- for (const [name, files] of Object.entries(pack)) for (const [rel, { digest }] of Object.entries(files)) {
112
- const target = join(dir, name, rel);
113
- const existing = await readFile(target).catch(() => null);
114
- if (existing === null) { plan.install.push({ target, digest }); continue; }
115
- if (sha256(existing) === digest) { plan.unchanged.push(target); continue; }
116
- if (owned.has(target)) plan.install.push({ target, digest, replaces: receipt.files[target] });
117
- else plan.conflicts.push({ target, note: 'exists with different content and was not installed by agentlinkops; left untouched.' });
442
+ const plan = {
443
+ dir,
444
+ install: [],
445
+ unchanged: [],
446
+ conflicts: [],
447
+ desired: [],
448
+ roots: [
449
+ dir,
450
+ ...["scripts", "references", "templates"].map((name) =>
451
+ join(dir, "..", name),
452
+ ),
453
+ ],
454
+ };
455
+ const entries = [];
456
+ for (const [name, files] of Object.entries(pack))
457
+ for (const [rel, data] of Object.entries(files))
458
+ entries.push({ target: join(dir, name, rel), ...data });
459
+ for (const [rel, data] of Object.entries(pack[SHARED] ?? {}))
460
+ entries.push({ target: join(dir, "..", rel), ...data });
461
+ plan.desired = entries.map((e) => e.target);
462
+ for (const { target, digest, bytes } of entries) {
463
+ const existing = await bytesAt(target),
464
+ before = existing === null ? null : sha256(existing);
465
+ if (before === digest) {
466
+ plan.unchanged.push(target);
467
+ continue;
468
+ }
469
+ if (existing === null || receipt?.files?.[target] === before)
470
+ plan.install.push({
471
+ target,
472
+ digest,
473
+ bytes,
474
+ before,
475
+ replaces: receipt?.files?.[target],
476
+ });
477
+ else
478
+ plan.conflicts.push({
479
+ target,
480
+ note: "Existing file differs from the last installed digest or is not owned; customer content was left untouched.",
481
+ });
118
482
  }
119
483
  return plan;
120
484
  }
121
485
 
122
- async function readReceipt(cwd) { try { return JSON.parse(await readFile(join(cwd, RECEIPT), 'utf8')); } catch { return null; } }
486
+ function locations(cwd, home, scope) {
487
+ const base = scope === "user" ? home : cwd;
488
+ return {
489
+ receipt: join(
490
+ base,
491
+ scope === "user" ? ".agentlinkops/agent-setup-user.json" : RECEIPT,
492
+ ),
493
+ journal: join(base, `.agentlinkops/agent-setup-${scope}.journal.json`),
494
+ lock: join(base, `.agentlinkops/agent-setup-${scope}.lock`),
495
+ };
496
+ }
497
+ function allowedPath(path, cwd, home, receiptPath, controls = true) {
498
+ if (typeof path !== "string" || !isAbsolute(path) || resolve(path) !== path)
499
+ return false;
500
+ if (path === receiptPath) return controls;
501
+ for (const client of AGENT_CLIENTS) {
502
+ for (const base of [
503
+ client.skills.project ? join(cwd, client.skills.project) : null,
504
+ expand(client.skills.user, home),
505
+ ].filter(Boolean)) {
506
+ const rel = relative(base, path);
507
+ if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return true;
508
+ for (const sibling of ["scripts", "references", "templates"]) {
509
+ const r = relative(join(base, "..", sibling), path);
510
+ if (r && !r.startsWith("..") && !isAbsolute(r)) return true;
511
+ }
512
+ }
513
+ const config = client.mcp.file.startsWith("~/")
514
+ ? expand(client.mcp.file, home)
515
+ : join(cwd, client.mcp.file);
516
+ if (controls && (path === config || path === join(home, client.mcp.file)))
517
+ return true;
518
+ }
519
+ return false;
520
+ }
521
+ async function readReceipt(path, cwd, home) {
522
+ const bytes = await bytesAt(path);
523
+ if (bytes === null) return null;
524
+ let value;
525
+ try {
526
+ value = JSON.parse(bytes);
527
+ } catch {
528
+ throw new ConfigError(
529
+ "Installer receipt is invalid JSON; inspect it without overwriting.",
530
+ );
531
+ }
532
+ if (
533
+ !value ||
534
+ !value.files ||
535
+ typeof value.files !== "object" ||
536
+ Array.isArray(value.files) ||
537
+ Object.entries(value.files).some(
538
+ ([p, d]) =>
539
+ !allowedPath(p, cwd, home, path, false) ||
540
+ typeof d !== "string" ||
541
+ !/^[a-f0-9]{64}$/u.test(d),
542
+ )
543
+ )
544
+ throw new ConfigError("Installer ownership receipt is invalid.");
545
+ if (
546
+ value.mcp !== undefined &&
547
+ (!value.mcp ||
548
+ typeof value.mcp !== "object" ||
549
+ Array.isArray(value.mcp) ||
550
+ Object.entries(value.mcp).some(
551
+ ([p, r]) => !validMcpOwnership(p, r, cwd, home, value.scope),
552
+ ))
553
+ )
554
+ throw new ConfigError("Installer MCP ownership receipt is invalid.");
555
+ return value;
556
+ }
557
+ async function syncDirectory(path) {
558
+ let handle;
559
+ try {
560
+ handle = await open(path, "r");
561
+ await handle.sync();
562
+ } catch (error) {
563
+ // Some platforms/filesystems do not support directory handles/fsync.
564
+ if (
565
+ ![
566
+ "EINVAL",
567
+ "ENOTSUP",
568
+ "EISDIR",
569
+ "EBADF",
570
+ ...(process.platform === "win32" ? ["EPERM", "EACCES"] : []),
571
+ ].includes(error.code)
572
+ )
573
+ throw error;
574
+ } finally {
575
+ await handle?.close();
576
+ }
577
+ }
578
+ async function durableMkdir(path) {
579
+ const first = await mkdir(path, { recursive: true });
580
+ if (first) {
581
+ const boundary = dirname(first);
582
+ for (let current = path; ; current = dirname(current)) {
583
+ await syncDirectory(current);
584
+ if (current === boundary || current === dirname(current)) break;
585
+ }
586
+ }
587
+ }
588
+ async function durableUnlink(path) {
589
+ await unlink(path);
590
+ await syncDirectory(dirname(path));
591
+ }
592
+ async function atomicWrite(path, bytes) {
593
+ await safePath(path);
594
+ await durableMkdir(dirname(path));
595
+ const temporary = `${path}.agentlinkops-${crypto.randomUUID()}.tmp`;
596
+ const handle = await open(temporary, "wx", 0o600);
597
+ try {
598
+ await handle.writeFile(bytes);
599
+ await handle.sync();
600
+ } finally {
601
+ await handle.close();
602
+ }
603
+ try {
604
+ await rename(temporary, path);
605
+ await syncDirectory(dirname(path));
606
+ } finally {
607
+ await rm(temporary, { force: true });
608
+ }
609
+ }
610
+ async function transaction(paths, operations, { cwd, home, afterOperation }) {
611
+ await safePath(paths.lock);
612
+ await durableMkdir(dirname(paths.lock));
613
+ let lock;
614
+ try {
615
+ lock = await open(paths.lock, "wx", 0o600);
616
+ } catch (e) {
617
+ if (e.code === "EEXIST")
618
+ throw new ConfigError(
619
+ "Installer lock exists; inspect or recover the interrupted operation.",
620
+ );
621
+ throw e;
622
+ }
623
+ try {
624
+ await lock.writeFile(JSON.stringify({ pid: process.pid }));
625
+ await lock.sync();
626
+ if (await bytesAt(paths.journal))
627
+ throw new ConfigError(
628
+ "An interrupted installer transaction exists; recover first.",
629
+ );
630
+ const entries = [];
631
+ for (const op of operations) {
632
+ if (!allowedPath(op.path, cwd, home, paths.receipt))
633
+ throw new ConfigError("Unsafe planned installer target.");
634
+ const before = await bytesAt(op.path);
635
+ if ((before === null ? null : sha256(before)) !== op.expected)
636
+ throw new ConfigError(
637
+ "Installation target changed after planning; retry.",
638
+ );
639
+ entries.push({
640
+ path: op.path,
641
+ before: before?.toString("base64") ?? null,
642
+ after: op.bytes?.toString("base64") ?? null,
643
+ beforeHash: before === null ? null : sha256(before),
644
+ afterHash: op.bytes === null ? null : sha256(op.bytes),
645
+ });
646
+ }
647
+ const journal = { schema: 1, receipt: paths.receipt, entries };
648
+ await atomicWrite(paths.journal, Buffer.from(JSON.stringify(journal)));
649
+ for (let i = 0; i < entries.length; i++) {
650
+ const e = entries[i],
651
+ current = await bytesAt(e.path);
652
+ if ((current === null ? null : sha256(current)) !== e.beforeHash)
653
+ throw new ConfigError(
654
+ "Installation target changed during apply; recover before retrying.",
655
+ );
656
+ if (e.after === null) await durableUnlink(e.path);
657
+ else await atomicWrite(e.path, Buffer.from(e.after, "base64"));
658
+ await afterOperation?.(i);
659
+ }
660
+ await durableUnlink(paths.journal);
661
+ } finally {
662
+ await lock.close();
663
+ await rm(paths.lock, { force: true });
664
+ }
665
+ }
666
+ async function recover(paths, { cwd, home, apply }) {
667
+ const lock = await bytesAt(paths.lock);
668
+ if (lock) {
669
+ let pid;
670
+ try {
671
+ pid = JSON.parse(lock).pid;
672
+ } catch {
673
+ throw new ConfigError("Invalid installer lock.");
674
+ }
675
+ if (!Number.isInteger(pid) || pid < 1)
676
+ throw new ConfigError("Invalid installer lock.");
677
+ try {
678
+ process.kill(pid, 0);
679
+ throw new ConfigError("Installer process is still running.");
680
+ } catch (e) {
681
+ if (e.code !== "ESRCH") throw e;
682
+ }
683
+ }
684
+ const bytes = await bytesAt(paths.journal);
685
+ if (!bytes)
686
+ throw new ConfigError(
687
+ "No recovery journal; inspect any stale lock manually.",
688
+ );
689
+ let j;
690
+ try {
691
+ j = JSON.parse(bytes);
692
+ } catch {
693
+ throw new ConfigError("Invalid recovery journal.");
694
+ }
695
+ if (
696
+ j.schema !== 1 ||
697
+ j.receipt !== paths.receipt ||
698
+ !Array.isArray(j.entries) ||
699
+ j.entries.length > 20000
700
+ )
701
+ throw new ConfigError("Invalid recovery journal.");
702
+ const seen = new Set();
703
+ for (const e of j.entries) {
704
+ if (!allowedPath(e.path, cwd, home, paths.receipt) || seen.has(e.path))
705
+ throw new ConfigError("Unsafe recovery target.");
706
+ seen.add(e.path);
707
+ for (const key of ["before", "after"]) {
708
+ const bytes =
709
+ e[key] === null
710
+ ? null
711
+ : typeof e[key] === "string"
712
+ ? Buffer.from(e[key], "base64")
713
+ : undefined;
714
+ if (
715
+ bytes === undefined ||
716
+ (bytes === null ? null : sha256(bytes)) !== e[`${key}Hash`]
717
+ )
718
+ throw new ConfigError("Corrupt installer recovery backup.");
719
+ }
720
+ const current = await bytesAt(e.path),
721
+ hash = current === null ? null : sha256(current);
722
+ if (hash !== e.beforeHash && hash !== e.afterHash)
723
+ throw new ConfigError(
724
+ "Customer edits conflict with recovery; no rollback performed.",
725
+ );
726
+ }
727
+ if (apply) {
728
+ if (lock) await rm(paths.lock, { force: true });
729
+ const handle = await open(paths.lock, "wx", 0o600);
730
+ try {
731
+ await handle.writeFile(JSON.stringify({ pid: process.pid }));
732
+ for (const e of [...j.entries].reverse()) {
733
+ const current = await bytesAt(e.path),
734
+ hash = current === null ? null : sha256(current);
735
+ if (hash !== e.beforeHash && hash !== e.afterHash)
736
+ throw new ConfigError("Customer edits conflict with recovery.");
737
+ if (e.before === null) {
738
+ if (current !== null) await durableUnlink(e.path);
739
+ } else await atomicWrite(e.path, Buffer.from(e.before, "base64"));
740
+ }
741
+ await durableUnlink(paths.journal);
742
+ } finally {
743
+ await handle.close();
744
+ await rm(paths.lock, { force: true });
745
+ }
746
+ }
747
+ return {
748
+ status: apply ? "recovered" : "preview",
749
+ operations: j.entries.length,
750
+ };
751
+ }
123
752
 
124
- export async function agentMain(argv, { cwd = process.cwd(), home = homedir(), env = process.env, out = console.log, err = console.error, isTTY = process.stdout.isTTY === true, packDir = null, now = () => new Date().toISOString() } = {}) {
125
- const args = parseArgs(argv, ['client']);
753
+ export async function agentMain(
754
+ argv,
755
+ {
756
+ cwd = process.cwd(),
757
+ home = homedir(),
758
+ env = process.env,
759
+ out = console.log,
760
+ err = console.error,
761
+ isTTY = process.stdout.isTTY === true,
762
+ packDir = null,
763
+ now = () => new Date().toISOString(),
764
+ afterOperation,
765
+ } = {},
766
+ ) {
767
+ const args = parseArgs(argv, ["client"]);
126
768
  const [command, sub] = args._;
127
- if (command !== 'agent' || !['setup', 'status'].includes(sub) || args._.length !== 2) throw new ConfigError(USAGE);
128
- const allowed = sub === 'setup' ? ['_', 'client', 'scope', 'origin', 'pack', 'dry-run', 'json'] : ['_', 'json'];
129
- if (Object.keys(args).some(k => !allowed.includes(k))) throw new ConfigError(USAGE);
769
+ if (
770
+ command !== "agent" ||
771
+ !["setup", "status", "remove", "recover"].includes(sub) ||
772
+ args._.length !== 2
773
+ )
774
+ throw new ConfigError(USAGE);
775
+ const allowed =
776
+ sub === "setup"
777
+ ? ["_", "client", "scope", "origin", "pack", "dry-run", "json"]
778
+ : ["remove", "recover"].includes(sub)
779
+ ? ["_", "scope", "apply", "json"]
780
+ : ["_", "json"];
781
+ if (Object.keys(args).some((k) => !allowed.includes(k)))
782
+ throw new ConfigError(USAGE);
130
783
  const json = args.json === true || !isTTY;
131
- const scope = args.scope ?? 'project';
132
- if (!['project', 'user'].includes(scope)) throw new ConfigError('--scope is project or user');
133
- const origin = (args.origin ?? env.AGENTLINKOPS_API_URL ?? env.LINKTRAIL_API_URL ?? DEFAULT_ORIGIN).replace(/\/$/u, '');
134
- if (!/^https:\/\/[^/?#]+$/u.test(origin)) throw new ConfigError('--origin must be an https origin without a path.');
784
+ const scope = args.scope ?? "project";
785
+ if (!["project", "user"].includes(scope))
786
+ throw new ConfigError("--scope is project or user");
787
+ const origin = (
788
+ args.origin ??
789
+ env.AGENTLINKOPS_API_URL ??
790
+ env.LINKTRAIL_API_URL ??
791
+ DEFAULT_ORIGIN
792
+ ).replace(/\/$/u, "");
793
+ if (!/^https:\/\/[^/?#]+$/u.test(origin))
794
+ throw new ConfigError("--origin must be an https origin without a path.");
135
795
  const only = args.client ? [].concat(args.client) : null;
136
- for (const id of only ?? []) if (!clientById(id)) throw new ConfigError(`Unknown client ${id}. Clients: ${AGENT_CLIENTS.map(c => c.id).join(', ')}.`);
137
- const receipt = await readReceipt(cwd);
796
+ for (const id of only ?? [])
797
+ if (!clientById(id))
798
+ throw new ConfigError(
799
+ `Unknown client ${id}. Clients: ${AGENT_CLIENTS.map((c) => c.id).join(", ")}.`,
800
+ );
801
+ if ([cwd, home].some((p) => p.split(/[\\/]/u).includes("..")))
802
+ throw new ConfigError(
803
+ "Installation roots must not contain parent traversal.",
804
+ );
805
+ cwd = resolve(cwd);
806
+ home = resolve(home);
807
+ await safePath(cwd);
808
+ await safePath(home);
809
+ const paths = locations(cwd, home, scope);
810
+ if (sub === "recover") {
811
+ const report = await recover(paths, {
812
+ cwd,
813
+ home,
814
+ apply: args.apply === true,
815
+ });
816
+ out(JSON.stringify(report, null, 2));
817
+ return 0;
818
+ }
819
+ const receipt = await readReceipt(paths.receipt, cwd, home);
820
+ if (sub === "remove") {
821
+ const operations = [],
822
+ preserved = [];
823
+ for (const [path, digest] of Object.entries(receipt?.files ?? {})) {
824
+ const bytes = await bytesAt(path);
825
+ if (bytes === null) continue;
826
+ if (sha256(bytes) !== digest) {
827
+ preserved.push(path);
828
+ continue;
829
+ }
830
+ operations.push({ path, bytes: null, expected: digest });
831
+ }
832
+ const remainingMcp = { ...(receipt?.mcp ?? {}) };
833
+ const configurations = [];
834
+ for (const [path, record] of Object.entries(remainingMcp)) {
835
+ const planned = await planMcpRemoval(path, record);
836
+ if (planned.preserved) {
837
+ preserved.push(path);
838
+ continue;
839
+ }
840
+ delete remainingMcp[path];
841
+ if (planned.operation) {
842
+ operations.push(planned.operation);
843
+ configurations.push(path);
844
+ }
845
+ }
846
+ const remaining = Object.fromEntries(
847
+ Object.entries(receipt?.files ?? {}).filter(([p]) =>
848
+ preserved.includes(p),
849
+ ),
850
+ );
851
+ if (receipt) {
852
+ const prior = await bytesAt(paths.receipt);
853
+ operations.push({
854
+ path: paths.receipt,
855
+ bytes:
856
+ Object.keys(remaining).length || Object.keys(remainingMcp).length
857
+ ? Buffer.from(
858
+ JSON.stringify(
859
+ { ...receipt, files: remaining, mcp: remainingMcp },
860
+ null,
861
+ 2,
862
+ ) + "\n",
863
+ )
864
+ : null,
865
+ expected: sha256(prior),
866
+ });
867
+ }
868
+ if (args.apply === true && operations.length)
869
+ await transaction(paths, operations, { cwd, home, afterOperation });
870
+ out(
871
+ JSON.stringify(
872
+ {
873
+ status: args.apply === true ? "removed" : "preview",
874
+ removed: operations
875
+ .filter((o) => o.path !== paths.receipt && o.bytes === null)
876
+ .map((o) => o.path),
877
+ preserved,
878
+ configurations,
879
+ },
880
+ null,
881
+ 2,
882
+ ),
883
+ );
884
+ return preserved.length ? 1 : 0;
885
+ }
886
+ if (sub === "setup" && (await bytesAt(paths.journal)))
887
+ throw new ConfigError(
888
+ "An interrupted installer transaction exists; recover first.",
889
+ );
138
890
 
139
- if (sub === 'status') {
891
+ if (sub === "status") {
140
892
  const clients = await detectClients(home, only);
141
893
  const report = { origin, clients: [] };
142
894
  for (const client of clients) {
143
895
  const mcp = await planMcp(client, { cwd, home, origin, scope });
144
- report.clients.push({ id: client.id, name: client.name, view: `${origin}${client.view}`, mcp: { file: mcp.file, state: mcp.action === 'unchanged' ? 'configured' : mcp.action === 'conflict' ? 'different' : 'absent' } });
896
+ report.clients.push({
897
+ id: client.id,
898
+ name: client.name,
899
+ view: `${origin}${client.view}`,
900
+ mcp: {
901
+ file: mcp.file,
902
+ state:
903
+ mcp.action === "unchanged"
904
+ ? "configured"
905
+ : mcp.action === "conflict"
906
+ ? "different"
907
+ : "absent",
908
+ },
909
+ });
145
910
  }
146
- report.receipt = receipt ? { installedAt: receipt.installedAt, files: Object.keys(receipt.files).length } : null;
911
+ report.receipt = receipt
912
+ ? {
913
+ installedAt: receipt.installedAt,
914
+ files: Object.keys(receipt.files).length,
915
+ }
916
+ : null;
147
917
  out(json ? JSON.stringify(report, null, 2) : renderStatus(report));
148
918
  return 0;
149
919
  }
150
920
 
151
921
  const pack = await readPack(await locatePack(args.pack ?? packDir));
152
922
  const clients = await detectClients(home, only);
153
- const report = { origin, scope, dryRun: args['dry-run'] === true, clients: [], next: [] };
154
- if (!clients.length) { report.next.push(`No agent client detected. Pass --client with one of ${AGENT_CLIENTS.map(c => c.id).join(', ')}.`); out(json ? JSON.stringify(report, null, 2) : report.next.join('\n')); return 2; }
155
- const ownedFiles = { ...(receipt?.files ?? {}) };
923
+ const report = {
924
+ origin,
925
+ scope,
926
+ dryRun: args["dry-run"] === true,
927
+ clients: [],
928
+ next: [],
929
+ };
930
+ if (!clients.length) {
931
+ report.next.push(
932
+ `No agent client detected. Pass --client with one of ${AGENT_CLIENTS.map((c) => c.id).join(", ")}.`,
933
+ );
934
+ out(json ? JSON.stringify(report, null, 2) : report.next.join("\n"));
935
+ return 2;
936
+ }
937
+ const ownedFiles = { ...(receipt?.files ?? {}) },
938
+ operations = new Map();
939
+ const ownedMcp = { ...(receipt?.mcp ?? {}) },
940
+ desired = new Set(),
941
+ selectedRoots = new Set();
156
942
  for (const client of clients) {
157
- const skills = await planSkills(client, pack, { cwd, home, scope, receipt });
943
+ const skills = await planSkills(client, pack, {
944
+ cwd,
945
+ home,
946
+ scope,
947
+ receipt,
948
+ });
949
+ for (const path of skills.desired) desired.add(path);
950
+ for (const path of skills.roots) selectedRoots.add(path);
158
951
  const mcp = await planMcp(client, { cwd, home, origin, scope });
159
- const entry = { id: client.id, name: client.name, view: mcp.url, reason: client.reason, skills: { dir: skills.dir, installed: [], unchanged: skills.unchanged.length, conflicts: skills.conflicts }, mcp: { file: mcp.file, action: mcp.action, ...(mcp.note ? { note: mcp.note } : {}), ...(mcp.snippet ? { snippet: mcp.snippet } : {}) } };
160
- if (!report.dryRun) {
161
- for (const { target, digest } of skills.install) {
162
- const [name, ...rest] = relative(skills.dir, target).split(/[\\/]/u);
163
- await mkdir(dirname(target), { recursive: true });
164
- await writeFile(target, pack[name][rest.join('/')].bytes);
165
- ownedFiles[target] = digest;
166
- entry.skills.installed.push(target);
167
- }
168
- if (mcp.action === 'write') { await mkdir(dirname(mcp.file), { recursive: true }); await writeFile(mcp.file, mcp.text); entry.mcp.action = 'written'; }
169
- } else {
170
- entry.skills.installed = skills.install.map(i => i.target);
171
- if (mcp.action === 'write') entry.mcp.action = 'would write';
172
- }
173
- if (mcp.action === 'printed') report.next.push(`${client.name}: add the MCP server yourself:\n${mcp.snippet}`);
174
- if (mcp.action === 'conflict') report.next.push(`${client.name}: ${mcp.note}`);
175
- for (const c of skills.conflicts) report.next.push(`${client.name}: ${c.target} ${c.note}`);
952
+ const entry = {
953
+ id: client.id,
954
+ name: client.name,
955
+ view: mcp.url,
956
+ reason: client.reason,
957
+ skills: {
958
+ dir: skills.dir,
959
+ installed: [],
960
+ unchanged: skills.unchanged.length,
961
+ conflicts: skills.conflicts,
962
+ },
963
+ mcp: {
964
+ file: mcp.file,
965
+ action: mcp.action,
966
+ ...(mcp.note ? { note: mcp.note } : {}),
967
+ ...(mcp.snippet ? { snippet: mcp.snippet } : {}),
968
+ },
969
+ };
970
+ for (const item of skills.install) {
971
+ operations.set(item.target, {
972
+ path: item.target,
973
+ bytes: item.bytes,
974
+ expected: item.before,
975
+ });
976
+ ownedFiles[item.target] = item.digest;
977
+ entry.skills.installed.push(item.target);
978
+ }
979
+ if (mcp.action === "write") {
980
+ ownedMcp[mcp.file] = {
981
+ client: client.id,
982
+ format: client.mcp.format,
983
+ url: mcp.url,
984
+ };
985
+ operations.set(mcp.file, {
986
+ path: mcp.file,
987
+ bytes: Buffer.from(mcp.text),
988
+ expected: mcp.beforeHash,
989
+ });
990
+ entry.mcp.action = report.dryRun ? "would write" : "planned";
991
+ }
992
+ if (mcp.action === "printed")
993
+ report.next.push(
994
+ `${client.name}: add the MCP server yourself:\n${mcp.snippet}`,
995
+ );
996
+ if (mcp.action === "conflict")
997
+ report.next.push(`${client.name}: ${mcp.note}`);
998
+ for (const c of skills.conflicts)
999
+ report.next.push(`${client.name}: ${c.target} ${c.note}`);
176
1000
  report.clients.push(entry);
177
1001
  }
178
- if (!report.dryRun) {
179
- await mkdir(join(cwd, '.agentlinkops'), { recursive: true });
180
- await writeFile(join(cwd, RECEIPT), `${JSON.stringify({ installedAt: now(), origin, scope, clients: report.clients.map(c => c.id), files: ownedFiles }, null, 2)}\n`);
1002
+ report.pruned = [];
1003
+ report.preservedObsolete = [];
1004
+ let ownershipChanged = false;
1005
+ for (const [path, digest] of Object.entries(ownedFiles)) {
1006
+ if (
1007
+ desired.has(path) ||
1008
+ ![...selectedRoots].some((root) => {
1009
+ const rel = relative(root, path);
1010
+ return rel && !rel.startsWith("..") && !isAbsolute(rel);
1011
+ })
1012
+ )
1013
+ continue;
1014
+ const current = await bytesAt(path);
1015
+ if (current !== null && sha256(current) !== digest) {
1016
+ report.preservedObsolete.push(path);
1017
+ continue;
1018
+ }
1019
+ delete ownedFiles[path];
1020
+ ownershipChanged = true;
1021
+ if (current !== null) {
1022
+ operations.set(path, { path, bytes: null, expected: digest });
1023
+ report.pruned.push(path);
1024
+ }
1025
+ }
1026
+ const conflicts = report.clients.some(
1027
+ (c) => c.skills.conflicts.length || c.mcp.action === "conflict",
1028
+ );
1029
+ if (
1030
+ !report.dryRun &&
1031
+ !conflicts &&
1032
+ (operations.size || ownershipChanged || !receipt)
1033
+ ) {
1034
+ const oldReceipt = await bytesAt(paths.receipt),
1035
+ next = Buffer.from(
1036
+ JSON.stringify(
1037
+ {
1038
+ installedAt: now(),
1039
+ origin,
1040
+ scope,
1041
+ clients: report.clients.map((c) => c.id),
1042
+ files: ownedFiles,
1043
+ mcp: ownedMcp,
1044
+ },
1045
+ null,
1046
+ 2,
1047
+ ) + "\n",
1048
+ );
1049
+ operations.set(paths.receipt, {
1050
+ path: paths.receipt,
1051
+ bytes: next,
1052
+ expected: oldReceipt === null ? null : sha256(oldReceipt),
1053
+ });
1054
+ await transaction(paths, [...operations.values()], {
1055
+ cwd,
1056
+ home,
1057
+ afterOperation,
1058
+ });
1059
+ for (const c of report.clients)
1060
+ if (c.mcp.action === "planned") c.mcp.action = "written";
1061
+ }
1062
+ if (conflicts) {
1063
+ report.pruned = [];
1064
+ report.next.push(
1065
+ "Conflicts prevented all installation writes. Resolve them and retry.",
1066
+ );
1067
+ for (const c of report.clients) {
1068
+ c.skills.installed = [];
1069
+ if (c.mcp.action === "planned") c.mcp.action = "not applied";
1070
+ }
181
1071
  }
182
- report.next.push('Sign in through each client\'s MCP OAuth flow; no credential was stored by this command.');
1072
+ report.next.push(
1073
+ "Sign in through each client's MCP OAuth flow; no credential was stored by this command.",
1074
+ );
183
1075
  report.next.push(NUDGE);
184
1076
  out(json ? JSON.stringify(report, null, 2) : renderSetup(report));
185
- return report.clients.some(c => c.skills.conflicts.length || c.mcp.action === 'conflict') ? 1 : 0;
1077
+ return report.clients.some(
1078
+ (c) => c.skills.conflicts.length || c.mcp.action === "conflict",
1079
+ )
1080
+ ? 1
1081
+ : 0;
186
1082
  }
187
1083
 
188
1084
  function renderStatus(report) {
189
1085
  const lines = [`origin ${report.origin}`];
190
- for (const c of report.clients) lines.push(`${c.name.padEnd(12)} ${c.mcp.state.padEnd(11)} ${c.view} (${c.mcp.file})`);
191
- lines.push(report.receipt ? `receipt: ${report.receipt.files} files installed at ${report.receipt.installedAt}` : 'receipt: none (run agentlinkops agent setup)');
192
- return lines.join('\n');
1086
+ for (const c of report.clients)
1087
+ lines.push(
1088
+ `${c.name.padEnd(12)} ${c.mcp.state.padEnd(11)} ${c.view} (${c.mcp.file})`,
1089
+ );
1090
+ lines.push(
1091
+ report.receipt
1092
+ ? `receipt: ${report.receipt.files} files installed at ${report.receipt.installedAt}`
1093
+ : "receipt: none (run agentlinkops agent setup)",
1094
+ );
1095
+ return lines.join("\n");
193
1096
  }
194
1097
  function renderSetup(report) {
195
- const lines = [`${report.dryRun ? 'plan' : 'done'}: origin ${report.origin}, scope ${report.scope}`];
1098
+ const lines = [
1099
+ `${report.dryRun ? "plan" : "done"}: origin ${report.origin}, scope ${report.scope}`,
1100
+ ];
196
1101
  for (const c of report.clients) {
197
1102
  lines.push(`${c.name}: view ${c.view}`);
198
- lines.push(` skills → ${c.skills.dir}: ${c.skills.installed.length} ${report.dryRun ? 'to install' : 'installed'}, ${c.skills.unchanged} unchanged${c.skills.conflicts.length ? `, ${c.skills.conflicts.length} conflicts` : ''}`);
1103
+ lines.push(
1104
+ ` skills → ${c.skills.dir}: ${c.skills.installed.length} ${report.dryRun ? "to install" : "installed"}, ${c.skills.unchanged} unchanged${c.skills.conflicts.length ? `, ${c.skills.conflicts.length} conflicts` : ""}`,
1105
+ );
199
1106
  lines.push(` mcp → ${c.mcp.file}: ${c.mcp.action}`);
200
1107
  }
201
- for (const n of report.next) lines.push('', n);
202
- return lines.join('\n');
1108
+ for (const path of report.pruned ?? [])
1109
+ lines.push(
1110
+ ` obsolete → ${path}: ${report.dryRun ? "would remove" : "removed"}`,
1111
+ );
1112
+ for (const path of report.preservedObsolete ?? [])
1113
+ lines.push(` obsolete → ${path}: preserved customer edits`);
1114
+ for (const n of report.next) lines.push("", n);
1115
+ return lines.join("\n");
203
1116
  }