severally-mcp 2.3.1 → 2.4.2

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 (45) hide show
  1. package/README.ja.md +52 -17
  2. package/README.md +52 -16
  3. package/bin/severally-mcp.mjs +14 -2
  4. package/docs/install.ja.md +48 -20
  5. package/docs/install.md +48 -21
  6. package/package.json +15 -3
  7. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  8. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  9. package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
  10. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  11. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  12. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  13. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  14. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  15. package/scripts/build.mjs +76 -68
  16. package/scripts/doctor.mjs +66 -20
  17. package/scripts/init-config.mjs +8 -6
  18. package/scripts/install.mjs +327 -90
  19. package/scripts/live-check.mjs +62 -23
  20. package/scripts/live-mcp-check.mjs +52 -16
  21. package/scripts/test.mjs +10 -4
  22. package/src/adapters/antigravity-sandbox.mjs +62 -28
  23. package/src/adapters/antigravity.mjs +111 -30
  24. package/src/adapters/claude-code.mjs +121 -28
  25. package/src/adapters/codex.mjs +125 -33
  26. package/src/adapters/opencode-sandbox.mjs +59 -22
  27. package/src/adapters/opencode.mjs +144 -30
  28. package/src/brief.mjs +148 -47
  29. package/src/consultants.mjs +255 -48
  30. package/src/cross-spawn-escape.d.ts +7 -0
  31. package/src/export.mjs +99 -18
  32. package/src/expose-paths.mjs +310 -50
  33. package/src/failures.mjs +31 -14
  34. package/src/jobs.mjs +863 -249
  35. package/src/jsonc.mjs +11 -3
  36. package/src/parse-result.mjs +172 -40
  37. package/src/platform.mjs +56 -8
  38. package/src/policy.mjs +245 -52
  39. package/src/redact.mjs +59 -16
  40. package/src/result-schema.mjs +13 -0
  41. package/src/run.mjs +225 -30
  42. package/src/schema.mjs +132 -27
  43. package/src/server.mjs +178 -81
  44. package/src/status.mjs +94 -13
  45. package/src/store.mjs +418 -35
@@ -27,24 +27,38 @@
27
27
  // client's own CLI rather than hand-edited, and every file this touches is
28
28
  // backed up first under ~/.severally/backups/<timestamp>/.
29
29
  //
30
- // node scripts/install.mjs [--dry-run] [--manual] [--skip-global] [--force]
30
+ // When the global npm install cannot be made (npm needs sudo, or npm's global
31
+ // folder is not on PATH), plugin mode registers Codex directly instead, so the
32
+ // install still finishes.
33
+ //
34
+ // node scripts/install.mjs [--dry-run] [--manual] [--skip-global] [--force] [--allow-local-tools]
31
35
 
32
36
  import { copyTree, execCommandSync, findCommand } from '../src/platform.mjs';
33
37
  import fs from 'node:fs';
34
38
  import os from 'node:os';
35
39
  import path from 'node:path';
40
+ import readline from 'node:readline/promises';
36
41
  import { fileURLToPath } from 'node:url';
37
42
 
43
+ if (Number(process.versions.node.split('.')[0]) < 22) {
44
+ console.error(
45
+ `severally-install: Node.js 22 or newer is needed (this is ${process.versions.node}); update Node.js and run it again`,
46
+ );
47
+ process.exit(1);
48
+ }
49
+
38
50
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
39
51
  const PLUGIN = path.join(root, 'plugins', 'severally');
40
- const FLAGS = ['--dry-run', '--manual', '--skip-global', '--force'];
52
+ const FLAGS = ['--dry-run', '--manual', '--skip-global', '--force', '--allow-local-tools'];
41
53
  const argv = new Set(process.argv.slice(2));
42
54
  // A word the installer does not know is refused before anything changes: a
43
55
  // shell that passes a pasted "# add --dry-run ..." comment on as arguments
44
56
  // would otherwise turn an install into a silent dry run.
45
57
  const unknownArgs = [...argv].filter((a) => !FLAGS.includes(a));
46
58
  if (unknownArgs.length) {
47
- console.error(`severally-install: unknown argument(s) ${unknownArgs.map((a) => JSON.stringify(a)).join(' ')}; it takes ${FLAGS.join(' ')}`);
59
+ console.error(
60
+ `severally-install: unknown argument(s) ${unknownArgs.map((a) => JSON.stringify(a)).join(' ')}; it takes ${FLAGS.join(' ')}`,
61
+ );
48
62
  process.exit(2);
49
63
  }
50
64
  const dryRun = argv.has('--dry-run');
@@ -66,9 +80,16 @@ const backupDir = path.join(home, '.severally', 'backups', stamp);
66
80
  let keptRegistrations = false;
67
81
  let lastListOutput = '';
68
82
 
83
+ /** @param {...unknown} a */
69
84
  const log = (...a) => console.log(...a);
85
+ /** @param {string} s */
70
86
  const step = (s) => log(`\n== ${s}`);
71
87
 
88
+ /**
89
+ * @param {string} cmd
90
+ * @param {string[]} args
91
+ * @param {import('../src/platform.mjs').ExecOptions & { real?: boolean }} [opts]
92
+ */
72
93
  function run(cmd, args, opts = {}) {
73
94
  const { real, ...spawnOpts } = opts;
74
95
  if (dryRun && !real) {
@@ -78,16 +99,25 @@ function run(cmd, args, opts = {}) {
78
99
  return execCommandSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], ...spawnOpts });
79
100
  }
80
101
 
102
+ /**
103
+ * @param {string} cmd
104
+ * @param {string[]} args
105
+ * @param {import('../src/platform.mjs').ExecOptions & { real?: boolean }} [opts]
106
+ * @returns {RunResult}
107
+ */
81
108
  function tryRun(cmd, args, opts) {
82
109
  try {
83
110
  return { ok: true, out: run(cmd, args, opts) };
84
- } catch (err) {
111
+ } catch (e) {
112
+ // execFileSync failures carry the child's stdout and stderr.
113
+ const err = /** @type {{ stdout?: unknown, stderr?: unknown, message?: unknown }} */ (e);
85
114
  return { ok: false, out: `${err.stdout ?? ''}${err.stderr ?? ''}`.trim() || String(err.message) };
86
115
  }
87
116
  }
88
117
 
89
118
  // Backup names are derived from the whole path: two different clients both
90
119
  // hold a "severally" directory, and one must not overwrite the other.
120
+ /** @param {string} target */
91
121
  function backupName(target) {
92
122
  return path
93
123
  .relative(home, target)
@@ -96,9 +126,13 @@ function backupName(target) {
96
126
  .replace(/^\./, '');
97
127
  }
98
128
 
129
+ /** @param {string} file */
99
130
  function backup(file) {
100
131
  if (!fs.existsSync(file)) return null;
101
- if (dryRun) { log(` [dry-run] backup ${file}`); return null; }
132
+ if (dryRun) {
133
+ log(` [dry-run] backup ${file}`);
134
+ return null;
135
+ }
102
136
  fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
103
137
  const dest = path.join(backupDir, backupName(file));
104
138
  // Several client operations can touch the same config in one install.
@@ -108,9 +142,13 @@ function backup(file) {
108
142
  return dest;
109
143
  }
110
144
 
145
+ /** @param {string} dir */
111
146
  function backupTree(dir) {
112
147
  if (!fs.existsSync(dir)) return null;
113
- if (dryRun) { log(` [dry-run] backup ${dir}`); return null; }
148
+ if (dryRun) {
149
+ log(` [dry-run] backup ${dir}`);
150
+ return null;
151
+ }
114
152
  fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
115
153
  const dest = path.join(backupDir, backupName(dir));
116
154
  copyTree(dir, dest);
@@ -118,6 +156,7 @@ function backupTree(dir) {
118
156
  }
119
157
 
120
158
  // Read-only probes run even under --dry-run, so the plan it prints is accurate.
159
+ /** @param {string} bin */
121
160
  function which(bin) {
122
161
  return findCommand(bin);
123
162
  }
@@ -128,7 +167,8 @@ function which(bin) {
128
167
  // severally-mcp package, so they are not where the command is looked for.
129
168
  function runtimeCommand() {
130
169
  const key = Object.keys(process.env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH';
131
- const dirs = (process.env[key] ?? '').split(path.delimiter)
170
+ const dirs = (process.env[key] ?? '')
171
+ .split(path.delimiter)
132
172
  .filter((dir) => !/(^|[\\/])node_modules[\\/]\.bin[\\/]?$/.test(dir.replace(/^"(.*)"$/, '$1')));
133
173
  return findCommand('severally-mcp', { env: { ...process.env, [key]: dirs.join(path.delimiter) } });
134
174
  }
@@ -153,7 +193,10 @@ function installRuntime() {
153
193
  run(process.execPath, ['--check', staged], { real: true });
154
194
  // The bundle carries other packages' code, so their licences travel with
155
195
  // it -- copied first, so a missing one fails before the runtime changes.
156
- fs.copyFileSync(path.join(path.dirname(source), 'THIRD_PARTY_NOTICES'), path.join(path.dirname(installed), 'THIRD_PARTY_NOTICES'));
196
+ fs.copyFileSync(
197
+ path.join(path.dirname(source), 'THIRD_PARTY_NOTICES'),
198
+ path.join(path.dirname(installed), 'THIRD_PARTY_NOTICES'),
199
+ );
157
200
  fs.copyFileSync(path.join(PLUGIN, 'LICENSE'), path.join(path.dirname(installed), 'LICENSE'));
158
201
  backup(installed);
159
202
  fs.renameSync(staged, installed);
@@ -164,19 +207,34 @@ function installRuntime() {
164
207
  return installed;
165
208
  }
166
209
 
210
+ const RUNTIME_PACKAGE = 'severally-runtime';
211
+ // Why plugin mode registers Codex directly (no global command), or null.
212
+ let codexDirect = null;
213
+ let globalRoot = null;
167
214
  // Run from a global install of the npm package, the global install below
168
215
  // would replace the very package this script and its plugin files are read
169
216
  // from. Refused before anything changes.
170
217
  if (mode === 'plugin' && !skipGlobal) {
171
- const globalRoot = tryRun('npm', ['root', '-g'], { real: true });
172
- const inside = (dir) => { try { return fs.realpathSync(root).startsWith(fs.realpathSync(dir) + path.sep); } catch { return false; } };
173
- if (globalRoot.ok && globalRoot.out.trim() && inside(globalRoot.out.trim())) {
174
- throw new Error('severally-install was run from a global npm install of severally-mcp, which installing would replace. '
175
- + 'Remove it (npm uninstall -g severally-mcp), then run npx -y severally-mcp@latest install, or install from a clone.');
218
+ const npmRoot = tryRun('npm', ['root', '-g'], { real: true });
219
+ globalRoot = npmRoot.ok ? npmRoot.out.trim() || null : null;
220
+ /** @param {string} dir */
221
+ const inside = (dir) => {
222
+ try {
223
+ return fs.realpathSync(root).startsWith(fs.realpathSync(dir) + path.sep);
224
+ } catch {
225
+ return false;
226
+ }
227
+ };
228
+ if (globalRoot && inside(globalRoot)) {
229
+ throw new Error(
230
+ 'severally-install was run from a global npm install of severally-mcp, which installing would replace. ' +
231
+ 'Remove it (npm uninstall -g severally-mcp), then run npx -y severally-mcp@latest install, or install from a clone.',
232
+ );
176
233
  }
177
234
  }
178
235
 
179
236
  step(`Installing the standalone MCP server (mode: ${mode})`);
237
+ /** @type {[string, string]} */
180
238
  const serverCommand = [process.execPath, installRuntime()];
181
239
  const runtimeDir = path.dirname(serverCommand[1]);
182
240
  const marketplaceRoot = path.join(home, '.severally', 'marketplace');
@@ -184,29 +242,53 @@ const marketplaceRoot = path.join(home, '.severally', 'marketplace');
184
242
  if (mode === 'plugin') {
185
243
  // npm may symlink a directory install. Its source must therefore be the
186
244
  // persistent runtime, never the user's disposable checkout.
245
+ // The runtime goes global under a name of its own: under the npm package's
246
+ // name and version, npx would run this installer-less copy for
247
+ // `npx severally-mcp@<that version> install` instead of fetching the package.
187
248
  const packageFile = path.join(runtimeDir, 'package.json');
188
249
  const metadata = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
189
250
  if (!dryRun) {
190
251
  backup(packageFile);
191
- fs.writeFileSync(packageFile, `${JSON.stringify({ name: metadata.name, version: metadata.version,
192
- type: 'module', bin: { 'severally-mcp': 'severally-mcp.mjs' } }, null, 2)}\n`);
252
+ fs.writeFileSync(
253
+ packageFile,
254
+ `${JSON.stringify(
255
+ { name: RUNTIME_PACKAGE, version: metadata.version, type: 'module', bin: { 'severally-mcp': 'severally-mcp.mjs' } },
256
+ null,
257
+ 2,
258
+ )}\n`,
259
+ );
193
260
  fs.chmodSync(serverCommand[1], 0o755);
194
261
  }
195
262
  if (skipGlobal) {
196
263
  const existing = runtimeCommand();
197
264
  if (!dryRun && (!existing || fs.realpathSync(existing) !== fs.realpathSync(serverCommand[1]))) {
198
- throw new Error('--skip-global requires severally-mcp on PATH to point to the installed runtime. Omit --skip-global or use --manual.');
265
+ throw new Error(
266
+ '--skip-global requires severally-mcp on PATH to point to the installed runtime. Omit --skip-global or use --manual.',
267
+ );
199
268
  }
200
269
  log(' --skip-global: using the existing runtime command');
201
270
  } else {
202
- run('npm', ['install', '-g', runtimeDir, '--install-links=false'], { cwd: dryRun ? root : runtimeDir });
271
+ // Installs before the rename put the runtime there under the package's
272
+ // own name; its severally-mcp command would collide with the new one.
273
+ if (globalRoot && fs.existsSync(path.join(globalRoot, metadata.name))) {
274
+ const r = tryRun('npm', ['uninstall', '-g', metadata.name]);
275
+ log(
276
+ r.ok
277
+ ? ` removed the global ${metadata.name} package (the runtime is now ${RUNTIME_PACKAGE})`
278
+ : ` could not remove the global ${metadata.name} package: ${r.out}`,
279
+ );
280
+ }
281
+ const r = tryRun('npm', ['install', '-g', runtimeDir, '--install-links=false'], { cwd: dryRun ? root : runtimeDir });
203
282
  if (which('nodenv')) tryRun('nodenv', ['rehash']);
204
- if (!dryRun) {
283
+ if (!r.ok) {
284
+ codexDirect = `npm could not install the severally-mcp command globally (${r.out.split('\n').find((l) => /ERR|EACCES|EPERM/.test(l)) ?? r.out.split('\n')[0]})`;
285
+ } else if (!dryRun) {
205
286
  const installedCommand = runtimeCommand();
206
287
  if (!installedCommand || fs.realpathSync(installedCommand) !== fs.realpathSync(serverCommand[1])) {
207
- throw new Error('The installed severally-mcp runtime command is not first on PATH. Fix PATH and rerun, or use --manual.');
288
+ codexDirect = "npm's global severally-mcp command is not first on PATH";
208
289
  }
209
290
  }
291
+ if (codexDirect) log(` ${codexDirect}; Codex will be registered directly instead, which needs no global command`);
210
292
  }
211
293
  if (dryRun) {
212
294
  backupTree(marketplaceRoot);
@@ -221,8 +303,10 @@ if (mode === 'plugin') {
221
303
  try {
222
304
  fs.mkdirSync(path.join(next, '.agents', 'plugins'), { recursive: true });
223
305
  copyTree(PLUGIN, path.join(next, 'plugins', 'severally'));
224
- fs.copyFileSync(path.join(root, '.agents', 'plugins', 'marketplace.json'),
225
- path.join(next, '.agents', 'plugins', 'marketplace.json'));
306
+ fs.copyFileSync(
307
+ path.join(root, '.agents', 'plugins', 'marketplace.json'),
308
+ path.join(next, '.agents', 'plugins', 'marketplace.json'),
309
+ );
226
310
  backupTree(marketplaceRoot);
227
311
  if (fs.existsSync(marketplaceRoot)) fs.renameSync(marketplaceRoot, previous);
228
312
  try {
@@ -259,21 +343,33 @@ const agyConfig = path.join(home, '.gemini', 'config', 'mcp_config.json');
259
343
  const CODEX_TIMEOUT_SEC = 660;
260
344
  const codexConfig = path.join(codexHome, 'config.toml');
261
345
  // The user's own tool_timeout_sec, read before --force removes the table that holds it.
346
+ /** @type {number | undefined} */
262
347
  let priorCodexTimeout;
263
348
 
264
349
  // Where [mcp_servers.severally] sits in config.toml, and what it holds.
265
350
  // A minimal text read, not a TOML parser: the table runs to the next header
266
351
  // that is not one of its own sub-tables (codex may put env in one).
352
+ /** @returns {{ reason: string, lines?: undefined, start?: undefined, timeout?: undefined, wait?: undefined } | { reason?: undefined, lines: string[], start: number, timeout: number | undefined, wait: boolean }} */
267
353
  function codexTable() {
354
+ /** @type {string[]} */
268
355
  let lines;
269
- try { lines = fs.readFileSync(codexConfig, 'utf8').split('\n'); } catch (err) { return { reason: `cannot read ${codexConfig}: ${err.message}` }; }
356
+ try {
357
+ lines = fs.readFileSync(codexConfig, 'utf8').split('\n');
358
+ } catch (err) {
359
+ return { reason: `cannot read ${codexConfig}: ${/** @type {Error} */ (err).message}` };
360
+ }
270
361
  const start = lines.findIndex((l) => /^\s*\[\s*mcp_servers\.severally\s*\]/.test(l));
271
362
  if (start < 0) return { reason: `${codexConfig} has no [mcp_servers.severally] table` };
272
363
  let end = lines.findIndex((l, i) => i > start && /^\s*\[/.test(l) && !/^\s*\[\s*mcp_servers\.severally\./.test(l));
273
364
  if (end < 0) end = lines.length;
274
365
  const block = lines.slice(start + 1, end).join('\n');
275
366
  const timeout = block.match(/^\s*tool_timeout_sec\s*=\s*(\d+(?:\.\d+)?)/m)?.[1];
276
- return { lines, start, timeout: timeout === undefined ? undefined : Number(timeout), wait: /SEVERALLY_MAX_WAIT_MS\s*=\s*"?\d+/.test(block) };
367
+ return {
368
+ lines,
369
+ start,
370
+ timeout: timeout === undefined ? undefined : Number(timeout),
371
+ wait: /SEVERALLY_MAX_WAIT_MS\s*=\s*"?\d+/.test(block),
372
+ };
277
373
  }
278
374
 
279
375
  // The wait follows the timeout Codex will enforce: 15 s under the user's own
@@ -285,24 +381,45 @@ function codexPlan() {
285
381
 
286
382
  // Inserts tool_timeout_sec right after the table header. Returns why it could
287
383
  // not, or null.
384
+ /** @param {number} sec */
288
385
  function writeCodexTimeout(sec) {
289
386
  const t = codexTable();
290
- if (t.reason) return t.reason;
387
+ if (t.reason !== undefined) return t.reason;
291
388
  if (t.timeout !== undefined) return null; // never overwrite the user's value
292
389
  t.lines.splice(t.start + 1, 0, `tool_timeout_sec = ${sec}`);
293
- try { fs.writeFileSync(codexConfig, t.lines.join('\n')); } catch (err) { return `cannot write ${codexConfig}: ${err.message}`; }
390
+ try {
391
+ fs.writeFileSync(codexConfig, t.lines.join('\n'));
392
+ } catch (err) {
393
+ return `cannot write ${codexConfig}: ${/** @type {Error} */ (err).message}`;
394
+ }
294
395
  return null;
295
396
  }
296
397
 
297
398
  const claudeSkillsDir = path.join(home, '.claude', 'skills');
298
399
 
299
400
  // ---------------------------------------------------------------- clients
401
+ /**
402
+ * @typedef {{ ok: boolean, out: string }} RunResult
403
+ * @typedef {object} Client
404
+ * @property {string} bin
405
+ * @property {string} label
406
+ * @property {string[]} backup
407
+ * @property {() => boolean} isRegistered
408
+ * @property {() => RunResult} remove
409
+ * @property {(bin: string[]) => RunResult} add
410
+ * @property {() => void} [keptNote]
411
+ * @property {boolean} [verifyAdd]
412
+ * @property {string[]} [registrationConfig]
413
+ * @property {string} skillFrom
414
+ * @property {string} skillTo
415
+ */
300
416
  // Each row fully describes how to register a client's MCP server directly
301
417
  // (through the client's own `mcp add`) and place its skill. This is the one
302
418
  // place that knows how severally is installed outside of a bundled
303
419
  // plugin: --manual mode installs every row this way, and the default plugin
304
420
  // mode falls back to the Antigravity and OpenCode rows alone, since those
305
421
  // two have no verified plugin-install path.
422
+ /** @type {Client[]} */
306
423
  const CLIENTS = [
307
424
  {
308
425
  bin: 'claude',
@@ -348,7 +465,9 @@ const CLIENTS = [
348
465
  keptNote: () => {
349
466
  const t = codexTable();
350
467
  if (t.timeout === undefined || !t.wait) {
351
- log(` this registration lacks the long consult_get wait (45 s); --force adds it (SEVERALLY_MAX_WAIT_MS and tool_timeout_sec = ${CODEX_TIMEOUT_SEC})`);
468
+ log(
469
+ ` this registration lacks the long consult_get wait (45 s); --force adds it (SEVERALLY_MAX_WAIT_MS and tool_timeout_sec = ${CODEX_TIMEOUT_SEC})`,
470
+ );
352
471
  }
353
472
  },
354
473
  skillFrom: path.join(PLUGIN, 'skills', 'codex', 'severally'),
@@ -381,10 +500,7 @@ const CLIENTS = [
381
500
  {
382
501
  bin: 'opencode',
383
502
  label: 'OpenCode',
384
- backup: [
385
- path.join(xdgConfig, 'opencode', 'opencode.json'),
386
- path.join(xdgConfig, 'opencode', 'opencode.jsonc'),
387
- ],
503
+ backup: [path.join(xdgConfig, 'opencode', 'opencode.json'), path.join(xdgConfig, 'opencode', 'opencode.jsonc')],
388
504
  // opencode has no `mcp get`; list and look for the exact server name.
389
505
  // `mcp list` prints through the prompt library, but the per-server lines
390
506
  // still reach stdout when stdout is not a TTY ("✓ severally connected").
@@ -405,37 +521,61 @@ const CLIENTS = [
405
521
  remove: () => ({ ok: true, out: 'opencode has no mcp remove; re-adding replaces the entry' }),
406
522
  // OpenCode sets no deadline of its own on a tool call (a 610 s call came
407
523
  // back), so it gets the long consult_get wait with no timeout to pair.
408
- add: (bin) => tryRun('opencode', ['mcp', 'add', 'severally', '--global', '--env', LONG_WAIT, '--', ...bin]),
524
+ // opencode 1.19 (its npm release) has no --global and always writes the
525
+ // global config; it is asked again without the flag, from the home
526
+ // folder, and the check after the add confirms where it landed.
527
+ add: (bin) => {
528
+ const r = tryRun('opencode', ['mcp', 'add', 'severally', '--global', '--env', LONG_WAIT, '--', ...bin]);
529
+ return r.ok ? r : tryRun('opencode', ['mcp', 'add', 'severally', '--env', LONG_WAIT, '--', ...bin], { cwd: home });
530
+ },
409
531
  keptNote: () => {
410
532
  const text = [path.join(xdgConfig, 'opencode', 'opencode.json'), path.join(xdgConfig, 'opencode', 'opencode.jsonc')]
411
- .map((f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return ''; } }).join('\n');
412
- if (!text.includes('SEVERALLY_MAX_WAIT_MS')) log(' this registration lacks the long consult_get wait (45 s); --force adds it (SEVERALLY_MAX_WAIT_MS)');
533
+ .map((f) => {
534
+ try {
535
+ return fs.readFileSync(f, 'utf8');
536
+ } catch {
537
+ return '';
538
+ }
539
+ })
540
+ .join('\n');
541
+ if (!text.includes('SEVERALLY_MAX_WAIT_MS'))
542
+ log(' this registration lacks the long consult_get wait (45 s); --force adds it (SEVERALLY_MAX_WAIT_MS)');
413
543
  },
414
544
  // A zero exit from `mcp add` can be a usage dump in disguise (see above),
415
545
  // so this client's registration is verified by listing after adding --
416
546
  // and the recorded command is compared against this install's.
417
547
  verifyAdd: true,
418
- registrationConfig: [
419
- path.join(xdgConfig, 'opencode', 'opencode.json'),
420
- path.join(xdgConfig, 'opencode', 'opencode.jsonc'),
421
- ],
548
+ registrationConfig: [path.join(xdgConfig, 'opencode', 'opencode.json'), path.join(xdgConfig, 'opencode', 'opencode.jsonc')],
422
549
  skillFrom: path.join(PLUGIN, 'skills', 'opencode', 'severally'),
423
550
  skillTo: path.join(xdgConfig, 'opencode', 'skills', 'severally'),
424
551
  },
425
552
  ];
426
- const antigravityClient = CLIENTS.find((c) => c.bin === 'agy');
427
- const opencodeClient = CLIENTS.find((c) => c.bin === 'opencode');
553
+ /** @param {string} bin */
554
+ function clientFor(bin) {
555
+ const client = CLIENTS.find((c) => c.bin === bin);
556
+ if (!client) throw new Error(`no client row for ${bin}`);
557
+ return client;
558
+ }
559
+ const codexClient = clientFor('codex');
560
+ const antigravityClient = clientFor('agy');
561
+ const opencodeClient = clientFor('opencode');
428
562
 
429
563
  // opencode re-adds in place and has no `mcp remove`, so --force over an
430
564
  // existing registration can silently keep the OLD command while the
431
565
  // name-only list check passes. The global config is where the CLI records
432
566
  // the server command; confirm it matches what this install registers.
567
+ /**
568
+ * @param {Client} client
569
+ * @param {string[]} bin
570
+ */
433
571
  function registrationCommandMatches(client, bin) {
434
572
  for (const file of client.registrationConfig ?? []) {
435
573
  let cfg;
436
574
  try {
437
575
  cfg = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, ''));
438
- } catch { continue; }
576
+ } catch {
577
+ continue;
578
+ }
439
579
  const entry = cfg.mcp?.servers?.severally ?? cfg.mcp?.severally;
440
580
  const command = entry?.command ?? entry;
441
581
  if (Array.isArray(command)) return JSON.stringify(command) === JSON.stringify(bin);
@@ -445,6 +585,7 @@ function registrationCommandMatches(client, bin) {
445
585
 
446
586
  // The list right after an add can be briefly empty, so it is asked up to
447
587
  // three times, 1.5 s apart, before the add is judged.
588
+ /** @param {Client} client */
448
589
  function listedAfterAdd(client) {
449
590
  for (let i = 0; i < 3; i++) {
450
591
  if (i) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1500);
@@ -456,7 +597,12 @@ function listedAfterAdd(client) {
456
597
  // Registers one client's MCP server directly (through its own `mcp add`) and
457
598
  // copies its skill, backing up everything it touches first. Shared by plugin
458
599
  // mode (Antigravity only) and --manual mode (all three clients).
459
- function installClientDirectly(client, bin) { step(`Registering with ${client.label}`);
600
+ /**
601
+ * @param {Client} client
602
+ * @param {string[]} bin
603
+ */
604
+ function installClientDirectly(client, bin) {
605
+ step(`Registering with ${client.label}`);
460
606
  if (!which(client.bin)) {
461
607
  log(` ${client.bin} CLI not found on PATH — skipped`);
462
608
  return;
@@ -501,7 +647,10 @@ function installClientDirectly(client, bin) { step(`Registering with ${client.l
501
647
  if (!fs.existsSync(parent) && !dryRun) fs.mkdirSync(parent, { recursive: true });
502
648
  const saved = backupTree(client.skillTo);
503
649
  if (saved) log(` backed up the existing ${client.label} skill to ${saved}`);
504
- if (dryRun) { log(` [dry-run] copy ${client.skillFrom} -> ${client.skillTo}`); return; }
650
+ if (dryRun) {
651
+ log(` [dry-run] copy ${client.skillFrom} -> ${client.skillTo}`);
652
+ return;
653
+ }
505
654
  fs.rmSync(client.skillTo, { recursive: true, force: true });
506
655
  copyTree(client.skillFrom, client.skillTo);
507
656
  log(` skill: ${client.skillTo}`);
@@ -511,13 +660,22 @@ function installClientDirectly(client, bin) { step(`Registering with ${client.l
511
660
  // severally@severally) loads that copy, and a skills-dir copy or user-scope
512
661
  // registration from this installer would load beside it as a second server
513
662
  // and skill (verified with claude 2.1.302). Claude Code is left to it.
663
+ // Returns the plugin's id (severally@<marketplace>), or false.
514
664
  function claudeHasMarketplacePlugin() {
515
665
  if (!which('claude')) return false;
516
666
  const r = tryRun('claude', ['plugin', 'list', '--json'], { real: true });
517
667
  try {
518
- return r.ok && JSON.parse(r.out).some((p) => /^severally@/.test(p.id) && p.id !== 'severally@skills-dir');
519
- } catch { return false; }
668
+ return (
669
+ (r.ok &&
670
+ JSON.parse(r.out).find((/** @type {{ id: string }} */ p) => /^severally@/.test(p.id) && p.id !== 'severally@skills-dir')
671
+ ?.id) ||
672
+ false
673
+ );
674
+ } catch {
675
+ return false;
676
+ }
520
677
  }
678
+ const claudeMarketplace = claudeHasMarketplacePlugin();
521
679
 
522
680
  function leaveClaudeToMarketplace() {
523
681
  step('Claude Code has severally from the marketplace; leaving Claude Code to it');
@@ -532,9 +690,28 @@ function leaveClaudeToMarketplace() {
532
690
  const r = tryRun('claude', ['mcp', 'remove', '--scope', 'user', 'severally']);
533
691
  log(r.ok ? ' removed the user-scope MCP registration' : ` could not remove the user-scope registration: ${r.out}`);
534
692
  }
535
- log(' update Claude Code with /plugin marketplace update severally, then restart it');
693
+ // Brought to this release too, so one install run updates every copy.
694
+ const updated =
695
+ tryRun('claude', ['plugin', 'marketplace', 'update', 'severally'], { timeout: 120_000 }).ok &&
696
+ tryRun('claude', ['plugin', 'update', claudeMarketplace], { timeout: 120_000 }).ok;
697
+ log(
698
+ updated
699
+ ? ` updated ${claudeMarketplace} from its marketplace`
700
+ : ' could not update it here; in Claude Code run /plugin marketplace update severally',
701
+ );
702
+ }
703
+
704
+ // A previous plugin installation would otherwise retain its old checkout
705
+ // marketplace alongside a new direct server registration.
706
+ function removeCodexLocalMarketplace() {
707
+ if (!which('codex')) return;
708
+ const known = tryRun('codex', ['plugin', 'marketplace', 'list'], { real: true });
709
+ if (known.ok && known.out.includes('severally-local')) {
710
+ backup(path.join(codexHome, 'config.toml'));
711
+ tryRun('codex', ['plugin', 'remove', 'severally', '--marketplace', 'severally-local']);
712
+ run('codex', ['plugin', 'marketplace', 'remove', 'severally-local']);
713
+ }
536
714
  }
537
- const claudeMarketplace = claudeHasMarketplacePlugin();
538
715
 
539
716
  if (mode === 'plugin') {
540
717
  // ------------------------------------------------------------- plugin mode
@@ -558,41 +735,54 @@ if (mode === 'plugin') {
558
735
  if (dup.ok) {
559
736
  backup(path.join(home, '.claude.json'));
560
737
  const r = tryRun('claude', ['mcp', 'remove', '--scope', 'user', 'severally']);
561
- log(r.ok ? ' removed the duplicate user-scope MCP registration' : ` could not remove the duplicate registration: ${r.out}`);
738
+ log(
739
+ r.ok
740
+ ? ' removed the duplicate user-scope MCP registration'
741
+ : ` could not remove the duplicate registration: ${r.out}`,
742
+ );
562
743
  }
563
744
  }
564
745
  }
565
746
 
566
- step('Installing the plugin into Codex (managed local marketplace)');
567
- if (!which('codex')) {
568
- log(' codex CLI not found on PATH — skipped');
747
+ if (codexDirect && which('codex')) {
748
+ removeCodexLocalMarketplace();
749
+ installClientDirectly(codexClient, serverCommand);
569
750
  } else {
570
- backup(path.join(codexHome, 'config.toml'));
571
- const marketplace = 'severally-local';
572
- const known = tryRun('codex', ['plugin', 'marketplace', 'list'], { real: true });
573
- if (!known.ok) throw new Error(`Could not inspect Codex marketplaces: ${known.out}`);
574
- // Refresh our own registration even when already present: older installs
575
- // registered the checkout here. Reusing it would retain that dependency.
576
- if (known.out.includes(marketplace)) {
577
- tryRun('codex', ['plugin', 'remove', 'severally', '--marketplace', marketplace]);
578
- run('codex', ['plugin', 'marketplace', 'remove', marketplace]);
579
- }
580
- run('codex', ['plugin', 'marketplace', 'add', marketplaceRoot]);
581
- log(` marketplace added: ${marketplace} -> ${marketplaceRoot}`);
582
- const r = tryRun('codex', ['plugin', 'add', `severally@${marketplace}`]);
583
- log(r.ok ? ' plugin installed' : ` plugin install failed: ${r.out}`);
584
- if (!r.ok) process.exitCode = 1;
585
- // Same duplication concern on the Codex side.
586
- const dup = tryRun('codex', ['mcp', 'get', 'severally'], { real: true });
587
- if (dup.ok) {
588
- const rm = tryRun('codex', ['mcp', 'remove', 'severally']);
589
- log(rm.ok ? ' removed the duplicate global MCP registration' : ` could not remove the duplicate registration: ${rm.out}`);
590
- }
591
- const strayCodexSkill = path.join(codexHome, 'skills', 'severally');
592
- if (fs.existsSync(strayCodexSkill)) {
593
- const s2 = backupTree(strayCodexSkill);
594
- if (!dryRun) fs.rmSync(strayCodexSkill, { recursive: true, force: true });
595
- log(` removed the hand-copied skill (the plugin supplies it); backup: ${s2}`);
751
+ step('Installing the plugin into Codex (managed local marketplace)');
752
+ if (!which('codex')) {
753
+ log(' codex CLI not found on PATH — skipped');
754
+ } else {
755
+ backup(path.join(codexHome, 'config.toml'));
756
+ const marketplace = 'severally-local';
757
+ const known = tryRun('codex', ['plugin', 'marketplace', 'list'], { real: true });
758
+ if (!known.ok) throw new Error(`Could not inspect Codex marketplaces: ${known.out}`);
759
+ // Refresh our own registration even when already present: older installs
760
+ // registered the checkout here. Reusing it would retain that dependency.
761
+ if (known.out.includes(marketplace)) {
762
+ tryRun('codex', ['plugin', 'remove', 'severally', '--marketplace', marketplace]);
763
+ run('codex', ['plugin', 'marketplace', 'remove', marketplace]);
764
+ }
765
+ run('codex', ['plugin', 'marketplace', 'add', marketplaceRoot]);
766
+ log(` marketplace added: ${marketplace} -> ${marketplaceRoot}`);
767
+ const r = tryRun('codex', ['plugin', 'add', `severally@${marketplace}`]);
768
+ log(r.ok ? ' plugin installed' : ` plugin install failed: ${r.out}`);
769
+ if (!r.ok) process.exitCode = 1;
770
+ // Same duplication concern on the Codex side.
771
+ const dup = tryRun('codex', ['mcp', 'get', 'severally'], { real: true });
772
+ if (dup.ok) {
773
+ const rm = tryRun('codex', ['mcp', 'remove', 'severally']);
774
+ log(
775
+ rm.ok
776
+ ? ' removed the duplicate global MCP registration'
777
+ : ` could not remove the duplicate registration: ${rm.out}`,
778
+ );
779
+ }
780
+ const strayCodexSkill = path.join(codexHome, 'skills', 'severally');
781
+ if (fs.existsSync(strayCodexSkill)) {
782
+ const s2 = backupTree(strayCodexSkill);
783
+ if (!dryRun) fs.rmSync(strayCodexSkill, { recursive: true, force: true });
784
+ log(` removed the hand-copied skill (the plugin supplies it); backup: ${s2}`);
785
+ }
596
786
  }
597
787
  }
598
788
 
@@ -605,22 +795,65 @@ if (mode === 'plugin') {
605
795
  installClientDirectly(opencodeClient, serverCommand);
606
796
  } else {
607
797
  // ------------------------------------------------------------- manual mode
608
- // A previous plugin installation would otherwise retain its old checkout
609
- // marketplace alongside the new direct server registration.
610
- if (which('codex')) {
611
- const known = tryRun('codex', ['plugin', 'marketplace', 'list'], { real: true });
612
- if (known.ok && known.out.includes('severally-local')) {
613
- backup(path.join(codexHome, 'config.toml'));
614
- tryRun('codex', ['plugin', 'remove', 'severally', '--marketplace', 'severally-local']);
615
- run('codex', ['plugin', 'marketplace', 'remove', 'severally-local']);
616
- }
617
- }
798
+ removeCodexLocalMarketplace();
618
799
  for (const client of CLIENTS) {
619
800
  if (client.bin === 'claude' && claudeMarketplace) leaveClaudeToMarketplace();
620
801
  else installClientDirectly(client, serverCommand);
621
802
  }
622
803
  }
623
804
 
805
+ // ---------------------------------------------------------------- local tools
806
+ // Five of the six tools stay on this machine; Claude Code asks before each
807
+ // call unless they are allowed, so a first consultation can stop on prompt
808
+ // after prompt. Offered here, never assumed: consult_start, which sends the
809
+ // brief out, keeps asking.
810
+ const LOCAL_TOOLS = ['consult_get', 'consult_list', 'consult_cancel', 'consult_record', 'consult_export'];
811
+ async function allowLocalTools() {
812
+ if (!which('claude')) return;
813
+ const prefix = mode === 'plugin' || claudeMarketplace ? 'mcp__plugin_severally_severally__' : 'mcp__severally__';
814
+ const settingsFile = path.join(home, '.claude', 'settings.json');
815
+ /** @type {{ permissions?: { allow?: string[] } & Record<string, unknown> } & Record<string, unknown>} */
816
+ let settings = {};
817
+ try {
818
+ settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
819
+ } catch (e) {
820
+ // readFileSync and JSON.parse throw Errors; only fs ones carry a code.
821
+ const err = /** @type {NodeJS.ErrnoException} */ (e);
822
+ if (err.code !== 'ENOENT') {
823
+ log(` could not read ${settingsFile} (${err.message}); left as it is`);
824
+ return;
825
+ }
826
+ }
827
+ const allow = settings.permissions?.allow ?? [];
828
+ const missing = LOCAL_TOOLS.map((t) => prefix + t).filter((t) => !allow.includes(t));
829
+ if (!missing.length) return;
830
+ step('Claude Code tool permissions');
831
+ let yes = argv.has('--allow-local-tools');
832
+ if (!yes && process.stdin.isTTY && process.stdout.isTTY && !dryRun) {
833
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
834
+ const answer = await rl.question(
835
+ ` Let Claude Code run ${LOCAL_TOOLS.join(', ')} without asking each time?\n` +
836
+ ' They send nothing off this machine; consult_start, which does, still asks. [Y/n] ',
837
+ );
838
+ rl.close();
839
+ yes = !/^\s*n/i.test(answer);
840
+ }
841
+ if (!yes) {
842
+ log(` not changed; rerun with --allow-local-tools to allow them in ${settingsFile}`);
843
+ return;
844
+ }
845
+ if (dryRun) {
846
+ log(` [dry-run] add ${missing.join(', ')} to permissions.allow in ${settingsFile}`);
847
+ return;
848
+ }
849
+ backup(settingsFile);
850
+ settings.permissions = { ...settings.permissions, allow: [...allow, ...missing] };
851
+ fs.mkdirSync(path.dirname(settingsFile), { recursive: true });
852
+ fs.writeFileSync(settingsFile, `${JSON.stringify(settings, null, 2)}\n`);
853
+ log(` allowed in ${settingsFile}: ${missing.join(', ')}`);
854
+ }
855
+ await allowLocalTools();
856
+
624
857
  // ---------------------------------------------------------------- summary
625
858
  step('Done');
626
859
  if (fs.existsSync(backupDir)) log(` backups: ${backupDir}`);
@@ -629,10 +862,12 @@ if (keptRegistrations) {
629
862
  } else if (!dryRun && !process.exitCode) {
630
863
  log(' The registered server and skills no longer depend on the source checkout.');
631
864
  }
632
- log(mode === 'plugin' ? `
865
+ log(
866
+ mode === 'plugin'
867
+ ? `
633
868
  Verify with:
634
869
  claude plugin details severally
635
- codex plugin list
870
+ ${codexDirect ? 'codex mcp get severally' : 'codex plugin list'}
636
871
  agy mcp list
637
872
  opencode mcp list
638
873
 
@@ -640,7 +875,8 @@ Restart any running client session to pick the plugin up. In Claude Code the too
640
875
  mcp__plugin_severally_severally__consult_start / _get / _cancel / _list.
641
876
 
642
877
  Re-run this after "npm run build" to push an updated plugin to ${claudeMarketplace ? 'Codex' : 'both clients'} (Antigravity and
643
- OpenCode are registered directly each run, so a re-run always refreshes them too).` : `
878
+ OpenCode are registered directly each run, so a re-run always refreshes them too).`
879
+ : `
644
880
  Verify with:
645
881
  claude mcp get severally
646
882
  codex mcp get severally
@@ -648,4 +884,5 @@ Verify with:
648
884
  opencode mcp list
649
885
 
650
886
  In a new Claude Code session the tools appear as mcp__severally__consult_start / _get / _cancel / _list.
651
- Restart any running client session to pick the server up.`);
887
+ Restart any running client session to pick the server up.`,
888
+ );