@consiliency/agent-board-cli 1.24.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/claude-marketplace/.claude-plugin/marketplace.json +14 -0
  2. package/claude-marketplace/plugins/agent-board/.claude-plugin/plugin.json +19 -0
  3. package/claude-marketplace/plugins/agent-board/hooks/hooks.json +25 -0
  4. package/claude-marketplace/plugins/agent-board/hooks/wake-hook.sh +288 -0
  5. package/dist/assets.d.ts +26 -0
  6. package/dist/assets.js +70 -0
  7. package/dist/assets.json +6 -0
  8. package/dist/claude_marketplace.d.ts +117 -0
  9. package/dist/claude_marketplace.js +306 -0
  10. package/dist/cli.d.ts +37 -3
  11. package/dist/cli.js +68 -26
  12. package/dist/config_edit.d.ts +37 -0
  13. package/dist/config_edit.js +549 -0
  14. package/dist/doctor.d.ts +24 -2
  15. package/dist/doctor.js +164 -4
  16. package/dist/doctor_scopes.d.ts +34 -2
  17. package/dist/doctor_scopes.js +128 -20
  18. package/dist/identity_writer.d.ts +9 -7
  19. package/dist/identity_writer.js +18 -30
  20. package/dist/setup.d.ts +116 -0
  21. package/dist/setup.js +603 -0
  22. package/dist/setup_storage.d.ts +117 -0
  23. package/dist/setup_storage.js +447 -0
  24. package/dist/setup_writers.d.ts +147 -0
  25. package/dist/setup_writers.js +916 -0
  26. package/keyslot/agent-board-keyslot-load@.service +20 -0
  27. package/keyslot/agent-board-keyslot@.service +20 -0
  28. package/keyslot/constants.sh +73 -0
  29. package/keyslot/keygen.mjs +67 -0
  30. package/keyslot/keyslot-load.sh +78 -0
  31. package/keyslot/lib.sh +599 -0
  32. package/keyslot/optin.sh +185 -0
  33. package/keyslot/probe.sh +135 -0
  34. package/keyslot/provision.sh +530 -0
  35. package/keyslot/seam.sh +48 -0
  36. package/keyslot/status.sh +254 -0
  37. package/package.json +9 -4
@@ -0,0 +1,916 @@
1
+ // SETUP SL-2 — the `harness` step: register the board with one harness, in one scope, at the
2
+ // versions this cli build baked (IF-0-SETUP-1, IF-0-SETUP-4).
3
+ //
4
+ // harness how setup's own exact check
5
+ // claude materialise the packaged marketplace into our plugin row's version = the
6
+ // `claudeMarketplaceDir` (per user), then baked mcp version, the scope's
7
+ // `claude plugin marketplace add <that dir>` marketplace source is that
8
+ // + `install` (or `update` over an older one) directory, and its content is
9
+ // byte-equal to the packaged copy
10
+ // pi `pi install [-l] npm:@consiliency/agent-board-pi@<v>` exactly that spec, once
11
+ // opencode `opencode plugin @consiliency/agent-board-opencode@<v> [-g]` exactly that spec, once
12
+ // codex `[mcp_servers.agent-board]` in config.toml (writer) the stanza deep-equals ours
13
+ // grok `[mcp_servers.agent-board]` in config.toml (writer) the stanza deep-equals ours
14
+ // agy `mcpServers.agent-board` in mcp_config.json (writer) the stanza deep-equals ours
15
+ //
16
+ // Doctor's registration reader is version-blind (it asks "is there a board server"), so it is
17
+ // never the exact check; it is the reader every written file must still satisfy, and the files
18
+ // are located and read through doctor's own scope table (`HARNESS_CONFIG_SCOPES`) so the two
19
+ // cannot disagree about where a harness looks. Auto-update is never enabled by anything here.
20
+ //
21
+ // Every `reason` is an authored code. File content (a server name, a path) only reaches the
22
+ // human `fix`, and a server name only when it is a plain token.
23
+ import { spawn } from "node:child_process";
24
+ import { randomBytes } from "node:crypto";
25
+ import { constants as FS, accessSync, chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
26
+ import * as fsp from "node:fs/promises";
27
+ import { homedir } from "node:os";
28
+ import { basename, delimiter, dirname, isAbsolute, join } from "node:path";
29
+ import { normalizeKeyReference } from "@consiliency/agent-board-client";
30
+ import { resolveMarketplaceDir } from "./assets.js";
31
+ import { MARKETPLACE_MANIFEST_KEY, claudeMarketplaceDir, compareVersions, describeTrustProblem, isOurMarketplacePath, pluginVersionIn, readTree, resolveTrustChain, sameTree, } from "./claude_marketplace.js";
32
+ import { ConfigEditRefused, upsertJsonMember, upsertTomlTable } from "./config_edit.js";
33
+ import { REPORTABLE_TOKEN } from "./doctor.js";
34
+ import { CLAUDE_PLUGIN_ID, CLAUDE_PLUGIN_MARKETPLACE, CLAUDE_PLUGIN_MCP_PACKAGE, CLAUDE_PLUGIN_SERVER, HARNESS_CONFIG_SCOPES, OPENCODE_PLUGIN_PACKAGE, PI_PACKAGE, claudeMarketplaceDeclarations, claudeProjectKey, judgeMcpEntry, nodeRegistrationFs, own, packageIdentity, parseRestrictedToml, readClaudePluginScopes, readScopes, } from "./doctor_scopes.js";
35
+ export const SETUP_SCOPES = Object.freeze(["user", "local"]);
36
+ /** The server name setup writes; the same name the claude plugin registers. */
37
+ export const BOARD_SERVER_NAME = CLAUDE_PLUGIN_SERVER;
38
+ /**
39
+ * What codex hands an MCP child and nothing else (`process_ancestry.ts`, measured 2026-09-18 on
40
+ * codex 0.155.0, across three configurations including `shell_environment_policy.inherit=all`).
41
+ * The writer test pins this list against that measurement's own text.
42
+ */
43
+ export const CODEX_CHILD_ENV = Object.freeze(["HOME", "LANG", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]);
44
+ /**
45
+ * Every non-secret input that decides where the identity file and the runtime records resolve
46
+ * (`identityConfigRoot`, `identityStateRoot`, `resolveHostSegment`). Written into the codex
47
+ * stanza's `env` when setup's own value is not the default, so the child resolves what setup
48
+ * wrote. Never a credential: the identity itself comes from the file.
49
+ */
50
+ export const CODEX_PATH_INPUTS = Object.freeze(["XDG_CONFIG_HOME", "XDG_STATE_HOME", "MESSAGE_BOARD_WAKE_MACHINE_ID", "MESSAGE_BOARD_WAKE_HOSTNAME"]);
51
+ /** `secret-tool` inherits the child env (`keystore_os_keychain.ts`): an os_keychain key needs the session bus. */
52
+ export const KEYCHAIN_CHILD_INPUTS = Object.freeze(["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"]);
53
+ const nonBlank = (value) => (value !== undefined && value.trim() !== "" ? value : undefined);
54
+ const homeOf = (env) => nonBlank(env.HOME) ?? homedir();
55
+ /** The `agent_socket:` socket spellings that mean `$SSH_AUTH_SOCK` (the client's `resolveSocketPath`). */
56
+ const SOCKET_SENTINELS = new Set(["", "default", "$ssh_auth_sock", "ssh_auth_sock"]);
57
+ /**
58
+ * The env an `agent_socket:` reference's socket resolves through, which codex does not hand its
59
+ * child: `MESSAGE_BOARD_AGENT_SOCKET_PATH` when setup's env sets it (it overrides the handle), else
60
+ * `SSH_AUTH_SOCK` when the handle names the socket only by that sentinel (an external agent). An
61
+ * absolute socket in the handle (a keyslot) needs nothing. null: the socket resolves to nothing
62
+ * here, so no child could reach it.
63
+ */
64
+ export function agentSocketInputs(keyReference, env) {
65
+ if (keyReference === null)
66
+ return {};
67
+ let handle;
68
+ try {
69
+ const reference = normalizeKeyReference(keyReference);
70
+ if (reference.resolver !== "agent_socket")
71
+ return {};
72
+ handle = String(reference.handle);
73
+ }
74
+ catch {
75
+ return {};
76
+ }
77
+ const override = nonBlank(env.MESSAGE_BOARD_AGENT_SOCKET_PATH);
78
+ if (override !== undefined)
79
+ return { MESSAGE_BOARD_AGENT_SOCKET_PATH: override };
80
+ const hash = handle.indexOf("#");
81
+ const spec = (hash >= 0 ? handle.slice(0, hash) : handle).trim();
82
+ if (!SOCKET_SENTINELS.has(spec.toLowerCase()))
83
+ return {};
84
+ const sock = nonBlank(env.SSH_AUTH_SOCK);
85
+ return sock === undefined ? null : { SSH_AUTH_SOCK: sock };
86
+ }
87
+ /**
88
+ * The codex stanza's `env`: each frozen path input setup's env sets to a non-default value, the
89
+ * session bus for an os_keychain key, and the socket inputs of an agent_socket one. null when the
90
+ * key's socket cannot be resolved (see `agentSocketInputs`).
91
+ */
92
+ export function codexStanzaEnv(env, keyReference) {
93
+ const home = homeOf(env);
94
+ const defaults = {
95
+ XDG_CONFIG_HOME: join(home, ".config"),
96
+ XDG_STATE_HOME: join(home, ".local", "state"),
97
+ MESSAGE_BOARD_WAKE_MACHINE_ID: undefined,
98
+ MESSAGE_BOARD_WAKE_HOSTNAME: undefined,
99
+ };
100
+ const out = {};
101
+ for (const name of CODEX_PATH_INPUTS) {
102
+ const value = nonBlank(env[name]);
103
+ if (value !== undefined && value !== defaults[name])
104
+ out[name] = value;
105
+ }
106
+ let resolver = null;
107
+ try {
108
+ resolver = keyReference === null ? null : normalizeKeyReference(keyReference).resolver;
109
+ }
110
+ catch {
111
+ resolver = null;
112
+ }
113
+ if (resolver === "os_keychain") {
114
+ for (const name of KEYCHAIN_CHILD_INPUTS) {
115
+ const value = nonBlank(env[name]);
116
+ if (value !== undefined)
117
+ out[name] = value;
118
+ }
119
+ }
120
+ const socket = agentSocketInputs(keyReference, env);
121
+ if (socket === null)
122
+ return null;
123
+ return { ...out, ...socket };
124
+ }
125
+ /** The environment codex actually gives the MCP child: the frozen seven from `env`, then the stanza's. */
126
+ export function codexChildEnv(env, stanza) {
127
+ const out = {};
128
+ for (const name of CODEX_CHILD_ENV)
129
+ if (env[name] !== undefined)
130
+ out[name] = env[name];
131
+ const extra = stanza.env;
132
+ if (extra !== undefined && typeof extra === "object" && !Array.isArray(extra)) {
133
+ for (const [name, value] of Object.entries(extra))
134
+ if (typeof value === "string")
135
+ out[name] = value;
136
+ }
137
+ return out;
138
+ }
139
+ /**
140
+ * The stanza setup writes for an MCP-configured harness: `npx -y <mcp>@<pin> --harness <h>` (+ the
141
+ * codex env). null only for codex, when its key's agent socket resolves to nothing here.
142
+ */
143
+ export function mcpStanza(harness, assets, env, keyReference) {
144
+ const stanza = { command: "npx", args: ["-y", `${CLAUDE_PLUGIN_MCP_PACKAGE}@${assets.mcpVersion}`, "--harness", harness] };
145
+ if (harness === "codex") {
146
+ const childEnv = codexStanzaEnv(env, keyReference);
147
+ if (childEnv === null)
148
+ return null;
149
+ if (Object.keys(childEnv).length > 0)
150
+ return { ...stanza, env: childEnv };
151
+ }
152
+ return stanza;
153
+ }
154
+ /**
155
+ * Whether an entry under our name runs OUR server: an argv element whose package identity is
156
+ * exactly `@consiliency/agent-board-mcp` (any version, as `npx -y <pkg>@<v>` passes it), or the
157
+ * package's bin `agent-board-mcp` as its own element. A substring is never enough: a lookalike
158
+ * package (`@example/agent-board-mcp-tools`, `@consiliency/agent-board-mcp-extra`) or a wrapper
159
+ * script whose path merely contains the name is someone else's, and is never overwritten.
160
+ */
161
+ export function isOurServer(words) {
162
+ return words.some((word) => word === "agent-board-mcp" || packageIdentity(word) === CLAUDE_PLUGIN_MCP_PACKAGE);
163
+ }
164
+ /** The entry keys setup owns; any other key a user keeps in the entry survives an upgrade. */
165
+ const OWNED_ENTRY_KEYS = new Set(["command", "args", "env"]);
166
+ /**
167
+ * The `env` names setup owns: exactly the ones it may write. Any other name a user keeps in the
168
+ * entry's `env` survives an upgrade; an owned one setup no longer writes is dropped.
169
+ */
170
+ export const OWNED_ENV_NAMES = new Set([
171
+ ...CODEX_PATH_INPUTS, ...KEYCHAIN_CHILD_INPUTS, "SSH_AUTH_SOCK", "MESSAGE_BOARD_AGENT_SOCKET_PATH",
172
+ ]);
173
+ function scopeContext(env, cwd, fs, claudeManagedDir, extra = {}) {
174
+ return { cwd, home: homeOf(env), env, fs, claudeManagedDir, ...extra };
175
+ }
176
+ const row = (harness, name) => {
177
+ const found = HARNESS_CONFIG_SCOPES[harness].find((candidate) => candidate.name === name);
178
+ if (!found)
179
+ throw new Error(`setup: doctor's scope table has no ${name} row`);
180
+ return found;
181
+ };
182
+ /**
183
+ * Whether `setup --harness <h> --scope <s>` is supported here. agy has no project scope; a codex
184
+ * project layer is loaded only under a trusted `[projects]` entry, judged by doctor's own walk
185
+ * (the `codex_project` row's `loaded`), so setup and doctor cannot disagree. Setup never writes trust.
186
+ */
187
+ export function scopeRefusal(harness, scope, ctx) {
188
+ if (scope === "user")
189
+ return null;
190
+ if (harness === "agy") {
191
+ return { reason: "scope_unsupported", fix: "agy has no project scope (only ~/.gemini/config/mcp_config.json): re-run with --scope user" };
192
+ }
193
+ if (harness === "codex") {
194
+ const scopeCtx = scopeContext(ctx.env, ctx.cwd, ctx.fs ?? nodeRegistrationFs, ctx.claudeManagedDir ?? "/etc/claude-code");
195
+ const project = row("codex", "codex_project");
196
+ let trusted = false;
197
+ try {
198
+ trusted = project.loaded?.(join(ctx.cwd, ".codex", "config.toml"), scopeCtx) ?? true;
199
+ }
200
+ catch {
201
+ trusted = false;
202
+ }
203
+ if (!trusted) {
204
+ return {
205
+ reason: "codex_project_untrusted",
206
+ fix: "codex loads .codex/config.toml only in a trusted project: open codex in this directory and accept its trust prompt (it records trust_level = \"trusted\" under [projects] in ~/.codex/config.toml), then re-run setup. Setup never writes trust.",
207
+ };
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+ export const defaultChildRunner = (file, args, options) => new Promise((resolve) => {
213
+ let stdout = "";
214
+ let stderr = "";
215
+ let child;
216
+ try {
217
+ child = spawn(file, [...args], { env: options.env, cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"], timeout: options.timeoutMs ?? 600_000 });
218
+ }
219
+ catch {
220
+ resolve({ code: null, stdout, stderr });
221
+ return;
222
+ }
223
+ child.stdout.on("data", (chunk) => { stdout += String(chunk); });
224
+ child.stderr.on("data", (chunk) => { stderr += String(chunk); });
225
+ child.on("error", () => resolve({ code: null, stdout, stderr }));
226
+ child.on("close", (code, signal) => resolve({ code: signal ? null : code, stdout, stderr }));
227
+ });
228
+ /** An executable named `name` on `env`'s PATH (never `process.env`'s), or null. */
229
+ export function which(name, env) {
230
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
231
+ if (!dir || !isAbsolute(dir))
232
+ continue;
233
+ const candidate = join(dir, name);
234
+ try {
235
+ if (!statSync(candidate).isFile())
236
+ continue;
237
+ accessSync(candidate, FS.X_OK);
238
+ return candidate;
239
+ }
240
+ catch {
241
+ continue;
242
+ }
243
+ }
244
+ return null;
245
+ }
246
+ function isSymlink(path) {
247
+ try {
248
+ return lstatSync(path).isSymbolicLink();
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ }
254
+ /**
255
+ * Replace `path`'s content so a reader sees the old file or the new one. A symlink is written
256
+ * THROUGH (its target is replaced, the link kept) unless `followSymlinks` is false, which refuses.
257
+ * An existing file keeps its mode; a new one is 0600 in 0700 directories.
258
+ */
259
+ async function writeConfigFile(path, text, followSymlinks) {
260
+ let target = path;
261
+ let mode = 0o600;
262
+ let symlink = false;
263
+ let stats = null;
264
+ try {
265
+ stats = lstatSync(path);
266
+ }
267
+ catch {
268
+ stats = null;
269
+ }
270
+ if (stats?.isSymbolicLink()) {
271
+ if (!followSymlinks)
272
+ return { reason: "config_symlink_refused", fix: `${path} is a symlink and --no-follow-symlinks was given: write the board entry yourself, or re-run without --no-follow-symlinks to write through it` };
273
+ try {
274
+ target = realpathSync(path);
275
+ }
276
+ catch {
277
+ return { reason: "config_symlink_dangling", fix: `${path} is a symlink to nothing: fix or remove the link, then re-run setup` };
278
+ }
279
+ symlink = true;
280
+ stats = lstatSync(target);
281
+ }
282
+ if (stats && !stats.isFile())
283
+ return { reason: "config_not_a_file", fix: `${target} is not a regular file` };
284
+ if (stats)
285
+ mode = stats.mode & 0o7777;
286
+ else
287
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
288
+ const temp = join(dirname(target), `.${basename(target)}.setup-${process.pid}-${Date.now()}`);
289
+ const handle = await fsp.open(temp, FS.O_CREAT | FS.O_EXCL | FS.O_WRONLY | FS.O_NOFOLLOW, 0o600);
290
+ try {
291
+ await handle.writeFile(text, "utf8");
292
+ await handle.chmod(mode);
293
+ await handle.sync();
294
+ }
295
+ catch (error) {
296
+ await handle.close();
297
+ await fsp.unlink(temp).catch(() => { });
298
+ throw error;
299
+ }
300
+ await handle.close();
301
+ try {
302
+ await fsp.rename(temp, target);
303
+ }
304
+ catch (error) {
305
+ await fsp.unlink(temp).catch(() => { });
306
+ throw error;
307
+ }
308
+ const dir = await fsp.open(dirname(target), "r");
309
+ try {
310
+ await dir.sync();
311
+ }
312
+ finally {
313
+ await dir.close();
314
+ }
315
+ return { symlink };
316
+ }
317
+ const readText = (path) => {
318
+ try {
319
+ return readFileSync(path, "utf8");
320
+ }
321
+ catch {
322
+ return null;
323
+ }
324
+ };
325
+ const failed = (reason, fix) => ({ status: "failed", reason, fix });
326
+ const token = (value) => (REPORTABLE_TOKEN.test(value) ? value : "(unprintable name)");
327
+ /** The MCP-writer target file for a harness and scope, as doctor's scope rows locate it. */
328
+ export function mcpTarget(harness, scope, ctx) {
329
+ const name = harness === "codex" ? (scope === "user" ? "codex_user" : "codex_project")
330
+ : harness === "grok" ? (scope === "user" ? "grok_user" : "grok_project")
331
+ : "agy_user";
332
+ const paths = row(harness, name).paths(ctx);
333
+ // A walked project row lists cwd first.
334
+ return scope === "user" ? paths[0] : join(ctx.cwd, harness === "codex" ? ".codex" : ".grok", "config.toml");
335
+ }
336
+ /** Board MCP servers registered anywhere in the harness's scopes (as doctor reads them), except `exempt`. */
337
+ function boardEntries(harness, ctx, exempt) {
338
+ const scopes = readScopes(harness, ctx);
339
+ let disabled = new Set();
340
+ if (harness === "claude") {
341
+ try {
342
+ const doc = JSON.parse(readText(join(nonBlank(ctx.env.CLAUDE_CONFIG_DIR) ?? ctx.home, ".claude.json")) ?? "{}");
343
+ const list = own(own(own(doc, "projects"), claudeProjectKey(ctx)), "disabledMcpServers");
344
+ disabled = new Set(Array.isArray(list) ? list.filter((item) => typeof item === "string") : []);
345
+ }
346
+ catch {
347
+ disabled = new Set();
348
+ }
349
+ }
350
+ const entries = [];
351
+ for (const file of scopes.files) {
352
+ for (const entry of file.contribution.mcp) {
353
+ if (entry.disabled)
354
+ continue;
355
+ if (harness === "claude" && disabled.has(entry.name) && file.row !== "claude_managed_mcp" && file.row !== "claude_managed_settings")
356
+ continue;
357
+ if (!judgeMcpEntry(entry, ctx.fs).board)
358
+ continue;
359
+ if (exempt && file.path === exempt.path && entry.name === exempt.name)
360
+ continue;
361
+ entries.push({ row: file.row, path: file.path, name: entry.name });
362
+ }
363
+ }
364
+ return { entries, unparseable: scopes.unparseable };
365
+ }
366
+ function existingBoardServer(harness, seen) {
367
+ const where = `${seen.row} (${seen.path}), server ${token(seen.name)}`;
368
+ const fix = harness === "claude"
369
+ ? `a board MCP server is already registered: ${where}. Remove it, or turn it off for this directory with projects[<this project>].disabledMcpServers in ~/.claude.json, then re-run setup; setup never overwrites it`
370
+ : `a board MCP server is already registered: ${where}. Remove it (or re-run setup with the --scope that holds it), then re-run setup; setup never overwrites it`;
371
+ return failed("existing_board_server", fix);
372
+ }
373
+ async function installMcp(ctx, scopeCtx) {
374
+ const target = mcpTarget(ctx.harness, ctx.scope, scopeCtx);
375
+ const seen = boardEntries(ctx.harness, scopeCtx, { path: target, name: BOARD_SERVER_NAME });
376
+ if (seen.unparseable.length > 0)
377
+ return failed("config_unparseable", `these ${ctx.harness} config files do not parse, so setup cannot tell what they register: ${[...new Set(seen.unparseable)].join(", ")}`);
378
+ if (seen.entries.length > 0)
379
+ return existingBoardServer(ctx.harness, seen.entries[0]);
380
+ const stanza = mcpStanza(ctx.harness, ctx.assets, ctx.env, ctx.keyReference);
381
+ if (stanza === null) {
382
+ return failed("agent_socket_unresolved", "the key's agent socket resolves through SSH_AUTH_SOCK (or MESSAGE_BOARD_AGENT_SOCKET_PATH), which is unset here, and codex hands its MCP child neither: run setup from the shell whose agent holds the key");
383
+ }
384
+ const text = readText(target) ?? "";
385
+ const path = ctx.harness === "agy" ? ["mcpServers", BOARD_SERVER_NAME] : ["mcp_servers", BOARD_SERVER_NAME];
386
+ // An entry under our name that is not a board server is someone else's: never overwritten. Ours
387
+ // keeps every key the user added (only command, args and env are setup's), and a server the user
388
+ // turned off stays off.
389
+ let desired = stanza;
390
+ let disabledByUser = false;
391
+ try {
392
+ const doc = ctx.harness === "agy" ? (text.trim() === "" ? {} : JSON.parse(text)) : parseRestrictedToml(text);
393
+ const current = own(own(doc, path[0]), path[1]);
394
+ if (current !== undefined && current !== null && typeof current === "object") {
395
+ const command = own(current, "command");
396
+ const args = own(current, "args");
397
+ const words = [typeof command === "string" ? command : "", ...(Array.isArray(args) ? args.filter((arg) => typeof arg === "string") : [])];
398
+ if (!isOurServer(words)) {
399
+ return failed("existing_board_server", `${target} already has a server named ${BOARD_SERVER_NAME} that is not ${CLAUDE_PLUGIN_MCP_PACKAGE} (another package, or a wrapper): setup never overwrites it; rename or remove it, then re-run setup`);
400
+ }
401
+ const kept = {};
402
+ for (const key of Object.keys(current))
403
+ if (!OWNED_ENTRY_KEYS.has(key))
404
+ kept[key] = own(current, key);
405
+ // `env` merges name by name: the user's names stay, setup's own come from the stanza.
406
+ const currentEnv = own(current, "env");
407
+ const env = {};
408
+ if (currentEnv !== null && typeof currentEnv === "object" && !Array.isArray(currentEnv)) {
409
+ for (const name of Object.keys(currentEnv))
410
+ if (!OWNED_ENV_NAMES.has(name))
411
+ env[name] = own(currentEnv, name);
412
+ }
413
+ for (const [name, value] of Object.entries(stanza.env ?? {}))
414
+ env[name] = value;
415
+ const { env: _stanzaEnv, ...rest } = stanza;
416
+ void _stanzaEnv;
417
+ desired = { ...rest, ...(Object.keys(env).length > 0 ? { env } : {}), ...kept };
418
+ disabledByUser = own(current, "enabled") === false || own(current, "disabled") === true;
419
+ }
420
+ }
421
+ catch {
422
+ return failed("config_unparseable", `${target} does not parse`);
423
+ }
424
+ const off = disabledByUser
425
+ ? { suffix: ",disabled_by_user", fix: `the board server in ${target} is turned off (enabled = false / disabled: true), and setup keeps your choice: remove that key to turn it on; until then doctor reports no registration` }
426
+ : { suffix: "", fix: undefined };
427
+ let edit;
428
+ try {
429
+ edit = ctx.harness === "agy"
430
+ ? upsertJsonMember(text, path, desired, { strict: true })
431
+ : upsertTomlTable(text, path, desired);
432
+ }
433
+ catch (error) {
434
+ if (error instanceof ConfigEditRefused) {
435
+ if (error.reason === "config_inline_table" || error.reason === "config_array_table") {
436
+ return failed(error.reason, `${target} defines mcp_servers (or the board entry) as an inline table, a dotted key or an array of tables, which TOML does not let a [mcp_servers.${BOARD_SERVER_NAME}] table extend: rewrite each server as its own [mcp_servers.<name>] table, then re-run setup`);
437
+ }
438
+ return failed(error.reason, `setup could not place the board entry in ${target} without disturbing it: add this stanza yourself: ${JSON.stringify(stanza)}`);
439
+ }
440
+ throw error;
441
+ }
442
+ if (edit.prior === "exact")
443
+ return { status: "already", reason: `exact${off.suffix}`, ...(off.fix ? { fix: off.fix } : {}) };
444
+ const written = await writeConfigFile(target, edit.text, ctx.followSymlinks);
445
+ if ("reason" in written)
446
+ return failed(written.reason, written.fix);
447
+ const reason = `${edit.prior === "differs" ? "upgraded" : "installed"}${written.symlink ? ",symlink_followed" : ""}${off.suffix}`;
448
+ return { status: "done", reason, ...(off.fix ? { fix: off.fix } : {}) };
449
+ }
450
+ /** The settings files a CLI install writes for this scope, as doctor's rows name them. */
451
+ function cliWriteTargets(harness, scope, ctx) {
452
+ if (harness === "claude")
453
+ return row("claude", scope === "user" ? "claude_user_settings" : "claude_project_local_settings").paths(ctx);
454
+ if (harness === "pi")
455
+ return row("pi", scope === "user" ? "pi_user" : "pi_project").paths(ctx);
456
+ if (scope === "user")
457
+ return row("opencode", "opencode_user").paths(ctx);
458
+ return row("opencode", "opencode_project").paths(ctx).filter((path) => dirname(path) === ctx.cwd);
459
+ }
460
+ /** `readlink` on each target BEFORE the harness CLI writes it: a symlink is reported, or refused. */
461
+ function symlinkCheck(ctx, targets) {
462
+ const links = targets.filter(isSymlink);
463
+ if (links.length === 0)
464
+ return { refusal: null, followed: false };
465
+ if (!ctx.followSymlinks) {
466
+ return { refusal: failed("config_symlink_refused", `${links.join(", ")} is a symlink and --no-follow-symlinks was given: register the board yourself, or re-run without --no-follow-symlinks`), followed: false };
467
+ }
468
+ return { refusal: null, followed: true };
469
+ }
470
+ async function runCli(ctx, cli, args) {
471
+ const outcome = await (ctx.run ?? defaultChildRunner)(cli, args, { env: ctx.env, cwd: ctx.cwd });
472
+ if (outcome.code === 0)
473
+ return null;
474
+ return failed("harness_cli_failed", `\`${basename(cli)} ${args.join(" ")}\` exited ${outcome.code ?? "abnormally"}: run it yourself and read what it reports, then re-run setup`);
475
+ }
476
+ const PACKAGE_ROWS = {
477
+ pi: { user: ["pi_user"], local: ["pi_project"] },
478
+ opencode: { user: ["opencode_user"], local: ["opencode_project", "opencode_project_dotdir"] },
479
+ };
480
+ function packageSpecsSeen(harness, ctx) {
481
+ const wanted = harness === "pi" ? PI_PACKAGE : OPENCODE_PLUGIN_PACKAGE;
482
+ const scopes = readScopes(harness, ctx);
483
+ const specs = scopes.files.flatMap((file) => file.contribution.packages
484
+ .filter((spec) => packageIdentity(spec) === wanted)
485
+ .map((spec) => ({ row: file.row, spec })));
486
+ return { specs, unparseable: scopes.unparseable };
487
+ }
488
+ async function installPackage(ctx, scopeCtx) {
489
+ const cli = which(ctx.harness, ctx.env);
490
+ if (cli === null)
491
+ return failed("harness_cli_missing", `the ${ctx.harness} CLI is not on PATH: install ${ctx.harness}, then re-run setup`);
492
+ const version = ctx.harness === "pi" ? ctx.assets.piVersion : ctx.assets.opencodeVersion;
493
+ const spec = ctx.harness === "pi" ? `${PI_PACKAGE}@${version}` : `${OPENCODE_PLUGIN_PACKAGE}@${version}`;
494
+ const ours = PACKAGE_ROWS[ctx.harness][ctx.scope];
495
+ const seenBoard = boardEntries(ctx.harness, scopeCtx);
496
+ if (seenBoard.entries.length > 0)
497
+ return existingBoardServer(ctx.harness, seenBoard.entries[0]);
498
+ const before = packageSpecsSeen(ctx.harness, scopeCtx);
499
+ if (before.unparseable.length > 0 || seenBoard.unparseable.length > 0) {
500
+ return failed("config_unparseable", `these ${ctx.harness} config files do not parse: ${[...new Set([...before.unparseable, ...seenBoard.unparseable])].join(", ")}`);
501
+ }
502
+ const elsewhere = before.specs.find((seen) => !ours.includes(seen.row));
503
+ if (elsewhere)
504
+ return failed("existing_board_package", `the board package is already registered in ${elsewhere.row}: remove it there (or re-run setup with that --scope), then re-run setup`);
505
+ const mine = before.specs.filter((seen) => ours.includes(seen.row));
506
+ if (mine.length > 1)
507
+ return failed("duplicate_board_package", `the board package is listed ${mine.length} times in this scope: keep one, then re-run setup`);
508
+ if (mine.length === 1 && mine[0].spec === spec)
509
+ return { status: "already", reason: "exact" };
510
+ const links = symlinkCheck(ctx, cliWriteTargets(ctx.harness, ctx.scope, scopeCtx));
511
+ if (links.refusal)
512
+ return links.refusal;
513
+ const upgrade = mine.length === 1;
514
+ const args = ctx.harness === "pi"
515
+ ? ["install", ...(ctx.scope === "local" ? ["-l"] : []), spec]
516
+ : ["plugin", spec, ...(ctx.scope === "user" ? ["-g"] : []), ...(upgrade ? ["-f"] : [])];
517
+ const cliFailure = await runCli(ctx, cli, args);
518
+ if (cliFailure)
519
+ return cliFailure;
520
+ // The count, not presence: an install that appended beside the old entry is not an upgrade.
521
+ const after = packageSpecsSeen(ctx.harness, scopeCtx);
522
+ if (after.specs.length !== 1 || after.specs[0].spec !== spec || !ours.includes(after.specs[0].row)) {
523
+ return failed("harness_install_unverified", `after \`${ctx.harness} ${args.join(" ")}\` the scope does not hold exactly one ${spec}: fix the entry by hand, then re-run setup`);
524
+ }
525
+ const reason = upgrade ? "upgraded" : "installed";
526
+ return { status: "done", reason: links.followed ? `${reason},symlink_followed` : reason };
527
+ }
528
+ /**
529
+ * Our plugin as installed AT one scope: that scope's `installed_plugins.json` row (for local, the row
530
+ * whose project is this directory), the enable in that scope's settings file, and the marketplace
531
+ * ref that file declares. The cwd-effective summary alone cannot say this: a local install makes
532
+ * the plugin effective here while a `--scope user` request is still unmet everywhere else.
533
+ */
534
+ function claudeScopeInstall(ctx, scope) {
535
+ const configured = nonBlank(ctx.env.CLAUDE_CONFIG_DIR);
536
+ const configDir = configured !== undefined && isAbsolute(configured) ? configured : join(ctx.home, ".claude");
537
+ const json = (path) => {
538
+ try {
539
+ return JSON.parse(readFileSync(path, "utf8"));
540
+ }
541
+ catch {
542
+ return null;
543
+ }
544
+ };
545
+ const real = (path) => {
546
+ try {
547
+ return realpathSync(path);
548
+ }
549
+ catch {
550
+ return path;
551
+ }
552
+ };
553
+ const rows = own(own(json(join(configDir, "plugins", "installed_plugins.json")), "plugins"), CLAUDE_PLUGIN_ID);
554
+ const row = Array.isArray(rows)
555
+ ? rows.find((candidate) => own(candidate, "scope") === scope
556
+ && (scope === "user" || (typeof own(candidate, "projectPath") === "string" && real(own(candidate, "projectPath")) === real(ctx.cwd))))
557
+ : undefined;
558
+ const settings = json(cliWriteTargets("claude", scope, ctx)[0]);
559
+ // This scope's declared source must be exactly our per-user directory (#549): a github ref (a
560
+ // fleet host installed before it) or any other directory is migrated by re-declaring it.
561
+ const source = own(own(own(settings, "extraKnownMarketplaces"), CLAUDE_PLUGIN_MARKETPLACE), "source");
562
+ const path = own(source, "path");
563
+ const directory = own(source, "source") === "directory" && isOurMarketplacePath(path, ctx.env, ctx.home);
564
+ const version = own(row, "version");
565
+ return {
566
+ row: row !== undefined,
567
+ version: typeof version === "string" ? version : null,
568
+ enabled: own(own(settings, "enabledPlugins"), CLAUDE_PLUGIN_ID) === true,
569
+ // The user turned it off at this scope (`claude plugin disable`): setup never turns it back on.
570
+ disabled: own(own(settings, "enabledPlugins"), CLAUDE_PLUGIN_ID) === false,
571
+ directory,
572
+ };
573
+ }
574
+ /**
575
+ * Setup's exact check for claude: our plugin, at the baked version, from our per-user directory,
576
+ * effective here AND installed and enabled at the REQUESTED scope from that directory, with no layer
577
+ * setup may write (user, local) still declaring the marketplace another way, and our directory
578
+ * judged sound by doctor's own reader. `rewrote`: setup has just rewritten the directory because its
579
+ * content differed from the packaged bytes, so what Claude Code installed from it is not what this
580
+ * build ships — never exact.
581
+ */
582
+ export function claudePluginExact(ctx, assets, scope, rewrote = false) {
583
+ const read = readClaudePluginScopes(ctx);
584
+ const summary = read.summary;
585
+ const atScope = claudeScopeInstall(ctx, scope);
586
+ const staleLayers = [...new Set(claudeMarketplaceDeclarations(ctx)
587
+ .filter((declaration) => !declaration.directory && (declaration.layer === "user" || declaration.layer === "local"))
588
+ .map((declaration) => declaration.layer))];
589
+ // Round 3 (C2): every row of ours setup can reach (user, and local for this cwd) must be at the baked
590
+ // version. A local row installed from the github ref is the FIRST applicable row in its project, so
591
+ // leaving it old leaves that project on the old plugin; setup updates it, and is not exact before.
592
+ const rowLayers = SETUP_SCOPES.filter((layer) => claudeScopeInstall(ctx, layer).row);
593
+ const staleRows = rowLayers.filter((layer) => claudeScopeInstall(ctx, layer).version !== assets.mcpVersion);
594
+ // The WINNING declaration must be our directory too: the disabled path returns on `pinned` alone.
595
+ const sourceSound = summary.sourceKind === "directory" && read.sourceUndetermined.length === 0
596
+ && !read.failures.some((failure) => failure.startsWith("plugin_source_"));
597
+ const effective = summary.installed && summary.version === assets.mcpVersion && !read.failures.includes("plugin_stanza_mismatch");
598
+ const pinned = atScope.row && atScope.version === assets.mcpVersion && atScope.directory && !rewrote && sourceSound && staleLayers.length === 0 && staleRows.length === 0;
599
+ return {
600
+ exact: effective && pinned && atScope.enabled,
601
+ installedAtScope: atScope.row,
602
+ enabledAtScope: atScope.row && atScope.enabled,
603
+ disabledAtScope: atScope.row && atScope.disabled,
604
+ pinnedAtScope: pinned,
605
+ foreign: read.failures.includes("foreign_board_plugin"),
606
+ unparseable: read.unparseable.length > 0 || read.untrusted,
607
+ staleLayers,
608
+ rowLayers,
609
+ staleRows,
610
+ };
611
+ }
612
+ const pinOf = (stats) => ({ dev: stats.dev, ino: stats.ino, uid: stats.uid });
613
+ /** The path now, lstat'd: still the same real directory (same dev/ino/owner), never a symlink. */
614
+ function samePin(path, pin) {
615
+ let stats;
616
+ try {
617
+ stats = lstatSync(path);
618
+ }
619
+ catch {
620
+ return pin === null;
621
+ }
622
+ return pin !== null && stats.isDirectory() && !stats.isSymbolicLink() && stats.dev === pin.dev && stats.ino === pin.ino && stats.uid === pin.uid;
623
+ }
624
+ /** The resolver's visited chain up to and including the parent: every directory entered, by identity. */
625
+ const chainKey = (visited) => visited.filter((entry) => entry.role !== "dir").map((entry) => `${entry.path}\u0000${entry.dev}:${entry.ino}`).join("\n");
626
+ const lstatOrNull = (path) => {
627
+ try {
628
+ return lstatSync(path);
629
+ }
630
+ catch {
631
+ return null;
632
+ }
633
+ };
634
+ const SIBLING = /^\.claude-marketplace\.(tmp|old)-/;
635
+ /**
636
+ * Make `claudeMarketplaceDir` hold exactly the packaged marketplace. It must be permanent (a
637
+ * directory-sourced plugin whose directory is gone fails to load: measured), so it is never the
638
+ * npx cache.
639
+ *
640
+ * Trust (B3): `checkTrustChain`, shared with doctor, over every existing path from `/` down. Our own
641
+ * `agent-board` or `claude-marketplace` directory that is group/world-writable is TIGHTENED to 0700
642
+ * rather than refused: setup owns both, nothing else is meant to write there, and a refusal would
643
+ * only ask the user to run the chmod setup can run; anything else untrusted is refused
644
+ * (`plugin_dir_untrusted`, naming the path). After `mkdir` the chain is judged again, and the parent
645
+ * and directory are pinned by dev/ino/owner; the pins are re-checked before the swap and before the
646
+ * old copy is removed, so a path swapped after the check is refused rather than written through or
647
+ * deleted through.
648
+ *
649
+ * Swap (S1): a sibling temp directory is built and renamed in; the old one is renamed aside first
650
+ * (Node cannot exchange two directories atomically, so the path is briefly absent). If the second
651
+ * rename fails, the old copy is renamed back. A failed removal of the old copy is a warning, not a
652
+ * failure. Stale temp/old siblings of ours from a crashed run are removed first.
653
+ */
654
+ function materializeMarketplace(ctx, scopeCtx) {
655
+ let packagedDir;
656
+ try {
657
+ packagedDir = ctx.marketplaceSource ?? resolveMarketplaceDir();
658
+ }
659
+ catch {
660
+ packagedDir = "";
661
+ }
662
+ const packaged = packagedDir === "" ? null : readTree(packagedDir);
663
+ // The key is `/`-separated on every OS (S5): `readTree` never uses the platform separator.
664
+ if (packaged === null || packaged.kind !== "ok" || !packaged.files.has(MARKETPLACE_MANIFEST_KEY)) {
665
+ return failed("marketplace_unpackaged", "this cli build carries no claude-marketplace/ (the build did not copy it): reinstall @consiliency/agent-board-cli, then re-run setup");
666
+ }
667
+ const dir = claudeMarketplaceDir(ctx.env, scopeCtx.home);
668
+ const parent = dirname(dir);
669
+ const uid = scopeCtx.fs.getuid();
670
+ // win32 has no uid (getuid() is -1) or POSIX mode: `checkTrustChain` then keeps only the symlink rule (S5).
671
+ const platform = process.platform;
672
+ const notes = [];
673
+ const untrusted = (detail) => failed("plugin_dir_untrusted", `${detail}; setup installs the Claude Code plugin from ${dir} and never follows or takes over what it did not make: fix or remove it (or set XDG_DATA_HOME to a directory you own), then re-run setup`);
674
+ let pinnedChain = "";
675
+ const trust = () => resolveTrustChain(dir, { ...ctx.marketplaceTrust, uid, platform });
676
+ const judge = () => {
677
+ for (let attempt = 0; attempt < 3; attempt++) {
678
+ const { problem, visited } = trust();
679
+ if (problem === null) {
680
+ pinnedChain = chainKey(visited);
681
+ return null;
682
+ }
683
+ if (!problem.tightenable)
684
+ return untrusted(describeTrustProblem(problem));
685
+ chmodSync(problem.path, 0o700);
686
+ if (!notes.includes("marketplace_dir_tightened"))
687
+ notes.push("marketplace_dir_tightened");
688
+ }
689
+ return untrusted(`${parent} could not be made private`);
690
+ };
691
+ const first = judge();
692
+ if (first)
693
+ return first;
694
+ // Leftovers of a crashed run: ours (owner, a real directory) only.
695
+ if (platform !== "win32" && lstatOrNull(parent)?.isDirectory()) {
696
+ for (const name of readdirSync(parent)) {
697
+ const stats = SIBLING.test(name) ? lstatOrNull(join(parent, name)) : null;
698
+ if (!(stats && stats.isDirectory() && !stats.isSymbolicLink() && stats.uid === uid))
699
+ continue;
700
+ try {
701
+ rmSync(join(parent, name), { recursive: true, force: true });
702
+ }
703
+ catch {
704
+ // `force` suppresses only ENOENT. A copy holding another uid's entry (the tree round 5 rebuilds)
705
+ // or a 0500 subdirectory cannot be removed: a note, as when the swap's own cleanup fails, never a throw.
706
+ if (!notes.includes("marketplace_old_copy_left"))
707
+ notes.push("marketplace_old_copy_left");
708
+ }
709
+ }
710
+ }
711
+ // Round 5: an entry inside that another uid owns or can write is NOT equal, however its bytes read:
712
+ // it is rewritten below through the same swap, with safe modes.
713
+ const current = readTree(dir, { uid, platform, lstat: ctx.marketplaceTrust?.lstat });
714
+ if (current.kind === "ok" && sameTree(current.files, packaged.files))
715
+ return { dir, rewrote: false, notes };
716
+ // Never a silent downgrade of a TRUSTED directory a newer cli wrote (round 4). An untrusted tree never
717
+ // reaches here as `ok` (round 5): its version is untrusted too, so it is rebuilt like any other.
718
+ const skew = current.kind === "ok" ? compareVersions(pluginVersionIn(current.files), pluginVersionIn(packaged.files)) : null;
719
+ if (skew !== null && skew > 0) {
720
+ return failed("plugin_dir_newer", `${dir} holds a newer agent-board plugin (${pluginVersionIn(current.kind === "ok" ? current.files : new Map()) ?? "?"}) than this cli ships (${pluginVersionIn(packaged.files) ?? "?"}); setup does not downgrade it: run the newer cli's setup (npx -y @consiliency/agent-board-cli@latest setup --harness claude)`);
721
+ }
722
+ const suffix = `${process.pid}-${randomBytes(4).toString("hex")}`;
723
+ const temp = join(parent, `.claude-marketplace.tmp-${suffix}`);
724
+ const old = join(parent, `.claude-marketplace.old-${suffix}`);
725
+ const paths = { parent, dir, temp, old };
726
+ try {
727
+ // Every directory setup creates is mkdir'd 0700; a umask can only narrow that, never widen it.
728
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
729
+ }
730
+ catch {
731
+ return failed("plugin_dir_unwritable", `setup could not create ${parent}: check that its parent is writable by you, then re-run setup`);
732
+ }
733
+ // Re-judge now that the parent exists, then pin it and the directory for the rest of the run.
734
+ const second = judge();
735
+ if (second)
736
+ return second;
737
+ // The pins: every directory the resolver entered up to the parent (round 4), and the directory.
738
+ const chainPin = pinnedChain;
739
+ const sameChain = () => {
740
+ const again = trust();
741
+ return again.problem === null && chainKey(again.visited) === chainPin;
742
+ };
743
+ const dirStats = lstatOrNull(dir);
744
+ const dirPin = dirStats ? pinOf(dirStats) : null;
745
+ const changed = () => untrusted(`${parent} or ${dir} changed while setup was writing it`);
746
+ try {
747
+ mkdirSync(temp, { mode: 0o700 });
748
+ for (const [path, file] of packaged.files) {
749
+ const target = join(temp, ...path.split("/"));
750
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
751
+ writeFileSync(target, file.bytes, { flag: "wx", mode: 0o600 });
752
+ // Private modes (round 5): 0700 or 0600 — the exec bit, which equality compares, is kept.
753
+ chmodSync(target, file.executable ? 0o700 : 0o600);
754
+ }
755
+ }
756
+ catch {
757
+ if (sameChain())
758
+ rmSync(temp, { recursive: true, force: true });
759
+ return failed("plugin_dir_unwritable", `setup could not write the Claude Code marketplace into ${parent}: check that it is writable by you, then re-run setup`);
760
+ }
761
+ ctx.marketplaceStep?.("prepared", paths);
762
+ if (!sameChain() || !samePin(dir, dirPin)) {
763
+ if (sameChain())
764
+ rmSync(temp, { recursive: true, force: true });
765
+ return changed();
766
+ }
767
+ let moved = false;
768
+ try {
769
+ if (dirPin !== null) {
770
+ renameSync(dir, old);
771
+ moved = true;
772
+ ctx.marketplaceStep?.("moved_old", paths);
773
+ }
774
+ renameSync(temp, dir);
775
+ }
776
+ catch {
777
+ // Put the old copy back (the plugin keeps loading), and drop the temp tree.
778
+ if (moved && sameChain() && lstatOrNull(dir) === null && samePin(old, dirPin)) {
779
+ try {
780
+ renameSync(old, dir);
781
+ }
782
+ catch {
783
+ /* reported below: the path may now be absent */
784
+ }
785
+ }
786
+ if (sameChain())
787
+ rmSync(temp, { recursive: true, force: true });
788
+ return failed("plugin_dir_unwritable", `setup could not swap the new Claude Code marketplace into ${dir}${lstatOrNull(dir) === null ? " and the directory is now absent" : "; the previous copy is still in place"}: check that ${parent} is writable by you, then re-run setup`);
789
+ }
790
+ ctx.marketplaceStep?.("swapped", paths);
791
+ if (moved) {
792
+ // Only what setup itself renamed aside, and only through the parent it pinned.
793
+ if (!sameChain() || !samePin(old, dirPin)) {
794
+ notes.push("marketplace_old_copy_left");
795
+ }
796
+ else {
797
+ try {
798
+ rmSync(old, { recursive: true });
799
+ }
800
+ catch {
801
+ notes.push("marketplace_old_copy_left");
802
+ }
803
+ }
804
+ }
805
+ return { dir, rewrote: true, notes };
806
+ }
807
+ async function installClaude(ctx, scopeCtx) {
808
+ const cli = which("claude", ctx.env);
809
+ if (cli === null)
810
+ return failed("harness_cli_missing", "the claude CLI is not on PATH: install Claude Code, then re-run setup");
811
+ const seen = boardEntries("claude", scopeCtx);
812
+ if (seen.unparseable.length > 0)
813
+ return failed("config_unparseable", `these claude config files do not parse: ${[...new Set(seen.unparseable)].join(", ")}`);
814
+ if (seen.entries.length > 0)
815
+ return existingBoardServer("claude", seen.entries[0]);
816
+ const before = claudePluginExact(scopeCtx, ctx.assets, ctx.scope);
817
+ if (before.foreign)
818
+ return failed("existing_board_plugin", "another Claude Code plugin already registers a board server or wake hook: disable it (claude plugin disable <id>), then re-run setup");
819
+ if (before.unparseable)
820
+ return failed("config_unparseable", "Claude Code's plugin records do not parse or name a path outside its cache: run `claude plugin list` and repair them, then re-run setup");
821
+ // B1: every layer that declares our marketplace. Setup re-declares it (our own entry only) in each
822
+ // layer it may write — the requested scope, and user or local if they still declare it another
823
+ // way (a github-ref install, the documented `--scope local` one included). A layer it must not
824
+ // write (a checked-in project `.claude/settings.json`, managed settings) that would still WIN is
825
+ // refused, naming the file and the command.
826
+ const dir = claudeMarketplaceDir(ctx.env, scopeCtx.home);
827
+ const rank = { user: 0, project: 1, local: 2, managed: 3 };
828
+ const declarations = claudeMarketplaceDeclarations(scopeCtx);
829
+ const addScopes = [ctx.scope, ...before.staleLayers.filter((layer) => layer !== ctx.scope)];
830
+ const winner = [...declarations.map((declaration) => ({ ...declaration, directory: declaration.directory || addScopes.includes(declaration.layer) })),
831
+ { layer: ctx.scope, path: cliWriteTargets("claude", ctx.scope, scopeCtx)[0], directory: true }]
832
+ .sort((x, y) => rank[x.layer] - rank[y.layer]).at(-1);
833
+ if (winner && !winner.directory) {
834
+ return failed("marketplace_declared_elsewhere", winner.layer === "project"
835
+ ? `${winner.path} (checked in) declares the message-board marketplace from another source, and it outranks the ${ctx.scope} scope setup writes; setup does not edit a project's shared settings: run \`claude plugin marketplace add ${dir} --scope project\` (then commit that file), or remove the declaration, then re-run setup`
836
+ : `${winner.path} (managed settings) declares the message-board marketplace from another source, and it outranks every scope setup writes: ask your administrator to point it at ${dir} or remove it, then re-run setup`);
837
+ }
838
+ // The directory next: whether it had to be rewritten decides whether the install is exact.
839
+ const materialized = materializeMarketplace(ctx, scopeCtx);
840
+ if ("status" in materialized)
841
+ return materialized;
842
+ const { rewrote, notes } = materialized;
843
+ const withNotes = (result) => (notes.length === 0 ? result : {
844
+ ...result,
845
+ reason: `${result.reason},${notes.join(",")}`,
846
+ fix: [result.fix, notes.includes("marketplace_dir_tightened") ? `setup made ${dirname(dir)} or ${dir} private (0700): other users could write to it` : "",
847
+ notes.includes("marketplace_old_copy_left") ? `setup could not remove the previous copy beside ${dir} (.claude-marketplace.old-*): remove it by hand` : ""].filter(Boolean).join("; "),
848
+ });
849
+ const current = claudePluginExact(scopeCtx, ctx.assets, ctx.scope, rewrote);
850
+ if (current.exact)
851
+ return withNotes({ status: "already", reason: "exact" });
852
+ // A plugin the user disabled at this scope stays disabled, as an MCP harness's off switch does:
853
+ // pinned already → `already`; otherwise upgraded in place (`update` keeps the enable state).
854
+ const offFix = "the board plugin is disabled at this scope (claude plugin disable), and setup keeps your choice: `claude plugin enable agent-board@message-board` to turn it on; until then doctor reports no registration";
855
+ if (current.disabledAtScope && current.pinnedAtScope)
856
+ return withNotes({ status: "already", reason: "exact,disabled_by_user", fix: offFix });
857
+ // readlink BEFORE `claude plugin … --scope` writes a settings file.
858
+ const links = symlinkCheck(ctx, addScopes.flatMap((scope) => cliWriteTargets("claude", scope, scopeCtx)));
859
+ if (links.refusal)
860
+ return links.refusal;
861
+ // Re-declaring under the same name with another source REPLACES it in both files and keeps the
862
+ // installed row (measured, #549): the migration from a github-ref install is this same sequence.
863
+ const steps = addScopes.map((scope) => ["plugin", "marketplace", "add", dir, "--scope", scope]);
864
+ const scopeArgs = ["--scope", ctx.scope];
865
+ // Update OUR rows only: at the requested scope, and (C2) at user or local wherever setup just
866
+ // re-declared the marketplace, rewrote the directory, or the row is not at the baked version — so a
867
+ // migration converges in one run. `update` keeps each scope's enable state (a user's disable too).
868
+ const updateScopes = before.rowLayers.filter((layer) => layer === ctx.scope || rewrote || addScopes.includes(layer) || before.staleRows.includes(layer));
869
+ if (updateScopes.length > 0)
870
+ steps.push(["plugin", "marketplace", "update", CLAUDE_PLUGIN_MARKETPLACE]);
871
+ for (const layer of updateScopes)
872
+ steps.push(["plugin", "update", CLAUDE_PLUGIN_ID, "--scope", layer]);
873
+ if (before.installedAtScope) {
874
+ // Our row with NO enable at this scope (not a disable: that is the user's choice, kept above).
875
+ if (!before.enabledAtScope && !before.disabledAtScope)
876
+ steps.push(["plugin", "enable", CLAUDE_PLUGIN_ID, ...scopeArgs]);
877
+ }
878
+ else {
879
+ steps.push(["plugin", "install", CLAUDE_PLUGIN_ID, ...scopeArgs]);
880
+ }
881
+ for (const args of steps) {
882
+ const cliFailure = await runCli(ctx, cli, args);
883
+ if (cliFailure)
884
+ return cliFailure;
885
+ }
886
+ const after = claudePluginExact(scopeCtx, ctx.assets, ctx.scope);
887
+ // Name the file that still wins with another source, if one does: the fix is there.
888
+ const winning = claudeMarketplaceDeclarations(scopeCtx).at(-1);
889
+ const where = winning && !winning.directory ? `; ${winning.path} still declares the message-board marketplace from another source` : "";
890
+ if (before.disabledAtScope) {
891
+ if (!after.pinnedAtScope || !after.disabledAtScope) {
892
+ return failed("harness_install_unverified", `after the claude plugin update, the disabled plugin at this scope is not ${ctx.assets.mcpVersion} from ${dir} (or was re-enabled)${where}: run \`claude plugin list\``);
893
+ }
894
+ return withNotes({ status: "done", reason: `upgraded${links.followed ? ",symlink_followed" : ""},disabled_by_user`, fix: offFix });
895
+ }
896
+ if (!after.exact) {
897
+ return failed("harness_install_unverified", `after the claude plugin install, the installed plugin is not ${ctx.assets.mcpVersion} from ${dir}${where}: run \`claude plugin list\` and doctor --harness claude`);
898
+ }
899
+ const reason = before.installedAtScope ? "upgraded" : "installed";
900
+ return withNotes({ status: "done", reason: links.followed ? `${reason},symlink_followed` : reason });
901
+ }
902
+ /** The `harness` step for one harness and scope. Never throws for a host condition. */
903
+ export async function installHarness(ctx) {
904
+ const scopeCtx = scopeContext(ctx.env, ctx.cwd, ctx.fs ?? nodeRegistrationFs, ctx.claudeManagedDir ?? "/etc/claude-code", {
905
+ ...(ctx.marketplaceSource ? { claudeMarketplacePackaged: ctx.marketplaceSource } : {}),
906
+ ...(ctx.marketplaceTrust ? { claudeMarketplaceTrust: ctx.marketplaceTrust } : {}),
907
+ });
908
+ const refusal = scopeRefusal(ctx.harness, ctx.scope, ctx);
909
+ if (refusal)
910
+ return failed(refusal.reason, refusal.fix);
911
+ if (ctx.harness === "claude")
912
+ return installClaude(ctx, scopeCtx);
913
+ if (ctx.harness === "pi" || ctx.harness === "opencode")
914
+ return installPackage(ctx, scopeCtx);
915
+ return installMcp(ctx, scopeCtx);
916
+ }