severally-mcp 2.1.0 → 2.4.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 (52) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/CONFIG.md +10 -6
  3. package/README.ja.md +146 -69
  4. package/README.md +150 -73
  5. package/bin/severally-mcp.mjs +17 -2
  6. package/config.example.json +26 -0
  7. package/docs/how-it-works.ja.md +4 -2
  8. package/docs/how-it-works.md +5 -3
  9. package/docs/install.ja.md +59 -24
  10. package/docs/install.md +60 -24
  11. package/package.json +16 -4
  12. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  13. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  14. package/plugins/severally/dist/severally-mcp.mjs +29220 -27764
  15. package/plugins/severally/skills/_template/SKILL.md.tmpl +16 -22
  16. package/plugins/severally/skills/_template/reference.md.tmpl +11 -5
  17. package/plugins/severally/skills/antigravity/severally/SKILL.md +16 -22
  18. package/plugins/severally/skills/antigravity/severally/reference.md +11 -5
  19. package/plugins/severally/skills/claude/severally/SKILL.md +16 -22
  20. package/plugins/severally/skills/claude/severally/reference.md +11 -5
  21. package/plugins/severally/skills/codex/severally/SKILL.md +16 -22
  22. package/plugins/severally/skills/codex/severally/reference.md +11 -5
  23. package/plugins/severally/skills/opencode/severally/SKILL.md +16 -22
  24. package/plugins/severally/skills/opencode/severally/reference.md +11 -5
  25. package/scripts/build.mjs +31 -26
  26. package/scripts/doctor.mjs +52 -17
  27. package/scripts/init-config.mjs +8 -6
  28. package/scripts/install.mjs +342 -84
  29. package/scripts/live-check.mjs +52 -19
  30. package/scripts/live-mcp-check.mjs +42 -12
  31. package/scripts/test.mjs +10 -4
  32. package/src/adapters/antigravity-sandbox.mjs +39 -23
  33. package/src/adapters/antigravity.mjs +34 -16
  34. package/src/adapters/claude-code.mjs +73 -21
  35. package/src/adapters/codex.mjs +69 -27
  36. package/src/adapters/opencode-sandbox.mjs +40 -21
  37. package/src/adapters/opencode.mjs +31 -13
  38. package/src/brief.mjs +65 -41
  39. package/src/consultants.mjs +99 -30
  40. package/src/export.mjs +49 -12
  41. package/src/expose-paths.mjs +162 -44
  42. package/src/failures.mjs +23 -13
  43. package/src/jobs.mjs +420 -140
  44. package/src/parse-result.mjs +85 -30
  45. package/src/platform.mjs +5 -7
  46. package/src/policy.mjs +139 -32
  47. package/src/redact.mjs +38 -14
  48. package/src/run.mjs +104 -22
  49. package/src/schema.mjs +76 -22
  50. package/src/server.mjs +176 -166
  51. package/src/status.mjs +132 -0
  52. package/src/store.mjs +164 -19
@@ -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');
@@ -98,7 +112,10 @@ function backupName(target) {
98
112
 
99
113
  function backup(file) {
100
114
  if (!fs.existsSync(file)) return null;
101
- if (dryRun) { log(` [dry-run] backup ${file}`); return null; }
115
+ if (dryRun) {
116
+ log(` [dry-run] backup ${file}`);
117
+ return null;
118
+ }
102
119
  fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
103
120
  const dest = path.join(backupDir, backupName(file));
104
121
  // Several client operations can touch the same config in one install.
@@ -110,7 +127,10 @@ function backup(file) {
110
127
 
111
128
  function backupTree(dir) {
112
129
  if (!fs.existsSync(dir)) return null;
113
- if (dryRun) { log(` [dry-run] backup ${dir}`); return null; }
130
+ if (dryRun) {
131
+ log(` [dry-run] backup ${dir}`);
132
+ return null;
133
+ }
114
134
  fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
115
135
  const dest = path.join(backupDir, backupName(dir));
116
136
  copyTree(dir, dest);
@@ -128,7 +148,8 @@ function which(bin) {
128
148
  // severally-mcp package, so they are not where the command is looked for.
129
149
  function runtimeCommand() {
130
150
  const key = Object.keys(process.env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH';
131
- const dirs = (process.env[key] ?? '').split(path.delimiter)
151
+ const dirs = (process.env[key] ?? '')
152
+ .split(path.delimiter)
132
153
  .filter((dir) => !/(^|[\\/])node_modules[\\/]\.bin[\\/]?$/.test(dir.replace(/^"(.*)"$/, '$1')));
133
154
  return findCommand('severally-mcp', { env: { ...process.env, [key]: dirs.join(path.delimiter) } });
134
155
  }
@@ -153,7 +174,10 @@ function installRuntime() {
153
174
  run(process.execPath, ['--check', staged], { real: true });
154
175
  // The bundle carries other packages' code, so their licences travel with
155
176
  // 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'));
177
+ fs.copyFileSync(
178
+ path.join(path.dirname(source), 'THIRD_PARTY_NOTICES'),
179
+ path.join(path.dirname(installed), 'THIRD_PARTY_NOTICES'),
180
+ );
157
181
  fs.copyFileSync(path.join(PLUGIN, 'LICENSE'), path.join(path.dirname(installed), 'LICENSE'));
158
182
  backup(installed);
159
183
  fs.renameSync(staged, installed);
@@ -164,15 +188,28 @@ function installRuntime() {
164
188
  return installed;
165
189
  }
166
190
 
191
+ const RUNTIME_PACKAGE = 'severally-runtime';
192
+ // Why plugin mode registers Codex directly (no global command), or null.
193
+ let codexDirect = null;
194
+ let globalRoot = null;
167
195
  // Run from a global install of the npm package, the global install below
168
196
  // would replace the very package this script and its plugin files are read
169
197
  // from. Refused before anything changes.
170
198
  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.');
199
+ const npmRoot = tryRun('npm', ['root', '-g'], { real: true });
200
+ globalRoot = npmRoot.ok ? npmRoot.out.trim() || null : null;
201
+ const inside = (dir) => {
202
+ try {
203
+ return fs.realpathSync(root).startsWith(fs.realpathSync(dir) + path.sep);
204
+ } catch {
205
+ return false;
206
+ }
207
+ };
208
+ if (globalRoot && inside(globalRoot)) {
209
+ throw new Error(
210
+ 'severally-install was run from a global npm install of severally-mcp, which installing would replace. ' +
211
+ 'Remove it (npm uninstall -g severally-mcp), then run npx -y severally-mcp@latest install, or install from a clone.',
212
+ );
176
213
  }
177
214
  }
178
215
 
@@ -184,29 +221,53 @@ const marketplaceRoot = path.join(home, '.severally', 'marketplace');
184
221
  if (mode === 'plugin') {
185
222
  // npm may symlink a directory install. Its source must therefore be the
186
223
  // persistent runtime, never the user's disposable checkout.
224
+ // The runtime goes global under a name of its own: under the npm package's
225
+ // name and version, npx would run this installer-less copy for
226
+ // `npx severally-mcp@<that version> install` instead of fetching the package.
187
227
  const packageFile = path.join(runtimeDir, 'package.json');
188
228
  const metadata = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
189
229
  if (!dryRun) {
190
230
  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`);
231
+ fs.writeFileSync(
232
+ packageFile,
233
+ `${JSON.stringify(
234
+ { name: RUNTIME_PACKAGE, version: metadata.version, type: 'module', bin: { 'severally-mcp': 'severally-mcp.mjs' } },
235
+ null,
236
+ 2,
237
+ )}\n`,
238
+ );
193
239
  fs.chmodSync(serverCommand[1], 0o755);
194
240
  }
195
241
  if (skipGlobal) {
196
242
  const existing = runtimeCommand();
197
243
  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.');
244
+ throw new Error(
245
+ '--skip-global requires severally-mcp on PATH to point to the installed runtime. Omit --skip-global or use --manual.',
246
+ );
199
247
  }
200
248
  log(' --skip-global: using the existing runtime command');
201
249
  } else {
202
- run('npm', ['install', '-g', runtimeDir, '--install-links=false'], { cwd: dryRun ? root : runtimeDir });
250
+ // Installs before the rename put the runtime there under the package's
251
+ // own name; its severally-mcp command would collide with the new one.
252
+ if (globalRoot && fs.existsSync(path.join(globalRoot, metadata.name))) {
253
+ const r = tryRun('npm', ['uninstall', '-g', metadata.name]);
254
+ log(
255
+ r.ok
256
+ ? ` removed the global ${metadata.name} package (the runtime is now ${RUNTIME_PACKAGE})`
257
+ : ` could not remove the global ${metadata.name} package: ${r.out}`,
258
+ );
259
+ }
260
+ const r = tryRun('npm', ['install', '-g', runtimeDir, '--install-links=false'], { cwd: dryRun ? root : runtimeDir });
203
261
  if (which('nodenv')) tryRun('nodenv', ['rehash']);
204
- if (!dryRun) {
262
+ if (!r.ok) {
263
+ 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]})`;
264
+ } else if (!dryRun) {
205
265
  const installedCommand = runtimeCommand();
206
266
  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.');
267
+ codexDirect = "npm's global severally-mcp command is not first on PATH";
208
268
  }
209
269
  }
270
+ if (codexDirect) log(` ${codexDirect}; Codex will be registered directly instead, which needs no global command`);
210
271
  }
211
272
  if (dryRun) {
212
273
  backupTree(marketplaceRoot);
@@ -221,8 +282,10 @@ if (mode === 'plugin') {
221
282
  try {
222
283
  fs.mkdirSync(path.join(next, '.agents', 'plugins'), { recursive: true });
223
284
  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'));
285
+ fs.copyFileSync(
286
+ path.join(root, '.agents', 'plugins', 'marketplace.json'),
287
+ path.join(next, '.agents', 'plugins', 'marketplace.json'),
288
+ );
226
289
  backupTree(marketplaceRoot);
227
290
  if (fs.existsSync(marketplaceRoot)) fs.renameSync(marketplaceRoot, previous);
228
291
  try {
@@ -249,8 +312,64 @@ const codexHome = process.env.CODEX_HOME ?? path.join(home, '.codex');
249
312
  // .mcp.json carries the same value for Claude Code, and its Codex manifest
250
313
  // with tool_timeout_sec 660 (codex 0.163 stops a tool call at 300 s by
251
314
  // default, progress notwithstanding). `codex mcp add` cannot set that
252
- // timeout, so a --manual Codex keeps 45 s.
315
+ // timeout, so a --manual Codex gets it as one line written into config.toml
316
+ // under [mcp_servers.severally] (see the Codex row), together with the env;
317
+ // both or neither, since the wait without the timeout would outlast Codex's
318
+ // own deadline.
253
319
  const LONG_WAIT = 'SEVERALLY_MAX_WAIT_MS=600000';
320
+ const AGY_WAIT = 'SEVERALLY_MAX_WAIT_MS=165000';
321
+ const agyConfig = path.join(home, '.gemini', 'config', 'mcp_config.json');
322
+ const CODEX_TIMEOUT_SEC = 660;
323
+ const codexConfig = path.join(codexHome, 'config.toml');
324
+ // The user's own tool_timeout_sec, read before --force removes the table that holds it.
325
+ let priorCodexTimeout;
326
+
327
+ // Where [mcp_servers.severally] sits in config.toml, and what it holds.
328
+ // A minimal text read, not a TOML parser: the table runs to the next header
329
+ // that is not one of its own sub-tables (codex may put env in one).
330
+ function codexTable() {
331
+ let lines;
332
+ try {
333
+ lines = fs.readFileSync(codexConfig, 'utf8').split('\n');
334
+ } catch (err) {
335
+ return { reason: `cannot read ${codexConfig}: ${err.message}` };
336
+ }
337
+ const start = lines.findIndex((l) => /^\s*\[\s*mcp_servers\.severally\s*\]/.test(l));
338
+ if (start < 0) return { reason: `${codexConfig} has no [mcp_servers.severally] table` };
339
+ let end = lines.findIndex((l, i) => i > start && /^\s*\[/.test(l) && !/^\s*\[\s*mcp_servers\.severally\./.test(l));
340
+ if (end < 0) end = lines.length;
341
+ const block = lines.slice(start + 1, end).join('\n');
342
+ const timeout = block.match(/^\s*tool_timeout_sec\s*=\s*(\d+(?:\.\d+)?)/m)?.[1];
343
+ return {
344
+ lines,
345
+ start,
346
+ timeout: timeout === undefined ? undefined : Number(timeout),
347
+ wait: /SEVERALLY_MAX_WAIT_MS\s*=\s*"?\d+/.test(block),
348
+ };
349
+ }
350
+
351
+ // The wait follows the timeout Codex will enforce: 15 s under the user's own
352
+ // tool_timeout_sec when that is short, else the full 600000 ms.
353
+ function codexPlan() {
354
+ const sec = priorCodexTimeout ?? CODEX_TIMEOUT_SEC;
355
+ return { sec, ms: sec < 615 ? Math.max(0, Math.floor((sec - 15) * 1000)) : 600000 };
356
+ }
357
+
358
+ // Inserts tool_timeout_sec right after the table header. Returns why it could
359
+ // not, or null.
360
+ function writeCodexTimeout(sec) {
361
+ const t = codexTable();
362
+ if (t.reason) return t.reason;
363
+ if (t.timeout !== undefined) return null; // never overwrite the user's value
364
+ t.lines.splice(t.start + 1, 0, `tool_timeout_sec = ${sec}`);
365
+ try {
366
+ fs.writeFileSync(codexConfig, t.lines.join('\n'));
367
+ } catch (err) {
368
+ return `cannot write ${codexConfig}: ${err.message}`;
369
+ }
370
+ return null;
371
+ }
372
+
254
373
  const claudeSkillsDir = path.join(home, '.claude', 'skills');
255
374
 
256
375
  // ---------------------------------------------------------------- clients
@@ -279,8 +398,37 @@ const CLIENTS = [
279
398
  label: 'Codex',
280
399
  backup: [path.join(codexHome, 'config.toml')],
281
400
  isRegistered: () => tryRun('codex', ['mcp', 'get', 'severally'], { real: true }).ok,
282
- remove: () => tryRun('codex', ['mcp', 'remove', 'severally']),
283
- add: (bin) => tryRun('codex', ['mcp', 'add', 'severally', '--', ...bin]),
401
+ remove: () => {
402
+ priorCodexTimeout = codexTable().timeout;
403
+ return tryRun('codex', ['mcp', 'remove', 'severally']);
404
+ },
405
+ // The long wait is the env plus tool_timeout_sec in config.toml, both or neither.
406
+ add: (bin) => {
407
+ const { sec, ms } = codexPlan();
408
+ const r = tryRun('codex', ['mcp', 'add', 'severally', '--env', `SEVERALLY_MAX_WAIT_MS=${ms}`, '--', ...bin]);
409
+ if (!r.ok) return r;
410
+ if (dryRun) {
411
+ log(` [dry-run] write tool_timeout_sec = ${sec} into ${codexConfig} under [mcp_servers.severally]`);
412
+ return r;
413
+ }
414
+ const why = writeCodexTimeout(sec);
415
+ if (!why) {
416
+ log(` consult_get waits up to ${ms / 1000} s (SEVERALLY_MAX_WAIT_MS=${ms}, tool_timeout_sec = ${sec})`);
417
+ return r;
418
+ }
419
+ const back = tryRun('codex', ['mcp', 'remove', 'severally']);
420
+ if (!back.ok) return back;
421
+ log(` the long consult_get wait was not set (it stays 45 s): ${why}`);
422
+ return tryRun('codex', ['mcp', 'add', 'severally', '--', ...bin]);
423
+ },
424
+ keptNote: () => {
425
+ const t = codexTable();
426
+ if (t.timeout === undefined || !t.wait) {
427
+ log(
428
+ ` this registration lacks the long consult_get wait (45 s); --force adds it (SEVERALLY_MAX_WAIT_MS and tool_timeout_sec = ${CODEX_TIMEOUT_SEC})`,
429
+ );
430
+ }
431
+ },
284
432
  skillFrom: path.join(PLUGIN, 'skills', 'codex', 'severally'),
285
433
  skillTo: path.join(codexHome, 'skills', 'severally'),
286
434
  },
@@ -297,17 +445,21 @@ const CLIENTS = [
297
445
  return r.ok && /severally/.test(r.out);
298
446
  },
299
447
  remove: () => tryRun('agy', ['mcp', 'remove', 'severally']),
300
- add: (bin) => tryRun('agy', ['mcp', 'add', 'severally', ...bin]),
448
+ // agy stops an MCP tool call at 3 minutes, progress notifications or not
449
+ // (measured: a 310 s call ended at 180 s), so its wait is 15 s under that.
450
+ add: (bin) => tryRun('agy', ['mcp', 'add', '--env', AGY_WAIT, 'severally', ...bin]),
451
+ keptNote: () => {
452
+ if (!/SEVERALLY_MAX_WAIT_MS/.test(fs.existsSync(agyConfig) ? fs.readFileSync(agyConfig, 'utf8') : '')) {
453
+ log(' this registration lacks the longer consult_get wait (45 s); --force adds it (SEVERALLY_MAX_WAIT_MS=165000)');
454
+ }
455
+ },
301
456
  skillFrom: path.join(PLUGIN, 'skills', 'antigravity', 'severally'),
302
457
  skillTo: path.join(home, '.gemini', 'config', 'skills', 'severally'),
303
458
  },
304
459
  {
305
460
  bin: 'opencode',
306
461
  label: 'OpenCode',
307
- backup: [
308
- path.join(xdgConfig, 'opencode', 'opencode.json'),
309
- path.join(xdgConfig, 'opencode', 'opencode.jsonc'),
310
- ],
462
+ backup: [path.join(xdgConfig, 'opencode', 'opencode.json'), path.join(xdgConfig, 'opencode', 'opencode.jsonc')],
311
463
  // opencode has no `mcp get`; list and look for the exact server name.
312
464
  // `mcp list` prints through the prompt library, but the per-server lines
313
465
  // still reach stdout when stdout is not a TTY ("✓ severally connected").
@@ -326,19 +478,38 @@ const CLIENTS = [
326
478
  // reachable only inside whatever directory the installer happened to
327
479
  // run from.
328
480
  remove: () => ({ ok: true, out: 'opencode has no mcp remove; re-adding replaces the entry' }),
329
- add: (bin) => tryRun('opencode', ['mcp', 'add', 'severally', '--global', '--', ...bin]),
481
+ // OpenCode sets no deadline of its own on a tool call (a 610 s call came
482
+ // back), so it gets the long consult_get wait with no timeout to pair.
483
+ // opencode 1.19 (its npm release) has no --global and always writes the
484
+ // global config; it is asked again without the flag, from the home
485
+ // folder, and the check after the add confirms where it landed.
486
+ add: (bin) => {
487
+ const r = tryRun('opencode', ['mcp', 'add', 'severally', '--global', '--env', LONG_WAIT, '--', ...bin]);
488
+ return r.ok ? r : tryRun('opencode', ['mcp', 'add', 'severally', '--env', LONG_WAIT, '--', ...bin], { cwd: home });
489
+ },
490
+ keptNote: () => {
491
+ const text = [path.join(xdgConfig, 'opencode', 'opencode.json'), path.join(xdgConfig, 'opencode', 'opencode.jsonc')]
492
+ .map((f) => {
493
+ try {
494
+ return fs.readFileSync(f, 'utf8');
495
+ } catch {
496
+ return '';
497
+ }
498
+ })
499
+ .join('\n');
500
+ if (!text.includes('SEVERALLY_MAX_WAIT_MS'))
501
+ log(' this registration lacks the long consult_get wait (45 s); --force adds it (SEVERALLY_MAX_WAIT_MS)');
502
+ },
330
503
  // A zero exit from `mcp add` can be a usage dump in disguise (see above),
331
504
  // so this client's registration is verified by listing after adding --
332
505
  // and the recorded command is compared against this install's.
333
506
  verifyAdd: true,
334
- registrationConfig: [
335
- path.join(xdgConfig, 'opencode', 'opencode.json'),
336
- path.join(xdgConfig, 'opencode', 'opencode.jsonc'),
337
- ],
507
+ registrationConfig: [path.join(xdgConfig, 'opencode', 'opencode.json'), path.join(xdgConfig, 'opencode', 'opencode.jsonc')],
338
508
  skillFrom: path.join(PLUGIN, 'skills', 'opencode', 'severally'),
339
509
  skillTo: path.join(xdgConfig, 'opencode', 'skills', 'severally'),
340
510
  },
341
511
  ];
512
+ const codexClient = CLIENTS.find((c) => c.bin === 'codex');
342
513
  const antigravityClient = CLIENTS.find((c) => c.bin === 'agy');
343
514
  const opencodeClient = CLIENTS.find((c) => c.bin === 'opencode');
344
515
 
@@ -351,7 +522,9 @@ function registrationCommandMatches(client, bin) {
351
522
  let cfg;
352
523
  try {
353
524
  cfg = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, ''));
354
- } catch { continue; }
525
+ } catch {
526
+ continue;
527
+ }
355
528
  const entry = cfg.mcp?.servers?.severally ?? cfg.mcp?.severally;
356
529
  const command = entry?.command ?? entry;
357
530
  if (Array.isArray(command)) return JSON.stringify(command) === JSON.stringify(bin);
@@ -372,7 +545,8 @@ function listedAfterAdd(client) {
372
545
  // Registers one client's MCP server directly (through its own `mcp add`) and
373
546
  // copies its skill, backing up everything it touches first. Shared by plugin
374
547
  // mode (Antigravity only) and --manual mode (all three clients).
375
- function installClientDirectly(client, bin) { step(`Registering with ${client.label}`);
548
+ function installClientDirectly(client, bin) {
549
+ step(`Registering with ${client.label}`);
376
550
  if (!which(client.bin)) {
377
551
  log(` ${client.bin} CLI not found on PATH — skipped`);
378
552
  return;
@@ -381,6 +555,7 @@ function installClientDirectly(client, bin) { step(`Registering with ${client.l
381
555
  if (client.isRegistered() && !force) {
382
556
  keptRegistrations = true;
383
557
  log(' already registered; leaving it as it is (use --force to re-register)');
558
+ client.keptNote?.();
384
559
  } else {
385
560
  if (client.isRegistered()) client.remove();
386
561
  const r = client.add(bin);
@@ -416,7 +591,10 @@ function installClientDirectly(client, bin) { step(`Registering with ${client.l
416
591
  if (!fs.existsSync(parent) && !dryRun) fs.mkdirSync(parent, { recursive: true });
417
592
  const saved = backupTree(client.skillTo);
418
593
  if (saved) log(` backed up the existing ${client.label} skill to ${saved}`);
419
- if (dryRun) { log(` [dry-run] copy ${client.skillFrom} -> ${client.skillTo}`); return; }
594
+ if (dryRun) {
595
+ log(` [dry-run] copy ${client.skillFrom} -> ${client.skillTo}`);
596
+ return;
597
+ }
420
598
  fs.rmSync(client.skillTo, { recursive: true, force: true });
421
599
  copyTree(client.skillFrom, client.skillTo);
422
600
  log(` skill: ${client.skillTo}`);
@@ -426,13 +604,17 @@ function installClientDirectly(client, bin) { step(`Registering with ${client.l
426
604
  // severally@severally) loads that copy, and a skills-dir copy or user-scope
427
605
  // registration from this installer would load beside it as a second server
428
606
  // and skill (verified with claude 2.1.302). Claude Code is left to it.
607
+ // Returns the plugin's id (severally@<marketplace>), or false.
429
608
  function claudeHasMarketplacePlugin() {
430
609
  if (!which('claude')) return false;
431
610
  const r = tryRun('claude', ['plugin', 'list', '--json'], { real: true });
432
611
  try {
433
- return r.ok && JSON.parse(r.out).some((p) => /^severally@/.test(p.id) && p.id !== 'severally@skills-dir');
434
- } catch { return false; }
612
+ return (r.ok && JSON.parse(r.out).find((p) => /^severally@/.test(p.id) && p.id !== 'severally@skills-dir')?.id) || false;
613
+ } catch {
614
+ return false;
615
+ }
435
616
  }
617
+ const claudeMarketplace = claudeHasMarketplacePlugin();
436
618
 
437
619
  function leaveClaudeToMarketplace() {
438
620
  step('Claude Code has severally from the marketplace; leaving Claude Code to it');
@@ -447,9 +629,28 @@ function leaveClaudeToMarketplace() {
447
629
  const r = tryRun('claude', ['mcp', 'remove', '--scope', 'user', 'severally']);
448
630
  log(r.ok ? ' removed the user-scope MCP registration' : ` could not remove the user-scope registration: ${r.out}`);
449
631
  }
450
- log(' update Claude Code with /plugin marketplace update severally, then restart it');
632
+ // Brought to this release too, so one install run updates every copy.
633
+ const updated =
634
+ tryRun('claude', ['plugin', 'marketplace', 'update', 'severally'], { timeout: 120_000 }).ok &&
635
+ tryRun('claude', ['plugin', 'update', claudeMarketplace], { timeout: 120_000 }).ok;
636
+ log(
637
+ updated
638
+ ? ` updated ${claudeMarketplace} from its marketplace`
639
+ : ' could not update it here; in Claude Code run /plugin marketplace update severally',
640
+ );
641
+ }
642
+
643
+ // A previous plugin installation would otherwise retain its old checkout
644
+ // marketplace alongside a new direct server registration.
645
+ function removeCodexLocalMarketplace() {
646
+ if (!which('codex')) return;
647
+ const known = tryRun('codex', ['plugin', 'marketplace', 'list'], { real: true });
648
+ if (known.ok && known.out.includes('severally-local')) {
649
+ backup(path.join(codexHome, 'config.toml'));
650
+ tryRun('codex', ['plugin', 'remove', 'severally', '--marketplace', 'severally-local']);
651
+ run('codex', ['plugin', 'marketplace', 'remove', 'severally-local']);
652
+ }
451
653
  }
452
- const claudeMarketplace = claudeHasMarketplacePlugin();
453
654
 
454
655
  if (mode === 'plugin') {
455
656
  // ------------------------------------------------------------- plugin mode
@@ -473,41 +674,54 @@ if (mode === 'plugin') {
473
674
  if (dup.ok) {
474
675
  backup(path.join(home, '.claude.json'));
475
676
  const r = tryRun('claude', ['mcp', 'remove', '--scope', 'user', 'severally']);
476
- log(r.ok ? ' removed the duplicate user-scope MCP registration' : ` could not remove the duplicate registration: ${r.out}`);
677
+ log(
678
+ r.ok
679
+ ? ' removed the duplicate user-scope MCP registration'
680
+ : ` could not remove the duplicate registration: ${r.out}`,
681
+ );
477
682
  }
478
683
  }
479
684
  }
480
685
 
481
- step('Installing the plugin into Codex (managed local marketplace)');
482
- if (!which('codex')) {
483
- log(' codex CLI not found on PATH — skipped');
686
+ if (codexDirect && which('codex')) {
687
+ removeCodexLocalMarketplace();
688
+ installClientDirectly(codexClient, serverCommand);
484
689
  } else {
485
- backup(path.join(codexHome, 'config.toml'));
486
- const marketplace = 'severally-local';
487
- const known = tryRun('codex', ['plugin', 'marketplace', 'list'], { real: true });
488
- if (!known.ok) throw new Error(`Could not inspect Codex marketplaces: ${known.out}`);
489
- // Refresh our own registration even when already present: older installs
490
- // registered the checkout here. Reusing it would retain that dependency.
491
- if (known.out.includes(marketplace)) {
492
- tryRun('codex', ['plugin', 'remove', 'severally', '--marketplace', marketplace]);
493
- run('codex', ['plugin', 'marketplace', 'remove', marketplace]);
494
- }
495
- run('codex', ['plugin', 'marketplace', 'add', marketplaceRoot]);
496
- log(` marketplace added: ${marketplace} -> ${marketplaceRoot}`);
497
- const r = tryRun('codex', ['plugin', 'add', `severally@${marketplace}`]);
498
- log(r.ok ? ' plugin installed' : ` plugin install failed: ${r.out}`);
499
- if (!r.ok) process.exitCode = 1;
500
- // Same duplication concern on the Codex side.
501
- const dup = tryRun('codex', ['mcp', 'get', 'severally'], { real: true });
502
- if (dup.ok) {
503
- const rm = tryRun('codex', ['mcp', 'remove', 'severally']);
504
- log(rm.ok ? ' removed the duplicate global MCP registration' : ` could not remove the duplicate registration: ${rm.out}`);
505
- }
506
- const strayCodexSkill = path.join(codexHome, 'skills', 'severally');
507
- if (fs.existsSync(strayCodexSkill)) {
508
- const s2 = backupTree(strayCodexSkill);
509
- if (!dryRun) fs.rmSync(strayCodexSkill, { recursive: true, force: true });
510
- log(` removed the hand-copied skill (the plugin supplies it); backup: ${s2}`);
690
+ step('Installing the plugin into Codex (managed local marketplace)');
691
+ if (!which('codex')) {
692
+ log(' codex CLI not found on PATH — skipped');
693
+ } else {
694
+ backup(path.join(codexHome, 'config.toml'));
695
+ const marketplace = 'severally-local';
696
+ const known = tryRun('codex', ['plugin', 'marketplace', 'list'], { real: true });
697
+ if (!known.ok) throw new Error(`Could not inspect Codex marketplaces: ${known.out}`);
698
+ // Refresh our own registration even when already present: older installs
699
+ // registered the checkout here. Reusing it would retain that dependency.
700
+ if (known.out.includes(marketplace)) {
701
+ tryRun('codex', ['plugin', 'remove', 'severally', '--marketplace', marketplace]);
702
+ run('codex', ['plugin', 'marketplace', 'remove', marketplace]);
703
+ }
704
+ run('codex', ['plugin', 'marketplace', 'add', marketplaceRoot]);
705
+ log(` marketplace added: ${marketplace} -> ${marketplaceRoot}`);
706
+ const r = tryRun('codex', ['plugin', 'add', `severally@${marketplace}`]);
707
+ log(r.ok ? ' plugin installed' : ` plugin install failed: ${r.out}`);
708
+ if (!r.ok) process.exitCode = 1;
709
+ // Same duplication concern on the Codex side.
710
+ const dup = tryRun('codex', ['mcp', 'get', 'severally'], { real: true });
711
+ if (dup.ok) {
712
+ const rm = tryRun('codex', ['mcp', 'remove', 'severally']);
713
+ log(
714
+ rm.ok
715
+ ? ' removed the duplicate global MCP registration'
716
+ : ` could not remove the duplicate registration: ${rm.out}`,
717
+ );
718
+ }
719
+ const strayCodexSkill = path.join(codexHome, 'skills', 'severally');
720
+ if (fs.existsSync(strayCodexSkill)) {
721
+ const s2 = backupTree(strayCodexSkill);
722
+ if (!dryRun) fs.rmSync(strayCodexSkill, { recursive: true, force: true });
723
+ log(` removed the hand-copied skill (the plugin supplies it); backup: ${s2}`);
724
+ }
511
725
  }
512
726
  }
513
727
 
@@ -520,22 +734,62 @@ if (mode === 'plugin') {
520
734
  installClientDirectly(opencodeClient, serverCommand);
521
735
  } else {
522
736
  // ------------------------------------------------------------- manual mode
523
- // A previous plugin installation would otherwise retain its old checkout
524
- // marketplace alongside the new direct server registration.
525
- if (which('codex')) {
526
- const known = tryRun('codex', ['plugin', 'marketplace', 'list'], { real: true });
527
- if (known.ok && known.out.includes('severally-local')) {
528
- backup(path.join(codexHome, 'config.toml'));
529
- tryRun('codex', ['plugin', 'remove', 'severally', '--marketplace', 'severally-local']);
530
- run('codex', ['plugin', 'marketplace', 'remove', 'severally-local']);
531
- }
532
- }
737
+ removeCodexLocalMarketplace();
533
738
  for (const client of CLIENTS) {
534
739
  if (client.bin === 'claude' && claudeMarketplace) leaveClaudeToMarketplace();
535
740
  else installClientDirectly(client, serverCommand);
536
741
  }
537
742
  }
538
743
 
744
+ // ---------------------------------------------------------------- local tools
745
+ // Five of the six tools stay on this machine; Claude Code asks before each
746
+ // call unless they are allowed, so a first consultation can stop on prompt
747
+ // after prompt. Offered here, never assumed: consult_start, which sends the
748
+ // brief out, keeps asking.
749
+ const LOCAL_TOOLS = ['consult_get', 'consult_list', 'consult_cancel', 'consult_record', 'consult_export'];
750
+ async function allowLocalTools() {
751
+ if (!which('claude')) return;
752
+ const prefix = mode === 'plugin' || claudeMarketplace ? 'mcp__plugin_severally_severally__' : 'mcp__severally__';
753
+ const settingsFile = path.join(home, '.claude', 'settings.json');
754
+ let settings = {};
755
+ try {
756
+ settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
757
+ } catch (err) {
758
+ if (err.code !== 'ENOENT') {
759
+ log(` could not read ${settingsFile} (${err.message}); left as it is`);
760
+ return;
761
+ }
762
+ }
763
+ const allow = settings.permissions?.allow ?? [];
764
+ const missing = LOCAL_TOOLS.map((t) => prefix + t).filter((t) => !allow.includes(t));
765
+ if (!missing.length) return;
766
+ step('Claude Code tool permissions');
767
+ let yes = argv.has('--allow-local-tools');
768
+ if (!yes && process.stdin.isTTY && process.stdout.isTTY && !dryRun) {
769
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
770
+ const answer = await rl.question(
771
+ ` Let Claude Code run ${LOCAL_TOOLS.join(', ')} without asking each time?\n` +
772
+ ' They send nothing off this machine; consult_start, which does, still asks. [Y/n] ',
773
+ );
774
+ rl.close();
775
+ yes = !/^\s*n/i.test(answer);
776
+ }
777
+ if (!yes) {
778
+ log(` not changed; rerun with --allow-local-tools to allow them in ${settingsFile}`);
779
+ return;
780
+ }
781
+ if (dryRun) {
782
+ log(` [dry-run] add ${missing.join(', ')} to permissions.allow in ${settingsFile}`);
783
+ return;
784
+ }
785
+ backup(settingsFile);
786
+ settings.permissions = { ...settings.permissions, allow: [...allow, ...missing] };
787
+ fs.mkdirSync(path.dirname(settingsFile), { recursive: true });
788
+ fs.writeFileSync(settingsFile, `${JSON.stringify(settings, null, 2)}\n`);
789
+ log(` allowed in ${settingsFile}: ${missing.join(', ')}`);
790
+ }
791
+ await allowLocalTools();
792
+
539
793
  // ---------------------------------------------------------------- summary
540
794
  step('Done');
541
795
  if (fs.existsSync(backupDir)) log(` backups: ${backupDir}`);
@@ -544,10 +798,12 @@ if (keptRegistrations) {
544
798
  } else if (!dryRun && !process.exitCode) {
545
799
  log(' The registered server and skills no longer depend on the source checkout.');
546
800
  }
547
- log(mode === 'plugin' ? `
801
+ log(
802
+ mode === 'plugin'
803
+ ? `
548
804
  Verify with:
549
805
  claude plugin details severally
550
- codex plugin list
806
+ ${codexDirect ? 'codex mcp get severally' : 'codex plugin list'}
551
807
  agy mcp list
552
808
  opencode mcp list
553
809
 
@@ -555,7 +811,8 @@ Restart any running client session to pick the plugin up. In Claude Code the too
555
811
  mcp__plugin_severally_severally__consult_start / _get / _cancel / _list.
556
812
 
557
813
  Re-run this after "npm run build" to push an updated plugin to ${claudeMarketplace ? 'Codex' : 'both clients'} (Antigravity and
558
- OpenCode are registered directly each run, so a re-run always refreshes them too).` : `
814
+ OpenCode are registered directly each run, so a re-run always refreshes them too).`
815
+ : `
559
816
  Verify with:
560
817
  claude mcp get severally
561
818
  codex mcp get severally
@@ -563,4 +820,5 @@ Verify with:
563
820
  opencode mcp list
564
821
 
565
822
  In a new Claude Code session the tools appear as mcp__severally__consult_start / _get / _cancel / _list.
566
- Restart any running client session to pick the server up.`);
823
+ Restart any running client session to pick the server up.`,
824
+ );