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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +9 -1
- package/cli/admission.js +130 -0
- package/cli/agent-setup.js +1014 -101
- package/cli/catalog.js +5742 -0
- package/cli/citation.js +1 -1
- package/cli/client.js +1 -1
- package/cli/commands.js +3 -3
- package/cli/disavow.js +54 -0
- package/cli/doctor.js +7 -8
- package/cli/ledger.js +7 -1
- package/cli/lifecycle.js +40 -0
- package/cli/main.js +17 -4
- package/cli/sync.js +18 -0
- package/package.json +1 -1
- package/references/recipes/catalog.json +148 -0
- package/references/recipes/command-notes.md +9 -0
- package/references/recipes/placement-reconciliation.md +41 -0
- package/references/recipes/qualified-campaign-handoff.md +56 -0
- package/references/recipes/site-context-brief.md +25 -0
- package/references/recipes/sourced-linkable-asset.md +25 -0
- package/shared/admission-contract.js +16 -0
- package/shared/admission-report-contract.js +8 -0
- package/shared/disavow-contract.js +18 -0
- package/shared/lifecycle-contract.js +22 -0
- package/shared/lifecycle-money.js +24 -0
- package/shared/response-contract.js +2 -0
- package/skills/agentlinkops-assets/SKILL.md +2 -0
- package/skills/agentlinkops-campaigns/SKILL.md +2 -0
- package/skills/agentlinkops-connect/SKILL.md +4 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +8 -4
- package/skills/agentlinkops-crm/SKILL.md +2 -0
- package/src/tool-views.js +6 -2
package/cli/agent-setup.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 =
|
|
18
|
-
|
|
19
|
-
export const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
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(
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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)) {
|
|
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)
|
|
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) =>
|
|
59
|
-
|
|
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)
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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))
|
|
90
|
-
|
|
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 ===
|
|
93
|
-
|
|
94
|
-
|
|
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) {
|
|
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))
|
|
100
|
-
|
|
101
|
-
|
|
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 =
|
|
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 = {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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(
|
|
125
|
-
|
|
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 (
|
|
128
|
-
|
|
129
|
-
|
|
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 ??
|
|
132
|
-
if (![
|
|
133
|
-
|
|
134
|
-
|
|
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 ?? [])
|
|
137
|
-
|
|
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 ===
|
|
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({
|
|
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
|
|
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 = {
|
|
154
|
-
|
|
155
|
-
|
|
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, {
|
|
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 = {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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(
|
|
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(
|
|
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)
|
|
191
|
-
|
|
192
|
-
|
|
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 = [
|
|
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(
|
|
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
|
|
202
|
-
|
|
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
|
}
|