s-app-scoder-agent 0.8.20 → 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 +50 -6
- package/dist/agent.js +754 -79
- 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 +37 -11
- 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 +297 -9
- package/dist/tui/commands.js +224 -36
- 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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 (3.5): Diagnostics — turn raw build/test/lint logs into structured
|
|
3
|
+
* {file, line, severity, code, message} records, plus an error classifier
|
|
4
|
+
* that maps a failure to a recovery strategy for the agent.
|
|
5
|
+
*
|
|
6
|
+
* Supported: tsc (both formats), eslint stylish/compact, pytest (tracebacks +
|
|
7
|
+
* FAILED lines), go test, cargo, make. Unknown formats fall back to a generic
|
|
8
|
+
* "error-ish line with a file(:line)? prefix" pattern. Output is deduplicated
|
|
9
|
+
* and capped (30 diagnostics) so the agent gets a fixable list, not a log.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
const MAX_DIAGNOSTICS = 30;
|
|
14
|
+
function add(out, seen, d) {
|
|
15
|
+
const key = `${d.file}:${d.line}:${d.code}:${d.message.slice(0, 80)}`;
|
|
16
|
+
if (seen.has(key))
|
|
17
|
+
return;
|
|
18
|
+
seen.add(key);
|
|
19
|
+
if (out.length < MAX_DIAGNOSTICS)
|
|
20
|
+
out.push(d);
|
|
21
|
+
}
|
|
22
|
+
/** Parse a command output into structured diagnostics (best-effort). */
|
|
23
|
+
export function parseDiagnostics(output) {
|
|
24
|
+
const out = [];
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
let currentFile = null;
|
|
27
|
+
let pendingCargo = null;
|
|
28
|
+
for (const rawLine of output.split("\n")) {
|
|
29
|
+
const line = rawLine.trimEnd();
|
|
30
|
+
let m;
|
|
31
|
+
// ── cargo (real output): error line, then " --> file:line" on the NEXT line.
|
|
32
|
+
if (pendingCargo) {
|
|
33
|
+
m = /^\s*-->\s+([^\s:]+):(\d+)(?::\d+)?$/.exec(line);
|
|
34
|
+
if (m) {
|
|
35
|
+
add(out, seen, { file: m[1], line: Number(m[2]), severity: "error", code: pendingCargo.code, message: pendingCargo.message, source: "cargo" });
|
|
36
|
+
pendingCargo = null;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
pendingCargo = null; // the error had no location line — drop it
|
|
40
|
+
}
|
|
41
|
+
// ── tsc (modern): src/foo.ts(12,5): error TS2322: message
|
|
42
|
+
m = /^([^\s()][^()]*)\((\d+),(\d+)\):\s*(error|warning)\s+(TS\d+):\s*(.+)$/.exec(line);
|
|
43
|
+
if (m) {
|
|
44
|
+
add(out, seen, { file: m[1], line: Number(m[2]), severity: m[4] === "warning" ? "warning" : "error", code: m[5], message: m[6].trim(), source: "tsc" });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// ── tsc (new style): src/foo.ts:12:5 - error TS2322: message
|
|
48
|
+
m = /^([^\s:][^:]*):(\d+):(\d+)\s+-\s+(error|warning)\s+(TS\d+):\s*(.+)$/.exec(line);
|
|
49
|
+
if (m) {
|
|
50
|
+
add(out, seen, { file: m[1], line: Number(m[2]), severity: m[4] === "warning" ? "warning" : "error", code: m[5], message: m[6].trim(), source: "tsc" });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// ── cargo: error[E0308]: msg --> src/main.rs:10:5
|
|
54
|
+
m = /^error(?:\[(E\d+)\])?:\s*(.+)\s+-->\s+([^\s:]+):(\d+)(?::\d+)?$/.exec(line);
|
|
55
|
+
if (m) {
|
|
56
|
+
add(out, seen, { file: m[3], line: Number(m[4]), severity: "error", code: m[1] ?? null, message: m[2].trim(), source: "cargo" });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// ── cargo (2-line): "error[E0308]: msg" standing alone → expect --> next line.
|
|
60
|
+
m = /^error(?:\[(E\d+)\])?:\s*(.+)$/.exec(line);
|
|
61
|
+
if (m) {
|
|
62
|
+
pendingCargo = { code: m[1] ?? null, message: m[2].trim() };
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// ── go vet/test: file.go:123: message (also covers "# package" headers)
|
|
66
|
+
m = /^([^\s:][^:()]*(?:\.go|\.py|\.rs|\.ts|\.tsx|\.js|\.mjs)):(\d+):\s+(.+)$/.exec(line);
|
|
67
|
+
if (m && /error|fail|cannot|undefined|invalid|unexpected|panic/i.test(m[3])) {
|
|
68
|
+
add(out, seen, { file: m[1], line: Number(m[2]), severity: "error", code: null, message: m[3].trim(), source: "generic" });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// ── eslint stylish: "✖ N problems" ignored; file header then indented rows
|
|
72
|
+
m = /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}([\w-]+)$/.exec(line);
|
|
73
|
+
if (m && currentFile) {
|
|
74
|
+
add(out, seen, { file: currentFile, line: Number(m[1]), severity: m[3], code: m[5], message: m[4].trim(), source: "eslint" });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// eslint file header: /abs/path/file.ts (a bare path on its own line)
|
|
78
|
+
if (/^([\w./-]+\.(?:ts|tsx|js|jsx|mjs|cjs|vue|svelte))\s*$/.test(line.trim())) {
|
|
79
|
+
currentFile = line.trim();
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// ── pytest: FAILED test_x.py::test_name - assert ...
|
|
83
|
+
m = /^FAILED\s+([^\s:]+)(?:::[^\s]+)?(?:\s+-\s+(.+))?$/.exec(line);
|
|
84
|
+
if (m) {
|
|
85
|
+
add(out, seen, { file: m[1], line: null, severity: "error", code: "FAILED", message: (m[2] ?? "test failed").trim(), source: "pytest" });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// pytest traceback frame: E AssertionError: ...
|
|
89
|
+
m = /^E\s+(.+)$/.exec(line);
|
|
90
|
+
if (m) {
|
|
91
|
+
add(out, seen, { file: "(pytest)", line: null, severity: "error", code: null, message: m[1].trim().slice(0, 200), source: "pytest" });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// ── go test: --- FAIL: TestName
|
|
95
|
+
m = /^--- FAIL:\s+([\w/]+)/.exec(line);
|
|
96
|
+
if (m) {
|
|
97
|
+
add(out, seen, { file: "(go test)", line: null, severity: "error", code: "FAIL", message: `test failed: ${m[1]}`, source: "go" });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// ── make: make: *** [target] Error 2
|
|
101
|
+
m = /^make(?:\[\d+\])?: \*\*\* \[([^\]]+)\] Error (\d+)/.exec(line);
|
|
102
|
+
if (m) {
|
|
103
|
+
add(out, seen, { file: "Makefile", line: null, severity: "error", code: `make:${m[2]}`, message: `target failed: ${m[1]}`, source: "make" });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/** Compact one-line-per-diagnostic rendering (prompt + UI friendly). */
|
|
110
|
+
export function formatDiagnostics(diags) {
|
|
111
|
+
if (diags.length === 0)
|
|
112
|
+
return "";
|
|
113
|
+
const lines = diags.map((d) => `- ${d.file}${d.line != null ? `:${d.line}` : ""} [${d.severity}${d.code ? ` ${d.code}` : ""}] ${d.message}`);
|
|
114
|
+
return `Diagnostics (${diags.length}):\n${lines.join("\n")}`;
|
|
115
|
+
}
|
|
116
|
+
/** Classify an error into a recovery strategy for the agent. */
|
|
117
|
+
export function classifyError(text) {
|
|
118
|
+
const t = text.toLowerCase();
|
|
119
|
+
const has = (...pats) => pats.some((p) => t.includes(p));
|
|
120
|
+
if (has("permission denied", "eacces", "denied by policy", "access is denied", "unauthorized", "403")) {
|
|
121
|
+
return { kind: "permission", strategy: "do NOT retry the same command — use a safer alternative or report the limitation to the user." };
|
|
122
|
+
}
|
|
123
|
+
if (has("enotfound", "econnrefused", "etimedout", "network", "fetch failed", "eai_again", "econnreset", "401", "rate limit", "429")) {
|
|
124
|
+
return { kind: "network", strategy: "check connectivity and credentials first (web_fetch/env), wait or pick another source; do not loop blindly." };
|
|
125
|
+
}
|
|
126
|
+
if (has("syntaxerror", "unexpected token", "unexpected end of", "parse error", "unterminated")) {
|
|
127
|
+
return { kind: "syntax", strategy: "read the file around the indicated line and fix the syntax error directly." };
|
|
128
|
+
}
|
|
129
|
+
if (/ts\d{3,5}|type .{0,40}is not assignable|type mismatch|expected type|no compatible/.test(t)) {
|
|
130
|
+
return { kind: "type", strategy: "read the failing type declaration and the usage site — fix the type, don't cast blindly." };
|
|
131
|
+
}
|
|
132
|
+
if (has("assertionerror", "assert failed", "expected", "to equal", "failed assert", "--- fail")) {
|
|
133
|
+
return { kind: "test", strategy: "read the failing test, understand what it expects, then fix the implementation (or the test only if it is provably wrong)." };
|
|
134
|
+
}
|
|
135
|
+
if (has("typeerror", "referenceerror", "rangeerror", "panic", "nil pointer", "null pointer", "segmentation", "enoent")) {
|
|
136
|
+
return { kind: "runtime", strategy: "read the stack trace, find the failing frame, and fix the root cause." };
|
|
137
|
+
}
|
|
138
|
+
return { kind: "unknown", strategy: "read the full error carefully; try a different approach or ask the user." };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Detect verify commands for the workdir from manifests. rc override via
|
|
142
|
+
* `verifyCmds` wins (empty string disables one).
|
|
143
|
+
*/
|
|
144
|
+
export function detectVerifyCmds(workdir) {
|
|
145
|
+
const out = {};
|
|
146
|
+
try {
|
|
147
|
+
const pkgPath = join(workdir, "package.json");
|
|
148
|
+
if (existsSync(pkgPath)) {
|
|
149
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
150
|
+
const s = pkg.scripts ?? {};
|
|
151
|
+
if (s.build)
|
|
152
|
+
out.build = "npm run build";
|
|
153
|
+
if (s.test)
|
|
154
|
+
out.test = "npm test";
|
|
155
|
+
if (s.lint)
|
|
156
|
+
out.lint = "npm run lint";
|
|
157
|
+
if (s.typecheck)
|
|
158
|
+
out.typecheck = "npm run typecheck";
|
|
159
|
+
else if (s["type-check"])
|
|
160
|
+
out.typecheck = "npm run type-check";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch { /* malformed package.json — fall through */ }
|
|
164
|
+
try {
|
|
165
|
+
if (existsSync(join(workdir, "pyproject.toml")))
|
|
166
|
+
out.test = out.test ?? "pytest";
|
|
167
|
+
if (existsSync(join(workdir, "Cargo.toml"))) {
|
|
168
|
+
out.build = out.build ?? "cargo build";
|
|
169
|
+
out.test = out.test ?? "cargo test";
|
|
170
|
+
}
|
|
171
|
+
if (existsSync(join(workdir, "go.mod"))) {
|
|
172
|
+
out.build = out.build ?? "go build ./...";
|
|
173
|
+
out.test = out.test ?? "go test ./...";
|
|
174
|
+
}
|
|
175
|
+
const makefile = join(workdir, "Makefile");
|
|
176
|
+
if (existsSync(makefile)) {
|
|
177
|
+
const mk = readFileSync(makefile, "utf8");
|
|
178
|
+
if (/^build:/m.test(mk))
|
|
179
|
+
out.build = out.build ?? "make";
|
|
180
|
+
if (/^test:/m.test(mk))
|
|
181
|
+
out.test = out.test ?? "make test";
|
|
182
|
+
if (/^lint:/m.test(mk))
|
|
183
|
+
out.lint = out.lint ?? "make lint";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch { /* best-effort */ }
|
|
187
|
+
return out;
|
|
188
|
+
}
|
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 };
|