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.
- package/README.ja.md +52 -17
- package/README.md +52 -16
- package/bin/severally-mcp.mjs +14 -2
- package/docs/install.ja.md +48 -20
- package/docs/install.md +48 -21
- package/package.json +15 -3
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +76 -68
- package/scripts/doctor.mjs +66 -20
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +327 -90
- package/scripts/live-check.mjs +62 -23
- package/scripts/live-mcp-check.mjs +52 -16
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +62 -28
- package/src/adapters/antigravity.mjs +111 -30
- package/src/adapters/claude-code.mjs +121 -28
- package/src/adapters/codex.mjs +125 -33
- package/src/adapters/opencode-sandbox.mjs +59 -22
- package/src/adapters/opencode.mjs +144 -30
- package/src/brief.mjs +148 -47
- package/src/consultants.mjs +255 -48
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +99 -18
- package/src/expose-paths.mjs +310 -50
- package/src/failures.mjs +31 -14
- package/src/jobs.mjs +863 -249
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +172 -40
- package/src/platform.mjs +56 -8
- package/src/policy.mjs +245 -52
- package/src/redact.mjs +59 -16
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +225 -30
- package/src/schema.mjs +132 -27
- package/src/server.mjs +178 -81
- package/src/status.mjs +94 -13
- package/src/store.mjs +418 -35
package/scripts/install.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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(
|
|
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 (
|
|
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) {
|
|
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) {
|
|
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] ?? '')
|
|
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(
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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(
|
|
192
|
-
|
|
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(
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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(
|
|
225
|
-
path.join(
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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) => {
|
|
412
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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 {
|
|
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
|
-
|
|
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) {
|
|
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
|
|
519
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
747
|
+
if (codexDirect && which('codex')) {
|
|
748
|
+
removeCodexLocalMarketplace();
|
|
749
|
+
installClientDirectly(codexClient, serverCommand);
|
|
569
750
|
} else {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
);
|