s-app-scoder-agent 0.9.3 → 0.9.7

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.
Files changed (112) hide show
  1. package/README.md +615 -638
  2. package/dist/{guardrails.js → agent/guardrails.js} +1 -1
  3. package/dist/agent/interaction.js +70 -0
  4. package/dist/agent/mcp-bridge.js +136 -0
  5. package/dist/agent/prompt-context.js +83 -0
  6. package/dist/agent/run-loop.js +318 -0
  7. package/dist/agent/session-persistence.js +61 -0
  8. package/dist/agent/tool-executor.js +158 -0
  9. package/dist/agent/types.js +88 -0
  10. package/dist/agent/verification.js +99 -0
  11. package/dist/agent.js +239 -1002
  12. package/dist/cli/args.js +230 -0
  13. package/dist/cli/init.js +79 -0
  14. package/dist/cli/main.js +211 -0
  15. package/dist/cli/mcp-command.js +185 -0
  16. package/dist/cli/repl.js +77 -0
  17. package/dist/cli/run-once.js +26 -0
  18. package/dist/{tui/commands.js → commands/definitions.js} +6 -16
  19. package/dist/commands/registry.js +260 -0
  20. package/dist/{config.js → config/config.js} +1 -1
  21. package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
  22. package/dist/{prompt.js → context/prompt.js} +111 -9
  23. package/dist/{skills.js → context/skills.js} +41 -2
  24. package/dist/index.js +5 -798
  25. package/dist/io/console-reporter.js +24 -0
  26. package/dist/io/reporter.js +1 -0
  27. package/dist/mcp/connection.js +227 -0
  28. package/dist/mcp/env.js +8 -0
  29. package/dist/{mcp.js → mcp/manager.js} +3 -515
  30. package/dist/mcp/sse.js +28 -0
  31. package/dist/mcp/transport-http.js +140 -0
  32. package/dist/mcp/transport-stdio.js +98 -0
  33. package/dist/mcp/types.js +11 -0
  34. package/dist/media/image.js +149 -0
  35. package/dist/{server.js → server/server.js} +1 -1
  36. package/dist/{cost.js → session/cost.js} +1 -1
  37. package/dist/{session.js → session/session.js} +1 -1
  38. package/dist/{version.js → shared/version.js} +1 -1
  39. package/dist/tools/apply-patch.js +62 -0
  40. package/dist/tools/edit-file.js +29 -0
  41. package/dist/tools/file-edit-common.js +124 -0
  42. package/dist/tools/list-files.js +64 -0
  43. package/dist/tools/path-safety.js +75 -0
  44. package/dist/tools/read-file.js +64 -0
  45. package/dist/tools/registry.js +297 -0
  46. package/dist/tools/run-command.js +192 -0
  47. package/dist/tools/schemas.js +8 -0
  48. package/dist/tools/search.js +237 -0
  49. package/dist/tools/task-tool.js +48 -0
  50. package/dist/tools/types.js +1 -0
  51. package/dist/tools/write-file.js +34 -0
  52. package/dist/tui/app.js +59 -532
  53. package/dist/tui/ask-user-modal.js +2 -1
  54. package/dist/tui/at-complete.js +2 -93
  55. package/dist/tui/charset.js +41 -0
  56. package/dist/tui/chat-views.js +72 -16
  57. package/dist/tui/chrome.js +11 -5
  58. package/dist/tui/hooks/use-command-host.js +26 -0
  59. package/dist/tui/hooks/use-event-queue.js +69 -0
  60. package/dist/tui/hooks/use-keybinds.js +87 -0
  61. package/dist/tui/hooks/use-scroll.js +38 -0
  62. package/dist/tui/hooks/use-session-resume.js +47 -0
  63. package/dist/tui/hooks/use-submit.js +106 -0
  64. package/dist/tui/image.js +2 -149
  65. package/dist/tui/input.js +1 -1
  66. package/dist/tui/markdown/ast.js +5 -0
  67. package/dist/tui/markdown/diagram.js +239 -0
  68. package/dist/tui/markdown/inline-render.js +24 -0
  69. package/dist/tui/markdown/inline.js +93 -0
  70. package/dist/tui/markdown/parse.js +139 -0
  71. package/dist/tui/markdown/render.js +146 -0
  72. package/dist/tui/markdown/table.js +106 -0
  73. package/dist/tui/markdown.js +32 -379
  74. package/dist/tui/permission-modal.js +2 -1
  75. package/dist/tui/selectors.js +157 -0
  76. package/dist/tui/session-picker.js +4 -3
  77. package/dist/tui/sidebar.js +130 -15
  78. package/dist/tui/state.js +47 -118
  79. package/dist/tui/text-width.js +139 -0
  80. package/dist/tui/theme.js +7 -2
  81. package/dist/{build.js → workspace/build.js} +1 -1
  82. package/dist/workspace/file-index.js +93 -0
  83. package/package.json +5 -1
  84. package/dist/tools.js +0 -1223
  85. package/dist/tui/command-registry.js +0 -477
  86. package/dist/tui/review.js +0 -64
  87. package/dist/tui/transcript.js +0 -145
  88. /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
  89. /package/dist/{agents.js → context/agents.js} +0 -0
  90. /package/dist/{compaction.js → context/compaction.js} +0 -0
  91. /package/dist/{context.js → context/context.js} +0 -0
  92. /package/dist/{memory.js → context/memory.js} +0 -0
  93. /package/dist/{repo-map.js → context/repo-map.js} +0 -0
  94. /package/dist/{ui.js → io/terminal.js} +0 -0
  95. /package/dist/{llm.js → llm/llm.js} +0 -0
  96. /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
  97. /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
  98. /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
  99. /package/dist/{retry.js → llm/retry.js} +0 -0
  100. /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
  101. /package/dist/{permissions.js → security/permissions.js} +0 -0
  102. /package/dist/{sandbox.js → security/sandbox.js} +0 -0
  103. /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
  104. /package/dist/{logger.js → session/logger.js} +0 -0
  105. /package/dist/{steering.js → session/steering.js} +0 -0
  106. /package/dist/{constants.js → shared/constants.js} +0 -0
  107. /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
  108. /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
  109. /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
  110. /package/dist/{diff.js → workspace/diff.js} +0 -0
  111. /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
  112. /package/dist/{watch.js → workspace/watch.js} +0 -0
package/dist/index.js CHANGED
@@ -1,803 +1,11 @@
1
1
  #!/usr/bin/env node
2
+ /** LEGACY PUBLIC PATH — CLI implementation lives in ./cli/main.js. */
2
3
  import process from "node:process";
3
- import { createInterface } from "node:readline";
4
- import { resolve } from "node:path";
5
- import { loadConfig } from "./config.js";
6
- import { Agent } from "./agent.js";
7
- import { listSessions, resolveSessionId, pruneSessions } from "./session.js";
8
- import { JsonlLogger } from "./logger.js";
9
- import { collectUsage, formatCostReport } from "./cost.js";
10
- import { getVersion as getPackageVersion } from "./version.js";
11
- import { dispatchCommand } from "./tui/command-registry.js";
12
- import { selectableAgents } from "./agents.js";
13
- import * as ui from "./ui.js";
14
- // Single source of truth: read version from package.json (see src/version.ts).
15
- const VERSION = getPackageVersion();
16
- // Safety net: a stray promise rejection (e.g. a provider socket aborting after
17
- // its consumer already moved on) must log — never hard-crash the agent.
18
- process.on("unhandledRejection", (reason) => {
19
- const msg = reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason);
20
- ui.warn(`background error (ignored): ${msg.slice(0, 200)}`);
21
- });
22
- const HELP = `scoder — autonomous coding agent CLI
23
-
24
- USAGE
25
- scoder [options] "<task>" Run a task autonomously until the build passes
26
- scoder Start interactive session (chat + tools)
27
- scoder tui [task] Full-screen terminal UI (chat pane, live tool feed)
28
- scoder init First-run wizard: writes .scoderrc.json + tests the connection
29
- scoder list List saved sessions for this directory
30
- scoder models [provider] [--refresh]
31
- Model catalog (context window, pricing) —
32
- synced from models.dev, cached 24h
33
- scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
34
- scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
35
- scoder cost [--session <id>] [--last <n>]
36
- Token usage & estimated cost from JSONL logs
37
- scoder serve [--port <n>] [--host <addr>]
38
- Run as an HTTP server: POST tasks, stream
39
- progress via Server-Sent Events (see below)
40
- scoder --version | --help
41
-
42
- OPTIONS
43
- -m, --model <name> LLM model (env SCODER_MODEL / OPENAI_MODEL, default gpt-4o-mini)
44
- --base-url <url> OpenAI-compatible API base URL (env SCODER_BASE_URL / OPENAI_BASE_URL)
45
- -k, --api-key <key> API key (env SCODER_API_KEY / OPENAI_API_KEY)
46
- --provider <name> openai | anthropic (env SCODER_PROVIDER; auto-detected from model/baseUrl)
47
- -B, --build <cmd> Build command = stop condition (auto-detected if omitted)
48
- -d, --dir <path> Working directory (default: current directory)
49
- -s, --max-steps <n> Max autonomous loop iterations (default 40)
50
- -y, --yes Non-interactive mode (auto-approves commands)
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>
53
- --no-save Disable session auto-save to .scoder/sessions/
54
- --log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
55
- (or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
56
- --session <id> (cost) Aggregate usage of one specific session
57
- --last <n> (cost) Aggregate usage of the n most recent sessions
58
- -h, --help Show this help
59
- -v, --version Show version
60
-
61
- CONFIG FILE
62
- .scoderrc.json in the working directory (see .scoderrc.example.json):
63
- { "model": "...", "baseUrl": "...", "buildCmd": "...", "maxSteps": 40,
64
- "permissions": { "mode": "auto", "allowCommands": ["npm "], "denyCommands": ["curl "] } }
65
-
66
- EXAMPLES
67
- scoder "add a divide function to src/math.ts with tests"
68
- scoder --build "make test" "fix the failing test in utils"
69
- scoder --permission ask "npm install express and wire up a route"
70
- scoder -m claude-sonnet-4-5 "refactor the parser module" # auto-detects Anthropic
71
- scoder resume last "now add unit tests"
72
- `;
73
- function parseArgs(argv) {
74
- const args = { task: null, yes: false, help: false, version: false };
75
- const rest = [];
76
- for (let i = 0; i < argv.length; i++) {
77
- const a = argv[i];
78
- const next = () => argv[++i];
79
- switch (a) {
80
- case "-m":
81
- case "--model":
82
- args.model = next();
83
- break;
84
- case "-k":
85
- case "--api-key":
86
- args.apiKey = next();
87
- break;
88
- case "--base-url":
89
- args.baseUrl = next();
90
- break;
91
- case "-B":
92
- case "--build":
93
- args.buildCmd = next();
94
- break;
95
- case "-d":
96
- case "--dir":
97
- args.workdir = next();
98
- break;
99
- case "-s":
100
- case "--max-steps":
101
- args.maxSteps = Number(next());
102
- break;
103
- case "-y":
104
- case "--yes":
105
- args.yes = true;
106
- break;
107
- case "--agent":
108
- args.agent = next();
109
- break;
110
- case "--permission": {
111
- const mode = next();
112
- if (mode !== "auto" && mode !== "ask" && mode !== "readonly") {
113
- throw new Error(`Invalid --permission mode: ${mode} (expected auto | ask | readonly)`);
114
- }
115
- args.permission = mode;
116
- break;
117
- }
118
- case "--no-save":
119
- args.noSave = true;
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;
133
- case "--log-file":
134
- args.logFile = true;
135
- break;
136
- case "--session":
137
- args.costSession = next();
138
- break;
139
- case "--last": {
140
- const n = Number(next());
141
- if (!Number.isFinite(n) || n < 1) {
142
- throw new Error(`Invalid --last value (expected a positive integer): ${n}`);
143
- }
144
- args.costLast = Math.floor(n);
145
- break;
146
- }
147
- case "--provider": {
148
- const p = next();
149
- if (p !== "openai" && p !== "anthropic") {
150
- throw new Error(`Invalid --provider: ${p} (expected openai | anthropic)`);
151
- }
152
- args.provider = p;
153
- break;
154
- }
155
- case "--port": {
156
- const n = Number(next());
157
- if (!Number.isInteger(n) || n < 0 || n > 65535) {
158
- throw new Error(`Invalid --port value (expected 0-65535): ${n}`);
159
- }
160
- args.servePort = n;
161
- break;
162
- }
163
- case "--host":
164
- args.serveHost = next();
165
- break;
166
- case "-h":
167
- case "--help":
168
- args.help = true;
169
- break;
170
- case "-v":
171
- case "--version":
172
- args.version = true;
173
- break;
174
- default: {
175
- if (a.startsWith("-")) {
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;
188
- }
189
- rest.push(a);
190
- }
191
- }
192
- }
193
- args.task = rest.length > 0 ? rest.join(" ") : null;
194
- // Subcommands: scoder list | scoder resume <id|last> [follow-up task] | scoder logs [id]
195
- if (rest.length > 0 && rest[0] === "list") {
196
- args.subcommand = "list";
197
- args.task = null;
198
- }
199
- else if (rest[0] === "resume") {
200
- args.subcommand = "resume";
201
- args.resumeId = rest[1] ?? "last";
202
- args.task = rest.length > 2 ? rest.slice(2).join(" ") : null;
203
- }
204
- else if (rest[0] === "logs") {
205
- args.subcommand = "logs";
206
- args.logsId = rest[1] ?? "last";
207
- args.logsFilter = rest.length > 2 ? rest.slice(2).join(" ") : undefined;
208
- }
209
- else if (rest[0] === "cost") {
210
- args.subcommand = "cost";
211
- args.task = null;
212
- }
213
- else if (rest[0] === "serve") {
214
- args.subcommand = "serve";
215
- args.task = null;
216
- }
217
- else if (rest[0] === "tui") {
218
- // scoder tui [initial-task] — full-screen Ink interface
219
- args.subcommand = "tui";
220
- args.task = rest.length > 1 ? rest.slice(1).join(" ") : null;
221
- }
222
- else if (rest[0] === "models") {
223
- // scoder models [provider] [--refresh] — models.dev catalog (I-02)
224
- args.subcommand = "models";
225
- args.modelsProvider = rest[1] && !rest[1].startsWith("--") ? rest[1] : undefined;
226
- args.modelsRefresh = rest.includes("--refresh");
227
- args.task = null;
228
- }
229
- else if (rest[0] === "init") {
230
- args.subcommand = "init";
231
- args.task = null;
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
- }
250
- return args;
251
- }
252
- async function runOnce(cfg, task) {
253
- const agent = new Agent(cfg);
254
- process.on("SIGINT", () => {
255
- ui.warn("\nSIGINT received — finishing current tool, then stopping...");
256
- agent.abort();
257
- });
258
- try {
259
- const result = await agent.run(task);
260
- const pruned = pruneSessions(cfg.workdir, 7);
261
- if (pruned > 0)
262
- ui.info(`pruned ${pruned} old session(s)`);
263
- return result.success ? 0 : 1;
264
- }
265
- catch (err) {
266
- ui.error(err.message);
267
- return 2;
268
- }
269
- finally {
270
- agent.dispose();
271
- }
272
- }
273
- /**
274
- * `scoder init` — first-run wizard: asks for the essentials, writes
275
- * .scoderrc.json, then offers a live connection test against the provider.
276
- */
277
- export async function runInit() {
278
- const { existsSync, writeFileSync } = await import("node:fs");
279
- const rcPath = resolve(process.cwd(), ".scoderrc.json");
280
- ui.banner("scoder init — configure this project", [
281
- ["file", rcPath],
282
- ["note", "answers are saved to .scoderrc.json (env vars still override)"],
283
- ]);
284
- if (existsSync(rcPath)) {
285
- const rl0 = createInterface({ input: process.stdin, output: process.stdout });
286
- const overwrite = await new Promise((res) => rl0.question(`.scoderrc.json already exists — overwrite? [y/N] `, res));
287
- rl0.close();
288
- if (!/^y(es)?$/i.test(overwrite.trim())) {
289
- ui.info("kept the existing .scoderrc.json");
290
- return;
291
- }
292
- }
293
- const rl = createInterface({ input: process.stdin, output: process.stdout });
294
- const ask = (q, def) => new Promise((res) => rl.question(`${q}${def ? ` [${def}]` : ""}: `, (a) => res(a.trim() || def)));
295
- // I-03: probe local engines first — suggest when found (no auto-switch when
296
- // a cloud key exists; user always decides).
297
- ui.info("probing local engines (ollama :11434, lmstudio :1234, vllm :8000)…");
298
- const { discoverLocalEngines } = await import("./provider-registry.js");
299
- const locals = await discoverLocalEngines();
300
- for (const e of locals) {
301
- ui.info(` found ${e.provider.id} at ${e.provider.baseUrl} — ${e.models.length} model(s), e.g. ${e.models[0]}`);
302
- }
303
- const suggestBase = locals[0]?.provider.baseUrl ?? "https://api.openai.com/v1";
304
- const suggestModel = locals[0]?.models[0] ?? "gpt-4o-mini";
305
- const baseUrl = await ask("API base URL (OpenAI-compatible)", suggestBase);
306
- const apiKey = await ask("API key (SCODER_API_KEY/OPENAI_API_KEY also work at runtime)", "");
307
- const model = await ask("Model", suggestModel);
308
- const buildCmd = await ask("Build command (empty = auto-detect)", "");
309
- const permission = (await ask("Permission mode (auto | ask | readonly)", "auto")).toLowerCase();
310
- rl.close();
311
- const provider = /api\.anthropic\.com/.test(baseUrl) || /^claude-/i.test(model) ? "anthropic" : "openai";
312
- const rc = {
313
- model,
314
- baseUrl,
315
- provider,
316
- ...(buildCmd ? { buildCmd } : {}),
317
- permissions: { mode: ["auto", "ask", "readonly"].includes(permission) ? permission : "auto" },
318
- };
319
- writeFileSync(rcPath, JSON.stringify(rc, null, 2) + "\n");
320
- ui.success(`wrote ${rcPath} (provider: ${provider})`);
321
- const rl2 = createInterface({ input: process.stdin, output: process.stdout });
322
- const doTest = await new Promise((res) => rl2.question("Test the connection now? [Y/n] ", res));
323
- rl2.close();
324
- if (!/^y(es)?$/i.test(doTest.trim()) && doTest.trim() !== "") {
325
- ui.info("skipped connection test");
326
- return;
327
- }
328
- if (!apiKey) {
329
- ui.warn("no API key provided — set SCODER_API_KEY/OPENAI_API_KEY before running tasks");
330
- return;
331
- }
332
- ui.info(`connecting to ${baseUrl} (${provider}) …`);
333
- try {
334
- const { LlmClient } = await import("./llm.js");
335
- const { AnthropicClient } = await import("./provider-anthropic.js");
336
- const client = provider === "anthropic"
337
- ? new AnthropicClient({ baseUrl, apiKey, model, maxTokens: 16, temperature: 0 })
338
- : new LlmClient({ baseUrl, apiKey, model, maxTokens: 16, temperature: 0 });
339
- const res = await client.chat([{ role: "user", content: "Reply with exactly: OK" }], []);
340
- const reply = (res.message.content ?? "").trim();
341
- ui.success(`connection OK — model replied: ${JSON.stringify(reply)}`);
342
- }
343
- catch (err) {
344
- ui.error(`connection test failed: ${err.message}`);
345
- process.exitCode = 2;
346
- }
347
- }
348
- async function repl(cfg) {
349
- ui.header(`scoder interactive — model: ${cfg.model}`);
350
- ui.info('Type a task and press Enter. "exit" or Ctrl+D to quit.');
351
- const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "you> " });
352
- // One shared session for the whole REPL conversation (plan P0.5 §4):
353
- // every input is a follow-up turn, context carries over, saved continuously.
354
- // /clear swaps in a fresh Agent (the /clear branch reassigns this).
355
- let agent = new Agent(cfg);
356
- process.on("SIGINT", () => {
357
- ui.warn("\nSIGINT — aborting current task...");
358
- agent.abort();
359
- });
360
- let first = true;
361
- for (;;) {
362
- const line = await new Promise((res) => rl.question("you> ", res));
363
- const input = line.trim();
364
- if (!input)
365
- continue;
366
- if (input === "exit" || input === "quit")
367
- break;
368
- // Slash commands — registry dùng chung với TUI (T1). Command riêng của
369
- // TUI (/review /undo /theme /diff turn-scope) do TUI tự xử lý trước.
370
- const host = {
371
- agent: () => agent,
372
- config: cfg,
373
- updateConfig: (patch) => {
374
- Object.assign(cfg, patch); // mutate the shared cfg so new Agents see it
375
- },
376
- replaceAgent: (next) => {
377
- agent.abort();
378
- agent.dispose();
379
- agent = next ?? new Agent(cfg);
380
- first = true;
381
- },
382
- resumeAndRun: async (s) => {
383
- agent.abort();
384
- agent.dispose();
385
- agent = new Agent(cfg);
386
- first = false;
387
- await agent.resume(s);
388
- return { success: true, steps: 0, buildCmd: cfg.buildCmd, summary: "resumed" };
389
- },
390
- };
391
- const { handled, outcome } = await dispatchCommand(input, host, (text, level) => {
392
- if (level === "error")
393
- ui.error(text);
394
- else
395
- console.log(text);
396
- });
397
- if (handled) {
398
- if (outcome?.quit)
399
- break;
400
- if (outcome?.notice)
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
- }
413
- continue;
414
- }
415
- try {
416
- const result = first ? await agent.run(input) : await agent.runTurn(input);
417
- first = false;
418
- if (!result.success)
419
- ui.warn("task did not complete — try refining it");
420
- }
421
- catch (err) {
422
- ui.error(err.message);
423
- }
424
- }
425
- agent.dispose();
426
- ui.info("bye");
427
- process.exit(0);
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
- }
614
- async function main() {
615
- let args;
616
- try {
617
- args = parseArgs(process.argv.slice(2));
618
- }
619
- catch (err) {
620
- ui.error(err.message);
621
- process.exit(2);
622
- }
623
- if (args.version) {
624
- console.log(VERSION);
625
- return;
626
- }
627
- if (args.help) {
628
- console.log(HELP);
629
- return;
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";
639
- const cfg = loadConfig({
640
- model: args.model,
641
- baseUrl: args.baseUrl,
642
- apiKey: args.apiKey,
643
- buildCmd: args.buildCmd ?? undefined,
644
- workdir: args.workdir ? resolve(args.workdir) : undefined,
645
- maxSteps: args.maxSteps,
646
- yes: args.yes,
647
- permissionMode: args.permission ?? undefined,
648
- provider: args.provider ?? undefined,
649
- noSave: args.noSave,
650
- logFile: args.logFile,
651
- agent: args.agent,
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
- }
661
- // Subcommands
662
- if (args.subcommand === "init") {
663
- await runInit();
664
- return;
665
- }
666
- if (args.subcommand === "list") {
667
- const sessions = listSessions(cfg.workdir);
668
- if (sessions.length === 0) {
669
- console.log("No saved sessions in this directory.");
670
- return;
671
- }
672
- for (const s of sessions) {
673
- console.log(`${s.id} steps=${String(s.steps).padStart(3)} ${s.updatedAt} ${s.task}`);
674
- }
675
- return;
676
- }
677
- if (args.subcommand === "resume") {
678
- const s = resolveSessionId(cfg.workdir, args.resumeId ?? "last");
679
- if (!s) {
680
- ui.error(`Session not found: ${args.resumeId ?? "last"}. Run \`scoder list\` to see saved sessions.`);
681
- process.exit(2);
682
- }
683
- const agent = new Agent(cfg);
684
- process.on("SIGINT", () => {
685
- ui.warn("\nSIGINT received — finishing current tool, then stopping...");
686
- agent.abort();
687
- });
688
- try {
689
- const result = await agent.resume(s, args.task ?? undefined);
690
- agent.dispose();
691
- process.exit(result.success ? 0 : 1);
692
- }
693
- catch (err) {
694
- agent.dispose();
695
- ui.error(err.message);
696
- process.exit(2);
697
- }
698
- }
699
- if (args.subcommand === "logs") {
700
- let id = args.logsId ?? "last";
701
- if (id === "last") {
702
- const sessions = listSessions(cfg.workdir);
703
- if (sessions.length === 0) {
704
- ui.error("No saved sessions in this directory. Run with --log-file to record logs.");
705
- process.exit(2);
706
- }
707
- id = sessions[0].id;
708
- }
709
- const events = JsonlLogger.read(cfg.workdir, id);
710
- if (events === null || events.length === 0) {
711
- ui.error(`No log found for session ${id}. Logs require --log-file (or SCODER_LOG_FILE=1).`);
712
- process.exit(2);
713
- }
714
- const filter = args.logsFilter?.toLowerCase();
715
- for (const ev of events) {
716
- const line = JSON.stringify(ev);
717
- if (filter && !line.toLowerCase().includes(filter))
718
- continue;
719
- console.log(line);
720
- }
721
- return;
722
- }
723
- if (args.subcommand === "cost") {
724
- const sessionCosts = collectUsage(cfg.workdir, {
725
- sessionId: args.costSession,
726
- last: args.costLast,
727
- pricingOverrides: cfg.modelPricing,
728
- });
729
- console.log(formatCostReport(sessionCosts));
730
- return;
731
- }
732
- if (args.subcommand === "models") {
733
- const { loadCatalog, cacheAgeHours } = await import("./model-catalog.js");
734
- const filter = args.modelsProvider ?? null;
735
- const { models, source } = await loadCatalog(args.modelsRefresh === true);
736
- const age = cacheAgeHours();
737
- const ageStr = source === "fallback" ? "offline fallback snapshot" : age < 0 ? "?" : `${age.toFixed(1)}h ago`;
738
- console.log(`models catalog — source: ${source} (updated ${ageStr}), ${Object.keys(models).length} entries\n`);
739
- const rows = Object.values(models)
740
- .filter((m) => !filter || m.provider === filter || m.id.includes(filter))
741
- .sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id))
742
- .slice(0, 60);
743
- for (const m of rows) {
744
- const flags = [m.supportCache ? "cache" : null, m.supportReasoning ? "reasoning" : null].filter(Boolean).join(",");
745
- console.log(` ${m.provider}/${m.id} ctx=${(m.contextWindow / 1000).toFixed(0)}k${m.pricing ? ` $${m.pricing.input}/$${m.pricing.output} per 1M` : ""}${flags ? ` [${flags}]` : ""}`);
746
- }
747
- if (rows.length === 0)
748
- console.log(` (no models match "${filter}")`);
749
- return;
750
- }
751
- if (args.subcommand === "mcp") {
752
- await runMcpCommand(cfg, args.mcpCmd ?? "list", args.mcpArgs ?? []);
753
- return;
754
- }
755
- if (args.subcommand === "tui") {
756
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
757
- ui.warn("TUI requires an interactive terminal (TTY) — falling back to the classic REPL.");
758
- await repl(cfg);
759
- return;
760
- }
761
- if (!cfg.apiKey) {
762
- ui.warn("No API key configured — tasks will fail until SCODER_API_KEY (or OPENAI_API_KEY) is set.");
763
- }
764
- const { runTui } = await import("./tui/app.js");
765
- await runTui(cfg, args.task);
766
- return;
767
- }
768
- if (args.subcommand === "serve") {
769
- const { startServer } = await import("./server.js");
770
- if (!cfg.apiKey) {
771
- ui.warn("No API key configured — runs will fail until SCODER_API_KEY (or OPENAI_API_KEY) is set.");
772
- }
773
- const srv = await startServer(cfg, { host: args.serveHost, port: args.servePort });
774
- const token = process.env.SCODER_SERVER_TOKEN ? " · token auth" : "";
775
- ui.banner(`scoder serve @ http://${args.serveHost ?? "127.0.0.1"}:${srv.port}${token}`, [
776
- ["task", `POST /api/runs { "task": "..." } → 202 { id, eventsUrl }`],
777
- ["events", `GET /api/runs/<id>/events → SSE stream`],
778
- ["list", `GET /api/runs`],
779
- ["abort", `POST /api/runs/<id>/abort`],
780
- ["workdir", cfg.workdir],
781
- ["model", `${cfg.model} · permission ${cfg.permissionMode}`],
782
- ]);
783
- const shutdown = async () => {
784
- ui.info("shutting down server...");
785
- await srv.close();
786
- process.exit(0);
787
- };
788
- process.on("SIGINT", () => {
789
- ui.warn("\nSIGINT — shutting down server, aborting in-flight runs...");
790
- void shutdown();
791
- });
792
- return;
793
- }
794
- if (args.task) {
795
- const code = await runOnce(cfg, args.task);
796
- process.exit(code);
797
- }
798
- await repl(cfg);
799
- }
800
4
  import { pathToFileURL } from "node:url";
5
+ import * as ui from "./io/terminal.js";
6
+ import { main } from "./cli/main.js";
7
+ export { parseArgs } from "./cli/args.js";
8
+ export { runInit } from "./cli/init.js";
801
9
  // Guard: only run when executed as entry point (not imported in tests).
802
10
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
803
11
  main().catch((err) => {
@@ -805,4 +13,3 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
805
13
  process.exit(2);
806
14
  });
807
15
  }
808
- export { parseArgs };