s-app-scoder-agent 0.8.21 → 0.9.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.md +31 -5
- package/dist/agent.js +662 -65
- package/dist/agents-guide.js +201 -0
- package/dist/agents.js +76 -3
- package/dist/compaction.js +12 -4
- package/dist/config.js +16 -2
- package/dist/context.js +15 -3
- package/dist/diagnostics.js +188 -0
- package/dist/index.js +277 -6
- package/dist/mcp-import.js +204 -0
- package/dist/mcp.js +553 -54
- package/dist/memory.js +140 -0
- package/dist/permissions.js +18 -3
- package/dist/prompt.js +204 -84
- package/dist/repo-map.js +177 -0
- package/dist/sandbox.js +6 -5
- package/dist/session.js +29 -8
- package/dist/skills.js +211 -62
- package/dist/snapshot.js +5 -0
- package/dist/steering.js +4 -0
- package/dist/tools.js +374 -57
- package/dist/tui/app.js +79 -28
- package/dist/tui/ask-user-modal.js +25 -0
- package/dist/tui/chat-views.js +15 -8
- package/dist/tui/command-registry.js +284 -9
- package/dist/tui/commands.js +224 -37
- package/dist/tui/markdown.js +170 -12
- package/dist/tui/sidebar.js +36 -13
- package/dist/tui/state.js +101 -2
- package/dist/tui/theme.js +4 -0
- package/dist/tui/transcript.js +145 -0
- package/dist/watch.js +8 -0
- package/dist/webfetch.js +5 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { JsonlLogger } from "./logger.js";
|
|
|
9
9
|
import { collectUsage, formatCostReport } from "./cost.js";
|
|
10
10
|
import { getVersion as getPackageVersion } from "./version.js";
|
|
11
11
|
import { dispatchCommand } from "./tui/command-registry.js";
|
|
12
|
+
import { selectableAgents } from "./agents.js";
|
|
12
13
|
import * as ui from "./ui.js";
|
|
13
14
|
// Single source of truth: read version from package.json (see src/version.ts).
|
|
14
15
|
const VERSION = getPackageVersion();
|
|
@@ -48,6 +49,7 @@ OPTIONS
|
|
|
48
49
|
-s, --max-steps <n> Max autonomous loop iterations (default 40)
|
|
49
50
|
-y, --yes Non-interactive mode (auto-approves commands)
|
|
50
51
|
--permission <mode> auto | ask | readonly (env SCODER_PERMISSION, default auto)
|
|
52
|
+
--agent <name> primary agent to run (default build; see /agent): build | plan | review | <custom>
|
|
51
53
|
--no-save Disable session auto-save to .scoder/sessions/
|
|
52
54
|
--log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
|
|
53
55
|
(or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
|
|
@@ -102,6 +104,9 @@ function parseArgs(argv) {
|
|
|
102
104
|
case "--yes":
|
|
103
105
|
args.yes = true;
|
|
104
106
|
break;
|
|
107
|
+
case "--agent":
|
|
108
|
+
args.agent = next();
|
|
109
|
+
break;
|
|
105
110
|
case "--permission": {
|
|
106
111
|
const mode = next();
|
|
107
112
|
if (mode !== "auto" && mode !== "ask" && mode !== "readonly") {
|
|
@@ -113,6 +118,18 @@ function parseArgs(argv) {
|
|
|
113
118
|
case "--no-save":
|
|
114
119
|
args.noSave = true;
|
|
115
120
|
break;
|
|
121
|
+
case "--no-color":
|
|
122
|
+
args.noColor = true;
|
|
123
|
+
break;
|
|
124
|
+
case "--no-motion":
|
|
125
|
+
args.noMotion = true;
|
|
126
|
+
break;
|
|
127
|
+
case "--plain":
|
|
128
|
+
args.plain = true;
|
|
129
|
+
break;
|
|
130
|
+
case "--high-contrast":
|
|
131
|
+
args.highContrast = true;
|
|
132
|
+
break;
|
|
116
133
|
case "--log-file":
|
|
117
134
|
args.logFile = true;
|
|
118
135
|
break;
|
|
@@ -154,11 +171,23 @@ function parseArgs(argv) {
|
|
|
154
171
|
case "--version":
|
|
155
172
|
args.version = true;
|
|
156
173
|
break;
|
|
157
|
-
default:
|
|
174
|
+
default: {
|
|
158
175
|
if (a.startsWith("-")) {
|
|
159
|
-
|
|
176
|
+
// MCP subcommand flags (--command, --url, --trust, --args) are not
|
|
177
|
+
// known globally — pass them through as positional args for runMcpCommand
|
|
178
|
+
// to interpret. Only throw if we are NOT in an mcp subcommand context.
|
|
179
|
+
const isMcpContext = rest[0] === "mcp" || argv.slice(0, i).includes("mcp");
|
|
180
|
+
if (!isMcpContext)
|
|
181
|
+
throw new Error(`Unknown option: ${a} (see --help)`);
|
|
182
|
+
rest.push(a);
|
|
183
|
+
// Consume the next token as this option's value if present.
|
|
184
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
185
|
+
rest.push(argv[++i]);
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
160
188
|
}
|
|
161
189
|
rest.push(a);
|
|
190
|
+
}
|
|
162
191
|
}
|
|
163
192
|
}
|
|
164
193
|
args.task = rest.length > 0 ? rest.join(" ") : null;
|
|
@@ -201,6 +230,23 @@ function parseArgs(argv) {
|
|
|
201
230
|
args.subcommand = "init";
|
|
202
231
|
args.task = null;
|
|
203
232
|
}
|
|
233
|
+
else if (rest[0] === "mcp") {
|
|
234
|
+
// scoder mcp list|add <name>|remove <name>|test <name>
|
|
235
|
+
// e.g. scoder mcp add github --command "npx mcp-github" --url "..." --trust readonly
|
|
236
|
+
args.subcommand = "mcp";
|
|
237
|
+
const m = rest[1];
|
|
238
|
+
if (m === "list" || m === "add" || m === "remove" || m === "test" || m === "import") {
|
|
239
|
+
args.mcpCmd = m;
|
|
240
|
+
}
|
|
241
|
+
else if (m) {
|
|
242
|
+
args.mcpCmd = "list";
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
args.mcpCmd = "list";
|
|
246
|
+
}
|
|
247
|
+
args.mcpArgs = rest.slice(2);
|
|
248
|
+
args.task = null;
|
|
249
|
+
}
|
|
204
250
|
return args;
|
|
205
251
|
}
|
|
206
252
|
async function runOnce(cfg, task) {
|
|
@@ -324,6 +370,9 @@ async function repl(cfg) {
|
|
|
324
370
|
const host = {
|
|
325
371
|
agent: () => agent,
|
|
326
372
|
config: cfg,
|
|
373
|
+
updateConfig: (patch) => {
|
|
374
|
+
Object.assign(cfg, patch); // mutate the shared cfg so new Agents see it
|
|
375
|
+
},
|
|
327
376
|
replaceAgent: (next) => {
|
|
328
377
|
agent.abort();
|
|
329
378
|
agent.dispose();
|
|
@@ -350,6 +399,17 @@ async function repl(cfg) {
|
|
|
350
399
|
break;
|
|
351
400
|
if (outcome?.notice)
|
|
352
401
|
ui.info(outcome.notice);
|
|
402
|
+
if (!outcome?.task)
|
|
403
|
+
continue;
|
|
404
|
+
try {
|
|
405
|
+
const result = first ? await agent.run(outcome.task) : await agent.runTurn(outcome.task);
|
|
406
|
+
first = false;
|
|
407
|
+
if (!result.success)
|
|
408
|
+
ui.warn("task did not complete — try refining it");
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
ui.error(err.message);
|
|
412
|
+
}
|
|
353
413
|
continue;
|
|
354
414
|
}
|
|
355
415
|
try {
|
|
@@ -366,6 +426,191 @@ async function repl(cfg) {
|
|
|
366
426
|
ui.info("bye");
|
|
367
427
|
process.exit(0);
|
|
368
428
|
}
|
|
429
|
+
async function runMcpCommand(cfg, cmd, restArgs) {
|
|
430
|
+
const { McpManager } = await import("./mcp.js");
|
|
431
|
+
const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
|
|
432
|
+
const { join, dirname } = await import("node:path");
|
|
433
|
+
const { mkdirSync } = await import("node:fs");
|
|
434
|
+
const rcPath = join(cfg.workdir, ".scoderrc.json");
|
|
435
|
+
// Load existing mcpServers from workdir rc.
|
|
436
|
+
const existing = {};
|
|
437
|
+
if (existsSync(rcPath)) {
|
|
438
|
+
try {
|
|
439
|
+
Object.assign(existing, JSON.parse(readFileSync(rcPath, "utf8")).mcpServers ?? {});
|
|
440
|
+
}
|
|
441
|
+
catch { /* ignore */ }
|
|
442
|
+
}
|
|
443
|
+
if (cmd === "list") {
|
|
444
|
+
const servers = Object.entries(cfg.mcpServers ?? existing);
|
|
445
|
+
if (servers.length === 0) {
|
|
446
|
+
console.log("no MCP servers configured — add mcpServers to .scoderrc.json");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
for (const [name, cfg_srv] of servers) {
|
|
450
|
+
const trust = cfg_srv.trust ?? "auto";
|
|
451
|
+
const enabled = cfg_srv.enabled === false ? " (disabled)" : "";
|
|
452
|
+
const target = cfg_srv.url ?? cfg_srv.command ?? "?";
|
|
453
|
+
console.log(`${name}: ${target}${trust !== "auto" ? ` [${trust}]` : ""}${enabled}`);
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (cmd === "add") {
|
|
458
|
+
// scoder mcp add <name> --command "..." [--url "..."] [--trust auto|ask|readonly|deny]
|
|
459
|
+
const name = restArgs[0];
|
|
460
|
+
if (!name) {
|
|
461
|
+
console.error("usage: scoder mcp add <name> --command '...' [--url '...'] [--trust readonly]");
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const opts = {};
|
|
465
|
+
for (let i = 1; i < restArgs.length; i++) {
|
|
466
|
+
if (restArgs[i] === "--command" || restArgs[i] === "-c")
|
|
467
|
+
opts.command = restArgs[++i];
|
|
468
|
+
else if (restArgs[i] === "--url" || restArgs[i] === "-u")
|
|
469
|
+
opts.url = restArgs[++i];
|
|
470
|
+
else if (restArgs[i] === "--trust" || restArgs[i] === "-t")
|
|
471
|
+
opts.trust = restArgs[++i];
|
|
472
|
+
else if (restArgs[i] === "--args" || restArgs[i] === "-a")
|
|
473
|
+
opts.args = restArgs[++i];
|
|
474
|
+
}
|
|
475
|
+
if (!opts.command && !opts.url) {
|
|
476
|
+
console.error("error: --command or --url required");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const newEntry = { ...(opts.command ? { command: opts.command } : {}), ...(opts.url ? { url: opts.url } : {}), ...(opts.args ? { args: opts.args.split(" ") } : {}), ...(opts.trust ? { trust: opts.trust } : {}) };
|
|
480
|
+
const allServers = { ...existing, [name]: newEntry };
|
|
481
|
+
const rc = existsSync(rcPath) ? JSON.parse(readFileSync(rcPath, "utf8")) : {};
|
|
482
|
+
mkdirSync(dirname(rcPath) || ".", { recursive: true });
|
|
483
|
+
writeFileSync(rcPath, JSON.stringify({ ...rc, mcpServers: allServers }, null, 2) + "\n", "utf8");
|
|
484
|
+
console.log(`MCP server "${name}" added to ${rcPath}`);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (cmd === "remove") {
|
|
488
|
+
const name = restArgs[0];
|
|
489
|
+
if (!name) {
|
|
490
|
+
console.error("usage: scoder mcp remove <name>");
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (!existing[name] && !(cfg.mcpServers ?? {})[name]) {
|
|
494
|
+
console.error(`MCP server "${name}" not found in config`);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const allServers = { ...existing };
|
|
498
|
+
delete allServers[name];
|
|
499
|
+
const rc = existsSync(rcPath) ? JSON.parse(readFileSync(rcPath, "utf8")) : {};
|
|
500
|
+
mkdirSync(dirname(rcPath) || ".", { recursive: true });
|
|
501
|
+
writeFileSync(rcPath, JSON.stringify({ ...rc, mcpServers: allServers }, null, 2) + "\n", "utf8");
|
|
502
|
+
console.log(`MCP server "${name}" removed from ${rcPath}`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (cmd === "test") {
|
|
506
|
+
const name = restArgs[0];
|
|
507
|
+
if (!name) {
|
|
508
|
+
console.error("usage: scoder mcp test <name>");
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const srvCfg = (cfg.mcpServers ?? {})[name] ?? existing[name];
|
|
512
|
+
if (!srvCfg) {
|
|
513
|
+
console.error(`MCP server "${name}" not found`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (srvCfg.enabled === false) {
|
|
517
|
+
console.log(`✘ "${name}" is disabled (enabled:false)`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (srvCfg.trust === "readonly") {
|
|
521
|
+
console.log(`✘ "${name}" is trust:readonly — not spawning`);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
console.log(`connecting to "${name}"...`);
|
|
525
|
+
const m = new McpManager({ [name]: srvCfg });
|
|
526
|
+
try {
|
|
527
|
+
await m.connectAll();
|
|
528
|
+
const states = m.serverStates();
|
|
529
|
+
const s = states[0];
|
|
530
|
+
if (s?.state === "ready") {
|
|
531
|
+
console.log(`✓ "${name}" connected — ${s.toolCount} tools`);
|
|
532
|
+
console.log(` serverInfo: ${s.serverInfo.name ?? "?"} v${s.serverInfo.version ?? "?"}`);
|
|
533
|
+
console.log(` capabilities: ${Object.keys(s.capabilities).join(", ") || "(none)"}`);
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
console.log(`✘ "${name}" failed to connect (state: ${s?.state})`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
finally {
|
|
540
|
+
m.killAll();
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (cmd === "import") {
|
|
545
|
+
// scoder mcp import [path] [--dry-run] [--force]
|
|
546
|
+
const { loadConfigFile, discoverConfigs, mergeConfigs } = await import("./mcp-import.js");
|
|
547
|
+
const dryRun = restArgs.includes("--dry-run");
|
|
548
|
+
const force = restArgs.includes("--force");
|
|
549
|
+
const paths = restArgs.filter((a) => !a.startsWith("--"));
|
|
550
|
+
let discovered = [];
|
|
551
|
+
try {
|
|
552
|
+
discovered = discoverConfigs();
|
|
553
|
+
}
|
|
554
|
+
catch { /* ignore */ }
|
|
555
|
+
if (paths.length === 0 && discovered.length === 0) {
|
|
556
|
+
console.log("No config files found. Provide a path or ensure one of these exists:");
|
|
557
|
+
console.log(" ~/.claude/config.json — Claude Desktop MCP servers");
|
|
558
|
+
console.log(" opencode.json — OpenCode MCP config");
|
|
559
|
+
console.log(" .mcp.json — standard MCP server list");
|
|
560
|
+
console.log("\nUsage: scoder mcp import [path] [--dry-run] [--force]");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// Auto-discover when no path given
|
|
564
|
+
const toCheck = paths.length > 0 ? paths : discovered.map((d) => d.source);
|
|
565
|
+
if (paths.length === 0 && discovered.length > 0) {
|
|
566
|
+
console.log("Auto-discovered MCP config files:");
|
|
567
|
+
for (const d of discovered) {
|
|
568
|
+
console.log(` ${d.format}: ${d.source} (${d.serverCount} server${d.serverCount === 1 ? "" : "s"})`);
|
|
569
|
+
}
|
|
570
|
+
console.log();
|
|
571
|
+
}
|
|
572
|
+
const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
|
|
573
|
+
const { mkdirSync } = await import("node:fs");
|
|
574
|
+
let totalAdded = 0;
|
|
575
|
+
let totalSkipped = 0;
|
|
576
|
+
for (const srcPath of toCheck) {
|
|
577
|
+
const servers = loadConfigFile(srcPath);
|
|
578
|
+
if (!servers) {
|
|
579
|
+
console.log(` skip: ${srcPath} not found`);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const { added, skipped } = mergeConfigs(existing, servers, { force, dryRun });
|
|
583
|
+
totalAdded += added.length;
|
|
584
|
+
totalSkipped += skipped.length;
|
|
585
|
+
console.log(`${srcPath}:`);
|
|
586
|
+
for (const s of added) {
|
|
587
|
+
const target = s.config.url ?? s.config.command ?? "?";
|
|
588
|
+
console.log(` + ${s.name}: ${target} (from ${s.originalKey})`);
|
|
589
|
+
}
|
|
590
|
+
for (const s of skipped) {
|
|
591
|
+
console.log(` ~ ${s.name}: skipped (already exists; use --force to overwrite)`);
|
|
592
|
+
}
|
|
593
|
+
if (added.length === 0 && skipped.length === 0) {
|
|
594
|
+
console.log(` (no servers found)`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (dryRun) {
|
|
598
|
+
console.log(`\n[DRY RUN] Would import ${totalAdded} server${totalAdded === 1 ? "" : "s"} (${totalSkipped} skipped).`);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (totalAdded > 0) {
|
|
602
|
+
const rc = existsSync(rcPath) ? JSON.parse(readFileSync(rcPath, "utf8")) : {};
|
|
603
|
+
mkdirSync(dirname(rcPath) || ".", { recursive: true });
|
|
604
|
+
writeFileSync(rcPath, JSON.stringify({ ...rc, mcpServers: existing }, null, 2) + "\n", "utf8");
|
|
605
|
+
console.log(`\nImported ${totalAdded} server${totalAdded === 1 ? "" : "s"} into ${rcPath} (${totalSkipped} skipped).`);
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
console.log(`\nNothing to import.`);
|
|
609
|
+
}
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
console.error(`unknown mcp command: ${cmd}`);
|
|
613
|
+
}
|
|
369
614
|
async function main() {
|
|
370
615
|
let args;
|
|
371
616
|
try {
|
|
@@ -383,6 +628,14 @@ async function main() {
|
|
|
383
628
|
console.log(HELP);
|
|
384
629
|
return;
|
|
385
630
|
}
|
|
631
|
+
if (args.noColor)
|
|
632
|
+
process.env.NO_COLOR = "1";
|
|
633
|
+
if (args.noMotion)
|
|
634
|
+
process.env.SCODER_NO_MOTION = "1";
|
|
635
|
+
if (args.plain)
|
|
636
|
+
process.env.SCODER_PLAIN = "1";
|
|
637
|
+
if (args.highContrast)
|
|
638
|
+
process.env.SCODER_THEME = "high-contrast";
|
|
386
639
|
const cfg = loadConfig({
|
|
387
640
|
model: args.model,
|
|
388
641
|
baseUrl: args.baseUrl,
|
|
@@ -395,7 +648,16 @@ async function main() {
|
|
|
395
648
|
provider: args.provider ?? undefined,
|
|
396
649
|
noSave: args.noSave,
|
|
397
650
|
logFile: args.logFile,
|
|
651
|
+
agent: args.agent,
|
|
398
652
|
});
|
|
653
|
+
// v2: --agent must reference a selectable (mode primary|all) agent.
|
|
654
|
+
if (args.agent) {
|
|
655
|
+
const ok = selectableAgents(cfg.workdir).some((a) => a.name === args.agent);
|
|
656
|
+
if (!ok) {
|
|
657
|
+
ui.error(`unknown or non-primary agent "${args.agent}" — selectable: ${selectableAgents(cfg.workdir).map((a) => a.name).join(", ")}`);
|
|
658
|
+
process.exit(2);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
399
661
|
// Subcommands
|
|
400
662
|
if (args.subcommand === "init") {
|
|
401
663
|
await runInit();
|
|
@@ -486,6 +748,10 @@ async function main() {
|
|
|
486
748
|
console.log(` (no models match "${filter}")`);
|
|
487
749
|
return;
|
|
488
750
|
}
|
|
751
|
+
if (args.subcommand === "mcp") {
|
|
752
|
+
await runMcpCommand(cfg, args.mcpCmd ?? "list", args.mcpArgs ?? []);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
489
755
|
if (args.subcommand === "tui") {
|
|
490
756
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
491
757
|
ui.warn("TUI requires an interactive terminal (TTY) — falling back to the classic REPL.");
|
|
@@ -531,7 +797,12 @@ async function main() {
|
|
|
531
797
|
}
|
|
532
798
|
await repl(cfg);
|
|
533
799
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
800
|
+
import { pathToFileURL } from "node:url";
|
|
801
|
+
// Guard: only run when executed as entry point (not imported in tests).
|
|
802
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
803
|
+
main().catch((err) => {
|
|
804
|
+
ui.error(`fatal: ${err.stack ?? err.message}`);
|
|
805
|
+
process.exit(2);
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
export { parseArgs };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 5 — MCP config import: parse server configs from third-party formats
|
|
3
|
+
* (Claude Desktop, OpenCode, .mcp.json) and merge them into our format.
|
|
4
|
+
*
|
|
5
|
+
* Supports:
|
|
6
|
+
* - Claude Desktop: `~/.claude/config.json` → `mcpServers` (stdio + env)
|
|
7
|
+
* - OpenCode: `opencode.json` → `mcp` (stdio or http)
|
|
8
|
+
* - Standard: `.mcp.json` → array of `{name, command, url, …}`
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
/** Normalise a Claude Desktop mcpServers entry to our McpServerConfig. */
|
|
14
|
+
function fromClaudeDesktop(raw, _name) {
|
|
15
|
+
const cfg = {};
|
|
16
|
+
if (typeof raw.command === "string") {
|
|
17
|
+
cfg.command = raw.command;
|
|
18
|
+
if (Array.isArray(raw.args))
|
|
19
|
+
cfg.args = raw.args;
|
|
20
|
+
}
|
|
21
|
+
else if (typeof raw.url === "string") {
|
|
22
|
+
cfg.url = raw.url;
|
|
23
|
+
if (typeof raw.headers === "object" && raw.headers !== null) {
|
|
24
|
+
cfg.headers = raw.headers;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (typeof raw.env === "object" && raw.env !== null) {
|
|
28
|
+
cfg.env = raw.env;
|
|
29
|
+
}
|
|
30
|
+
if (typeof raw.autoApprove === "boolean") {
|
|
31
|
+
// Claude Desktop autoApprove maps to our trust levels:
|
|
32
|
+
// autoApprove=true → "auto" (trust the server to run without prompt)
|
|
33
|
+
// autoApprove=false → "ask" (default, prompt before each tool call)
|
|
34
|
+
// We deliberately do NOT set trust here — let the global mode apply.
|
|
35
|
+
}
|
|
36
|
+
return cfg;
|
|
37
|
+
}
|
|
38
|
+
/** Normalise an OpenCode mcp config entry to our McpServerConfig. */
|
|
39
|
+
function fromOpenCode(raw, _name) {
|
|
40
|
+
const cfg = {};
|
|
41
|
+
if (typeof raw.command === "string") {
|
|
42
|
+
cfg.command = raw.command;
|
|
43
|
+
if (Array.isArray(raw.args))
|
|
44
|
+
cfg.args = raw.args;
|
|
45
|
+
}
|
|
46
|
+
else if (typeof raw.url === "string") {
|
|
47
|
+
cfg.url = raw.url;
|
|
48
|
+
if (typeof raw.headers === "object" && raw.headers !== null) {
|
|
49
|
+
cfg.headers = raw.headers;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (typeof raw.env === "object" && raw.env !== null) {
|
|
53
|
+
cfg.env = raw.env;
|
|
54
|
+
}
|
|
55
|
+
if (raw.disabled === true)
|
|
56
|
+
cfg.enabled = false;
|
|
57
|
+
return cfg;
|
|
58
|
+
}
|
|
59
|
+
/** Parse Claude Desktop `mcpServers` config from a JSON string. */
|
|
60
|
+
function parseClaude(raw, sourcePath) {
|
|
61
|
+
const parsed = JSON.parse(raw);
|
|
62
|
+
const servers = [];
|
|
63
|
+
const entries = parsed?.mcpServers ?? parsed?.mcp?.servers ?? {};
|
|
64
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
65
|
+
if (!value || typeof value !== "object")
|
|
66
|
+
continue;
|
|
67
|
+
const rec = value;
|
|
68
|
+
if (rec.enabled === false)
|
|
69
|
+
continue;
|
|
70
|
+
const config = fromClaudeDesktop(rec, key);
|
|
71
|
+
if (!config.command && !config.url)
|
|
72
|
+
continue;
|
|
73
|
+
servers.push({ source: sourcePath, name: key, originalKey: key, config });
|
|
74
|
+
}
|
|
75
|
+
return servers;
|
|
76
|
+
}
|
|
77
|
+
/** Parse OpenCode `mcp` config from a JSON string. */
|
|
78
|
+
function parseOpenCode(raw, sourcePath) {
|
|
79
|
+
const parsed = JSON.parse(raw);
|
|
80
|
+
const servers = [];
|
|
81
|
+
const entries = parsed?.mcp ?? parsed?.servers ?? {};
|
|
82
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
83
|
+
if (!value || typeof value !== "object")
|
|
84
|
+
continue;
|
|
85
|
+
const rec = value;
|
|
86
|
+
// OpenCode: "disabled" flag; our config: "enabled: false". Both mean skip.
|
|
87
|
+
if (rec.disabled === true || rec.enabled === false)
|
|
88
|
+
continue;
|
|
89
|
+
const config = fromOpenCode(rec, key);
|
|
90
|
+
if (!config.command && !config.url)
|
|
91
|
+
continue;
|
|
92
|
+
servers.push({ source: sourcePath, name: key, originalKey: key, config });
|
|
93
|
+
}
|
|
94
|
+
return servers;
|
|
95
|
+
}
|
|
96
|
+
/** Parse standard `.mcp.json` array of server configs. */
|
|
97
|
+
function parseMcpJson(raw, sourcePath) {
|
|
98
|
+
const parsed = JSON.parse(raw);
|
|
99
|
+
const servers = [];
|
|
100
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (!entry || typeof entry !== "object")
|
|
103
|
+
continue;
|
|
104
|
+
const rec = entry;
|
|
105
|
+
const name = typeof rec.name === "string" ? rec.name : "unknown";
|
|
106
|
+
if (rec.enabled === false)
|
|
107
|
+
continue;
|
|
108
|
+
const cfg = {};
|
|
109
|
+
if (typeof rec.command === "string") {
|
|
110
|
+
cfg.command = rec.command;
|
|
111
|
+
if (Array.isArray(rec.args))
|
|
112
|
+
cfg.args = rec.args;
|
|
113
|
+
}
|
|
114
|
+
else if (typeof rec.url === "string") {
|
|
115
|
+
cfg.url = rec.url;
|
|
116
|
+
if (typeof rec.headers === "object" && rec.headers !== null) {
|
|
117
|
+
cfg.headers = rec.headers;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (typeof rec.timeoutMs === "number")
|
|
121
|
+
cfg.timeoutMs = rec.timeoutMs;
|
|
122
|
+
if (typeof rec.env === "object" && rec.env !== null) {
|
|
123
|
+
cfg.env = rec.env;
|
|
124
|
+
}
|
|
125
|
+
if (typeof rec.trust === "string")
|
|
126
|
+
cfg.trust = rec.trust;
|
|
127
|
+
if (typeof rec.enabled === "boolean")
|
|
128
|
+
cfg.enabled = rec.enabled;
|
|
129
|
+
if (!cfg.command && !cfg.url)
|
|
130
|
+
continue;
|
|
131
|
+
servers.push({ source: sourcePath, name, originalKey: name, config: cfg });
|
|
132
|
+
}
|
|
133
|
+
return servers;
|
|
134
|
+
}
|
|
135
|
+
/** Detect format and parse a source file; throws on unreadable/invalid JSON. */
|
|
136
|
+
export function parseConfigFile(raw, sourcePath) {
|
|
137
|
+
// Heuristic: if it has `mcpServers` top-level → Claude Desktop.
|
|
138
|
+
// If it has `mcp` top-level (object or array) → OpenCode or standard MCP.
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(raw);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new Error(`Invalid JSON in ${sourcePath}`);
|
|
145
|
+
}
|
|
146
|
+
if ("mcpServers" in parsed)
|
|
147
|
+
return parseClaude(raw, sourcePath);
|
|
148
|
+
if ("mcp" in parsed)
|
|
149
|
+
return parseOpenCode(raw, sourcePath);
|
|
150
|
+
// Fallback: treat as standard MCP JSON array.
|
|
151
|
+
return parseMcpJson(raw, sourcePath);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Load + parse one config file by path.
|
|
155
|
+
* Returns `null` when the file does not exist; throws on unreadable/invalid.
|
|
156
|
+
*/
|
|
157
|
+
export function loadConfigFile(path) {
|
|
158
|
+
let raw;
|
|
159
|
+
try {
|
|
160
|
+
raw = readFileSync(path, "utf8");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return parseConfigFile(raw, path);
|
|
166
|
+
}
|
|
167
|
+
export function discoverConfigs() {
|
|
168
|
+
const found = [];
|
|
169
|
+
const checks = [
|
|
170
|
+
[join(homedir(), ".claude", "config.json"), "claude"],
|
|
171
|
+
[join(homedir(), ".config", "claude", "config.json"), "claude"],
|
|
172
|
+
[join(process.cwd(), "opencode.json"), "opencode"],
|
|
173
|
+
[join(process.cwd(), ".mcp.json"), "mcp-json"],
|
|
174
|
+
];
|
|
175
|
+
for (const [path, format] of checks) {
|
|
176
|
+
try {
|
|
177
|
+
const raw = readFileSync(path, "utf8");
|
|
178
|
+
const servers = parseConfigFile(raw, path);
|
|
179
|
+
if (servers.length > 0)
|
|
180
|
+
found.push({ source: path, format, serverCount: servers.length });
|
|
181
|
+
}
|
|
182
|
+
catch { /* not present */ }
|
|
183
|
+
}
|
|
184
|
+
return found;
|
|
185
|
+
}
|
|
186
|
+
/** Merge imported servers into an existing config map. */
|
|
187
|
+
export function mergeConfigs(existing, imported, opts = {}) {
|
|
188
|
+
const added = [];
|
|
189
|
+
const skipped = [];
|
|
190
|
+
const updated = [];
|
|
191
|
+
for (const srv of imported) {
|
|
192
|
+
const existingCfg = existing[srv.name];
|
|
193
|
+
if (existingCfg && !opts.force) {
|
|
194
|
+
skipped.push(srv);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (existingCfg && opts.force)
|
|
198
|
+
updated.push(srv.name);
|
|
199
|
+
if (!opts.dryRun)
|
|
200
|
+
existing[srv.name] = srv.config;
|
|
201
|
+
added.push(srv);
|
|
202
|
+
}
|
|
203
|
+
return { added, skipped, updated };
|
|
204
|
+
}
|