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/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
- throw new Error(`Unknown option: ${a} (see --help)`);
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
- main().catch((err) => {
535
- ui.error(`fatal: ${err.stack ?? err.message}`);
536
- process.exit(2);
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
+ }