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.
- package/CODE_OF_CONDUCT.md +83 -0
- package/CONFIG.md +10 -6
- package/README.ja.md +146 -69
- package/README.md +150 -73
- package/bin/severally-mcp.mjs +17 -2
- package/config.example.json +26 -0
- package/docs/how-it-works.ja.md +4 -2
- package/docs/how-it-works.md +5 -3
- package/docs/install.ja.md +59 -24
- package/docs/install.md +60 -24
- package/package.json +16 -4
- 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 +29220 -27764
- package/plugins/severally/skills/_template/SKILL.md.tmpl +16 -22
- package/plugins/severally/skills/_template/reference.md.tmpl +11 -5
- package/plugins/severally/skills/antigravity/severally/SKILL.md +16 -22
- package/plugins/severally/skills/antigravity/severally/reference.md +11 -5
- package/plugins/severally/skills/claude/severally/SKILL.md +16 -22
- package/plugins/severally/skills/claude/severally/reference.md +11 -5
- package/plugins/severally/skills/codex/severally/SKILL.md +16 -22
- package/plugins/severally/skills/codex/severally/reference.md +11 -5
- package/plugins/severally/skills/opencode/severally/SKILL.md +16 -22
- package/plugins/severally/skills/opencode/severally/reference.md +11 -5
- package/scripts/build.mjs +31 -26
- package/scripts/doctor.mjs +52 -17
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +342 -84
- package/scripts/live-check.mjs +52 -19
- package/scripts/live-mcp-check.mjs +42 -12
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +39 -23
- package/src/adapters/antigravity.mjs +34 -16
- package/src/adapters/claude-code.mjs +73 -21
- package/src/adapters/codex.mjs +69 -27
- package/src/adapters/opencode-sandbox.mjs +40 -21
- package/src/adapters/opencode.mjs +31 -13
- package/src/brief.mjs +65 -41
- package/src/consultants.mjs +99 -30
- package/src/export.mjs +49 -12
- package/src/expose-paths.mjs +162 -44
- package/src/failures.mjs +23 -13
- package/src/jobs.mjs +420 -140
- package/src/parse-result.mjs +85 -30
- package/src/platform.mjs +5 -7
- package/src/policy.mjs +139 -32
- package/src/redact.mjs +38 -14
- package/src/run.mjs +104 -22
- package/src/schema.mjs +76 -22
- package/src/server.mjs +176 -166
- package/src/status.mjs +132 -0
- package/src/store.mjs +164 -19
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');
|
|
@@ -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) {
|
|
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) {
|
|
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] ?? '')
|
|
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(
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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(
|
|
192
|
-
|
|
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(
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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(
|
|
225
|
-
path.join(
|
|
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
|
|
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: () =>
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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) {
|
|
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) {
|
|
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).
|
|
434
|
-
} catch {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
686
|
+
if (codexDirect && which('codex')) {
|
|
687
|
+
removeCodexLocalMarketplace();
|
|
688
|
+
installClientDirectly(codexClient, serverCommand);
|
|
484
689
|
} else {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
);
|