severally-mcp 2.3.1 → 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.
@@ -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 {
@@ -266,14 +329,23 @@ let priorCodexTimeout;
266
329
  // that is not one of its own sub-tables (codex may put env in one).
267
330
  function codexTable() {
268
331
  let lines;
269
- try { lines = fs.readFileSync(codexConfig, 'utf8').split('\n'); } catch (err) { return { reason: `cannot read ${codexConfig}: ${err.message}` }; }
332
+ try {
333
+ lines = fs.readFileSync(codexConfig, 'utf8').split('\n');
334
+ } catch (err) {
335
+ return { reason: `cannot read ${codexConfig}: ${err.message}` };
336
+ }
270
337
  const start = lines.findIndex((l) => /^\s*\[\s*mcp_servers\.severally\s*\]/.test(l));
271
338
  if (start < 0) return { reason: `${codexConfig} has no [mcp_servers.severally] table` };
272
339
  let end = lines.findIndex((l, i) => i > start && /^\s*\[/.test(l) && !/^\s*\[\s*mcp_servers\.severally\./.test(l));
273
340
  if (end < 0) end = lines.length;
274
341
  const block = lines.slice(start + 1, end).join('\n');
275
342
  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) };
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
+ };
277
349
  }
278
350
 
279
351
  // The wait follows the timeout Codex will enforce: 15 s under the user's own
@@ -290,7 +362,11 @@ function writeCodexTimeout(sec) {
290
362
  if (t.reason) return t.reason;
291
363
  if (t.timeout !== undefined) return null; // never overwrite the user's value
292
364
  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}`; }
365
+ try {
366
+ fs.writeFileSync(codexConfig, t.lines.join('\n'));
367
+ } catch (err) {
368
+ return `cannot write ${codexConfig}: ${err.message}`;
369
+ }
294
370
  return null;
295
371
  }
296
372
 
@@ -348,7 +424,9 @@ const CLIENTS = [
348
424
  keptNote: () => {
349
425
  const t = codexTable();
350
426
  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})`);
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
+ );
352
430
  }
353
431
  },
354
432
  skillFrom: path.join(PLUGIN, 'skills', 'codex', 'severally'),
@@ -381,10 +459,7 @@ const CLIENTS = [
381
459
  {
382
460
  bin: 'opencode',
383
461
  label: 'OpenCode',
384
- backup: [
385
- path.join(xdgConfig, 'opencode', 'opencode.json'),
386
- path.join(xdgConfig, 'opencode', 'opencode.jsonc'),
387
- ],
462
+ backup: [path.join(xdgConfig, 'opencode', 'opencode.json'), path.join(xdgConfig, 'opencode', 'opencode.jsonc')],
388
463
  // opencode has no `mcp get`; list and look for the exact server name.
389
464
  // `mcp list` prints through the prompt library, but the per-server lines
390
465
  // still reach stdout when stdout is not a TTY ("✓ severally connected").
@@ -405,24 +480,36 @@ const CLIENTS = [
405
480
  remove: () => ({ ok: true, out: 'opencode has no mcp remove; re-adding replaces the entry' }),
406
481
  // OpenCode sets no deadline of its own on a tool call (a 610 s call came
407
482
  // 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]),
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
+ },
409
490
  keptNote: () => {
410
491
  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)');
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)');
413
502
  },
414
503
  // A zero exit from `mcp add` can be a usage dump in disguise (see above),
415
504
  // so this client's registration is verified by listing after adding --
416
505
  // and the recorded command is compared against this install's.
417
506
  verifyAdd: true,
418
- registrationConfig: [
419
- path.join(xdgConfig, 'opencode', 'opencode.json'),
420
- path.join(xdgConfig, 'opencode', 'opencode.jsonc'),
421
- ],
507
+ registrationConfig: [path.join(xdgConfig, 'opencode', 'opencode.json'), path.join(xdgConfig, 'opencode', 'opencode.jsonc')],
422
508
  skillFrom: path.join(PLUGIN, 'skills', 'opencode', 'severally'),
423
509
  skillTo: path.join(xdgConfig, 'opencode', 'skills', 'severally'),
424
510
  },
425
511
  ];
512
+ const codexClient = CLIENTS.find((c) => c.bin === 'codex');
426
513
  const antigravityClient = CLIENTS.find((c) => c.bin === 'agy');
427
514
  const opencodeClient = CLIENTS.find((c) => c.bin === 'opencode');
428
515
 
@@ -435,7 +522,9 @@ function registrationCommandMatches(client, bin) {
435
522
  let cfg;
436
523
  try {
437
524
  cfg = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, ''));
438
- } catch { continue; }
525
+ } catch {
526
+ continue;
527
+ }
439
528
  const entry = cfg.mcp?.servers?.severally ?? cfg.mcp?.severally;
440
529
  const command = entry?.command ?? entry;
441
530
  if (Array.isArray(command)) return JSON.stringify(command) === JSON.stringify(bin);
@@ -456,7 +545,8 @@ function listedAfterAdd(client) {
456
545
  // Registers one client's MCP server directly (through its own `mcp add`) and
457
546
  // copies its skill, backing up everything it touches first. Shared by plugin
458
547
  // mode (Antigravity only) and --manual mode (all three clients).
459
- function installClientDirectly(client, bin) { step(`Registering with ${client.label}`);
548
+ function installClientDirectly(client, bin) {
549
+ step(`Registering with ${client.label}`);
460
550
  if (!which(client.bin)) {
461
551
  log(` ${client.bin} CLI not found on PATH — skipped`);
462
552
  return;
@@ -501,7 +591,10 @@ function installClientDirectly(client, bin) { step(`Registering with ${client.l
501
591
  if (!fs.existsSync(parent) && !dryRun) fs.mkdirSync(parent, { recursive: true });
502
592
  const saved = backupTree(client.skillTo);
503
593
  if (saved) log(` backed up the existing ${client.label} skill to ${saved}`);
504
- 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
+ }
505
598
  fs.rmSync(client.skillTo, { recursive: true, force: true });
506
599
  copyTree(client.skillFrom, client.skillTo);
507
600
  log(` skill: ${client.skillTo}`);
@@ -511,13 +604,17 @@ function installClientDirectly(client, bin) { step(`Registering with ${client.l
511
604
  // severally@severally) loads that copy, and a skills-dir copy or user-scope
512
605
  // registration from this installer would load beside it as a second server
513
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.
514
608
  function claudeHasMarketplacePlugin() {
515
609
  if (!which('claude')) return false;
516
610
  const r = tryRun('claude', ['plugin', 'list', '--json'], { real: true });
517
611
  try {
518
- return r.ok && JSON.parse(r.out).some((p) => /^severally@/.test(p.id) && p.id !== 'severally@skills-dir');
519
- } 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
+ }
520
616
  }
617
+ const claudeMarketplace = claudeHasMarketplacePlugin();
521
618
 
522
619
  function leaveClaudeToMarketplace() {
523
620
  step('Claude Code has severally from the marketplace; leaving Claude Code to it');
@@ -532,9 +629,28 @@ function leaveClaudeToMarketplace() {
532
629
  const r = tryRun('claude', ['mcp', 'remove', '--scope', 'user', 'severally']);
533
630
  log(r.ok ? ' removed the user-scope MCP registration' : ` could not remove the user-scope registration: ${r.out}`);
534
631
  }
535
- 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
+ }
536
653
  }
537
- const claudeMarketplace = claudeHasMarketplacePlugin();
538
654
 
539
655
  if (mode === 'plugin') {
540
656
  // ------------------------------------------------------------- plugin mode
@@ -558,41 +674,54 @@ if (mode === 'plugin') {
558
674
  if (dup.ok) {
559
675
  backup(path.join(home, '.claude.json'));
560
676
  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}`);
677
+ log(
678
+ r.ok
679
+ ? ' removed the duplicate user-scope MCP registration'
680
+ : ` could not remove the duplicate registration: ${r.out}`,
681
+ );
562
682
  }
563
683
  }
564
684
  }
565
685
 
566
- step('Installing the plugin into Codex (managed local marketplace)');
567
- if (!which('codex')) {
568
- log(' codex CLI not found on PATH — skipped');
686
+ if (codexDirect && which('codex')) {
687
+ removeCodexLocalMarketplace();
688
+ installClientDirectly(codexClient, serverCommand);
569
689
  } 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}`);
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
+ }
596
725
  }
597
726
  }
598
727
 
@@ -605,22 +734,62 @@ if (mode === 'plugin') {
605
734
  installClientDirectly(opencodeClient, serverCommand);
606
735
  } else {
607
736
  // ------------------------------------------------------------- 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
- }
737
+ removeCodexLocalMarketplace();
618
738
  for (const client of CLIENTS) {
619
739
  if (client.bin === 'claude' && claudeMarketplace) leaveClaudeToMarketplace();
620
740
  else installClientDirectly(client, serverCommand);
621
741
  }
622
742
  }
623
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
+
624
793
  // ---------------------------------------------------------------- summary
625
794
  step('Done');
626
795
  if (fs.existsSync(backupDir)) log(` backups: ${backupDir}`);
@@ -629,10 +798,12 @@ if (keptRegistrations) {
629
798
  } else if (!dryRun && !process.exitCode) {
630
799
  log(' The registered server and skills no longer depend on the source checkout.');
631
800
  }
632
- log(mode === 'plugin' ? `
801
+ log(
802
+ mode === 'plugin'
803
+ ? `
633
804
  Verify with:
634
805
  claude plugin details severally
635
- codex plugin list
806
+ ${codexDirect ? 'codex mcp get severally' : 'codex plugin list'}
636
807
  agy mcp list
637
808
  opencode mcp list
638
809
 
@@ -640,7 +811,8 @@ Restart any running client session to pick the plugin up. In Claude Code the too
640
811
  mcp__plugin_severally_severally__consult_start / _get / _cancel / _list.
641
812
 
642
813
  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).` : `
814
+ OpenCode are registered directly each run, so a re-run always refreshes them too).`
815
+ : `
644
816
  Verify with:
645
817
  claude mcp get severally
646
818
  codex mcp get severally
@@ -648,4 +820,5 @@ Verify with:
648
820
  opencode mcp list
649
821
 
650
822
  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.`);
823
+ Restart any running client session to pick the server up.`,
824
+ );