s-app-scoder-agent 0.8.21 → 0.9.3
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/chrome.js +2 -1
- 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
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { listSessions, resolveSessionId } from "../session.js";
|
|
2
|
-
import { discoverSkills } from "../skills.js";
|
|
3
|
-
import {
|
|
2
|
+
import { discoverSkills, skillWarnings } from "../skills.js";
|
|
3
|
+
import { selectableAgents } from "../agents.js";
|
|
4
|
+
import { agentsMdTemplate } from "../agents-guide.js";
|
|
5
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { formatSlashHelp, parseMcpExec, BUILTIN_COMMANDS, resolveCommandDef, discoverCustomCommands, runCustomCommand } from "./commands.js";
|
|
8
|
+
import { exportTranscript, findInTranscript } from "./transcript.js";
|
|
4
9
|
/** Format usage/cost block (dùng chung — REPL console.log, TUI notice). */
|
|
5
10
|
export function formatUsage(u) {
|
|
6
11
|
return u.calls > 0
|
|
@@ -30,8 +35,30 @@ export function formatMcpStatus(status) {
|
|
|
30
35
|
if (status.length === 0)
|
|
31
36
|
return "no MCP servers configured — add mcpServers to .scoderrc.json";
|
|
32
37
|
return status
|
|
33
|
-
.map((srv) =>
|
|
34
|
-
|
|
38
|
+
.map((srv) => {
|
|
39
|
+
const capParts = [];
|
|
40
|
+
if (srv.serverInfo?.name)
|
|
41
|
+
capParts.push(srv.serverInfo.name + (srv.serverInfo.version ? " v" + srv.serverInfo.version : ""));
|
|
42
|
+
if (srv.capabilities?.ping === true)
|
|
43
|
+
capParts.push("ping");
|
|
44
|
+
if (srv.capabilities?.tools)
|
|
45
|
+
capParts.push("tools");
|
|
46
|
+
if (srv.capabilities?.resources)
|
|
47
|
+
capParts.push("resources");
|
|
48
|
+
if (srv.capabilities?.prompts)
|
|
49
|
+
capParts.push("prompts");
|
|
50
|
+
const capTag = capParts.length > 0 ? ` [${capParts.join(", ")}]` : "";
|
|
51
|
+
const counts = [
|
|
52
|
+
`${srv.tools.length} tools`,
|
|
53
|
+
...(srv.resourceCount ? [`${srv.resourceCount} resources`] : []),
|
|
54
|
+
...(srv.promptCount ? [`${srv.promptCount} prompts`] : []),
|
|
55
|
+
].join(", ");
|
|
56
|
+
return (`${srv.connected ? "✓" : "·"} ${srv.name} — ${srv.command}${capTag}` +
|
|
57
|
+
(srv.connected
|
|
58
|
+
? ` (${counts})\n` +
|
|
59
|
+
srv.tools.map((t) => ` mcp__${srv.name}__${t.name} ${t.description ?? ""}`).join("\n")
|
|
60
|
+
: " (not connected)"));
|
|
61
|
+
})
|
|
35
62
|
.join("\n");
|
|
36
63
|
}
|
|
37
64
|
/**
|
|
@@ -70,9 +97,58 @@ export function coreCommands() {
|
|
|
70
97
|
},
|
|
71
98
|
{
|
|
72
99
|
name: "/skills",
|
|
73
|
-
description: "list
|
|
74
|
-
run(
|
|
75
|
-
|
|
100
|
+
description: "list/info/lint/reload skills — /skills [info <name>|lint|reload]",
|
|
101
|
+
run(slash, host, notice) {
|
|
102
|
+
const arg = slash.arg.trim();
|
|
103
|
+
const skills = host.agent()?.getSkills() ?? discoverSkills(host.config.workdir);
|
|
104
|
+
if (!arg) {
|
|
105
|
+
notice(formatSkills(skills));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const parts = arg.split(/\s+/);
|
|
109
|
+
const sub = parts[0].toLowerCase();
|
|
110
|
+
if (sub === "info") {
|
|
111
|
+
const name = parts.slice(1).join(" ").trim();
|
|
112
|
+
if (!name)
|
|
113
|
+
return notice("usage: /skills info <name>", "warn");
|
|
114
|
+
const skill = skills.find((s) => s.name.toLowerCase() === name.toLowerCase());
|
|
115
|
+
if (!skill)
|
|
116
|
+
return notice(`skill "${name}" not found`, "error");
|
|
117
|
+
const lines = [
|
|
118
|
+
`# ${skill.name}${skill.version ? ` v${skill.version}` : ""}`,
|
|
119
|
+
`description: ${skill.description}`,
|
|
120
|
+
`scope: ${skill.scope}`,
|
|
121
|
+
skill.tags?.length ? `tags: ${skill.tags.join(", ")}` : null,
|
|
122
|
+
skill.allowedTools?.length ? `allowed-tools: ${skill.allowedTools.join(", ")}` : null,
|
|
123
|
+
skill.references?.length ? `references: ${skill.references.join(", ")}` : null,
|
|
124
|
+
skill.whenToUse ? `when-to-use: ${skill.whenToUse}` : null,
|
|
125
|
+
"",
|
|
126
|
+
skill.body,
|
|
127
|
+
].filter(Boolean);
|
|
128
|
+
notice(lines.join("\n"));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (sub === "lint") {
|
|
132
|
+
const warnings = skillWarnings.filter((w) => !host.config.workdir || w.path.startsWith(host.config.workdir));
|
|
133
|
+
if (warnings.length === 0)
|
|
134
|
+
return notice("skills lint: ok (no warnings)", "info");
|
|
135
|
+
notice(warnings.map((w) => `${w.path}: ${w.message}`).join("\n"), "warn");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (sub === "reload") {
|
|
139
|
+
const ag = host.agent();
|
|
140
|
+
if (ag)
|
|
141
|
+
ag.reloadSkills?.();
|
|
142
|
+
notice(formatSkills(host.agent()?.getSkills() ?? discoverSkills(host.config.workdir)));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// default: treat arg as skill name → show info
|
|
146
|
+
const skill = skills.find((s) => s.name.toLowerCase() === arg.toLowerCase());
|
|
147
|
+
if (skill) {
|
|
148
|
+
notice(`# ${skill.name}\n${skill.description}\n${skill.body.slice(0, 500)}`, "info");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
notice(`unknown /skills subcommand: ${arg}`, "error");
|
|
76
152
|
},
|
|
77
153
|
},
|
|
78
154
|
{
|
|
@@ -135,10 +211,86 @@ export function coreCommands() {
|
|
|
135
211
|
return;
|
|
136
212
|
}
|
|
137
213
|
host.agent()?.setPermissionMode(mode);
|
|
214
|
+
host.updateConfig?.({ permissionMode: mode });
|
|
138
215
|
host.config = withPermissionMode(host.config, mode);
|
|
139
216
|
notice(`permission mode → ${mode}`);
|
|
140
217
|
},
|
|
141
218
|
},
|
|
219
|
+
{
|
|
220
|
+
name: "/plan",
|
|
221
|
+
description: "show the current plan — /plan done|start <id|n|text> to mark steps",
|
|
222
|
+
run(slash, host, notice) {
|
|
223
|
+
const agent = host.agent();
|
|
224
|
+
const plan = agent?.getPlan() ?? [];
|
|
225
|
+
if (!slash.arg) {
|
|
226
|
+
if (plan.length === 0)
|
|
227
|
+
return notice("no plan yet — multi-step tasks get one via write_todos");
|
|
228
|
+
const icon = (s) => (s === "completed" ? "✓" : s === "in_progress" ? "▸" : "○");
|
|
229
|
+
notice("Plan:\n" +
|
|
230
|
+
plan
|
|
231
|
+
.map((t, i) => {
|
|
232
|
+
let line = ` ${icon(t.status)} ${String(i + 1).padStart(2)}. ${t.content}`;
|
|
233
|
+
if (t.verify)
|
|
234
|
+
line += `\n verify: ${t.verify}`;
|
|
235
|
+
if (t.acceptance)
|
|
236
|
+
line += `\n acceptance: ${t.acceptance}`;
|
|
237
|
+
if (t.files?.length)
|
|
238
|
+
line += `\n files: ${t.files.join(", ")}`;
|
|
239
|
+
return line;
|
|
240
|
+
})
|
|
241
|
+
.join("\n") +
|
|
242
|
+
"\nmark: /plan done <id|n> · /plan start <id|n>");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const m = /^(done|start)\s+(.+)$/.exec(slash.arg.trim());
|
|
246
|
+
if (!agent)
|
|
247
|
+
return notice("agent not ready", "warn");
|
|
248
|
+
if (!m)
|
|
249
|
+
return notice("usage: /plan [done|start <id|n|text>]", "warn");
|
|
250
|
+
const r = agent.markPlanStep(m[1] === "done" ? "completed" : "in_progress", m[2]);
|
|
251
|
+
notice(r.message, r.ok ? "info" : "warn");
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: "/agent",
|
|
256
|
+
description: "list selectable agents — /agent <name> to switch (idle only)",
|
|
257
|
+
run(slash, host, notice) {
|
|
258
|
+
const agents = selectableAgents(host.config.workdir);
|
|
259
|
+
const current = host.config.agent ?? "build";
|
|
260
|
+
if (!slash.arg) {
|
|
261
|
+
notice("Agents:\n" +
|
|
262
|
+
agents.map((a) => ` ${a.name === current ? "*" : " "} ${a.name.padEnd(10)} ${a.description}`).join("\n") +
|
|
263
|
+
"\nswitch with /agent <name> (idle only; resets the conversation)");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const name = slash.arg.trim();
|
|
267
|
+
const def = agents.find((a) => a.name === name);
|
|
268
|
+
if (!def) {
|
|
269
|
+
return notice(`agent "${name}" not selectable — available: ${agents.map((a) => a.name).join(", ")}`, "error");
|
|
270
|
+
}
|
|
271
|
+
if (current === name)
|
|
272
|
+
return notice(`already on agent "${name}"`);
|
|
273
|
+
if (host.isRunning?.())
|
|
274
|
+
return notice("agent is running — wait or Esc first", "warn");
|
|
275
|
+
host.updateConfig?.({ agent: name });
|
|
276
|
+
host.config = { ...host.config, agent: name };
|
|
277
|
+
host.replaceAgent(null); // fresh agent with the new def; old conversation ends here
|
|
278
|
+
notice(`agent → ${name} (${def.mode}) — conversation reset; /sessions can resume the previous one`);
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: "/init",
|
|
283
|
+
description: "create an AGENTS.md template in this project",
|
|
284
|
+
run(_slash, host, notice) {
|
|
285
|
+
const target = join(host.config.workdir, "AGENTS.md");
|
|
286
|
+
if (existsSync(target)) {
|
|
287
|
+
notice(`AGENTS.md already exists: ${target}`, "warn");
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
writeFileSync(target, agentsMdTemplate(host.config.workdir), "utf8");
|
|
291
|
+
notice(`created ${target} — review and commit it`);
|
|
292
|
+
},
|
|
293
|
+
},
|
|
142
294
|
{
|
|
143
295
|
name: "/sessions",
|
|
144
296
|
description: "list saved sessions — /sessions resume <id|last>",
|
|
@@ -160,8 +312,115 @@ export function coreCommands() {
|
|
|
160
312
|
notice(formatSessions(listSessions(host.config.workdir)));
|
|
161
313
|
},
|
|
162
314
|
},
|
|
315
|
+
{
|
|
316
|
+
name: "/commands",
|
|
317
|
+
description: "list all commands (builtin + custom) — /commands <name> to inspect",
|
|
318
|
+
run(slash, host, notice) {
|
|
319
|
+
const all = BUILTIN_COMMANDS.map((c) => ({
|
|
320
|
+
name: c.name,
|
|
321
|
+
desc: c.description,
|
|
322
|
+
source: c.source,
|
|
323
|
+
}));
|
|
324
|
+
const custom = discoverCustomCommands(host.config.workdir).map((c) => ({
|
|
325
|
+
name: "/commands " + c.name,
|
|
326
|
+
desc: c.description,
|
|
327
|
+
source: c.source,
|
|
328
|
+
}));
|
|
329
|
+
const combined = [...all, ...custom];
|
|
330
|
+
if (!slash.arg) {
|
|
331
|
+
// List mode — grouped by source.
|
|
332
|
+
const bySource = {};
|
|
333
|
+
for (const c of combined)
|
|
334
|
+
bySource[c.source] = (bySource[c.source] ?? []).concat(c);
|
|
335
|
+
const sections = Object.entries(bySource)
|
|
336
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
337
|
+
.map(([src, cmds]) => ` ${src}:\n${cmds.map((c) => ` ${c.name} ${c.desc}`).join("\n")}`);
|
|
338
|
+
notice(`commands:\n${sections.join("\n\n")}`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
// Inspect mode — /commands <name>
|
|
342
|
+
const name = slash.arg.trim().toLowerCase();
|
|
343
|
+
const customCmd = custom.find((c) => c.name === "/commands " + name || c.name === "/commands " + name.slice(1));
|
|
344
|
+
if (customCmd) {
|
|
345
|
+
// Full info for custom command
|
|
346
|
+
const cmd = discoverCustomCommands(host.config.workdir).find((c) => "/commands " + c.name === customCmd.name);
|
|
347
|
+
if (!cmd)
|
|
348
|
+
return;
|
|
349
|
+
notice(`command: ${cmd.name}\n` +
|
|
350
|
+
`description: ${cmd.description}\n` +
|
|
351
|
+
`exec: ${cmd.exec ?? "prompt"}\n` +
|
|
352
|
+
`source: ${cmd.source} — ${cmd.filePath}\n` +
|
|
353
|
+
(cmd.args ? `args: ${cmd.args.join(", ")}\n` : "") +
|
|
354
|
+
`\n${cmd.body.slice(0, 400)}\n` +
|
|
355
|
+
(cmd.body.length > 400 ? "\n… (truncated)" : ""));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const builtin = BUILTIN_COMMANDS.find((c) => c.name === "/" + name || c.name === "/commands " + name);
|
|
359
|
+
if (builtin) {
|
|
360
|
+
notice(`${builtin.name}: ${builtin.description}\nusage: ${builtin.usage}`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
notice(`command "/${name}" not found`, "warn");
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
name: "/export",
|
|
368
|
+
description: "export session transcript: /export [md|json] — writes to workdir/scoder-export.{md|json}",
|
|
369
|
+
run(slash, host, notice) {
|
|
370
|
+
const items = host.transcriptItems ? host.transcriptItems() : [];
|
|
371
|
+
if (items.length === 0) {
|
|
372
|
+
notice("no transcript to export", "warn");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const fmt = slash.arg.trim().toLowerCase() || "md";
|
|
376
|
+
if (fmt !== "md" && fmt !== "json") {
|
|
377
|
+
notice("usage: /export [md|json]", "warn");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const content = exportTranscript(items, fmt);
|
|
381
|
+
const fileName = `scoder-export.${fmt}`;
|
|
382
|
+
const filePath = join(host.config.workdir, fileName);
|
|
383
|
+
try {
|
|
384
|
+
writeFileSync(filePath, "\ufeff" + content, "utf8");
|
|
385
|
+
notice(`exported ${items.length} items → ${filePath}`);
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
notice(`export failed: ${e.message}`, "error");
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: "/search",
|
|
394
|
+
description: "search transcript: /search <query> — shows snippets; TUI jumps to result",
|
|
395
|
+
run(slash, host, notice) {
|
|
396
|
+
if (!slash.arg.trim()) {
|
|
397
|
+
notice("usage: /search <query>", "warn");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const items = host.transcriptItems ? host.transcriptItems() : [];
|
|
401
|
+
if (items.length === 0) {
|
|
402
|
+
notice("no transcript to search", "warn");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const matches = findInTranscript(items, slash.arg);
|
|
406
|
+
if (matches.length === 0) {
|
|
407
|
+
notice(`no results for "${slash.arg.trim()}"`, "warn");
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
notice(`${matches.length} match${matches.length > 1 ? "es" : ""} in transcript:\n` +
|
|
411
|
+
matches.slice(0, 20).map((m) => ` [${m.index}] ${m.kind}: ${m.snippet}`).join("\n"));
|
|
412
|
+
// TUI: jump to first result
|
|
413
|
+
if (host.selectTranscriptItem && matches.length > 0) {
|
|
414
|
+
host.selectTranscriptItem(matches[0].index);
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
},
|
|
163
418
|
];
|
|
164
419
|
}
|
|
420
|
+
/** Merged catalog of builtin command metadata — /help & completion read this. */
|
|
421
|
+
export function commandCatalog() {
|
|
422
|
+
return BUILTIN_COMMANDS.map((def) => ({ ...def }));
|
|
423
|
+
}
|
|
165
424
|
/** Clone config với permissionMode mới (host.config thay thế được). */
|
|
166
425
|
function withPermissionMode(cfg, mode) {
|
|
167
426
|
return { ...cfg, permissionMode: mode };
|
|
@@ -172,11 +431,14 @@ function withPermissionMode(cfg, mode) {
|
|
|
172
431
|
* /diff; REPL: không có) TRƯỚC khi gọi hàm này.
|
|
173
432
|
*/
|
|
174
433
|
export async function dispatchCommand(input, host, notice, extra = []) {
|
|
175
|
-
const m = /^\/([a-zA-Z][a-zA-Z0-9]*)(?:\s+(
|
|
434
|
+
const m = /^\/([a-zA-Z][a-zA-Z0-9-]*)(?:\s+([\s\S]*\S))?\s*$/.exec(input.trim());
|
|
176
435
|
if (!m)
|
|
177
436
|
return { handled: false, outcome: null };
|
|
178
|
-
const
|
|
437
|
+
const raw = "/" + m[1].toLowerCase();
|
|
179
438
|
const arg = m[2] ?? "";
|
|
439
|
+
// Resolve aliases → canonical command (e.g. /quit → /exit).
|
|
440
|
+
const resolved = resolveCommandDef(raw);
|
|
441
|
+
const cmd = resolved?.name ?? raw;
|
|
180
442
|
const slash = { cmd, arg };
|
|
181
443
|
if (cmd === "/exit" || cmd === "/quit")
|
|
182
444
|
return { handled: true, outcome: { quit: true } };
|
|
@@ -185,6 +447,19 @@ export async function dispatchCommand(input, host, notice, extra = []) {
|
|
|
185
447
|
host.replaceAgent(null);
|
|
186
448
|
return { handled: true, outcome: { notice: "conversation cleared — context reset" } };
|
|
187
449
|
}
|
|
450
|
+
const custom = discoverCustomCommands(host.config.workdir);
|
|
451
|
+
const customMatch = custom.find((c) => `/${c.name.toLowerCase()}` === cmd || c.aliases.some((a) => `/${a.toLowerCase().replace(/^\//, "")}` === cmd));
|
|
452
|
+
if (customMatch) {
|
|
453
|
+
if (customMatch.exec === "shell") {
|
|
454
|
+
notice(`custom command "${customMatch.name}" declares shell execution; run it explicitly through a reviewed agent task`, "warn");
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
const prompt = runCustomCommand(customMatch, arg);
|
|
458
|
+
notice(`custom command "${customMatch.name}"`, "info");
|
|
459
|
+
return { handled: true, outcome: { task: prompt } };
|
|
460
|
+
}
|
|
461
|
+
return { handled: true, outcome: {} };
|
|
462
|
+
}
|
|
188
463
|
const entry = [...extra, ...coreCommands()].find((c) => c.name === cmd);
|
|
189
464
|
if (!entry) {
|
|
190
465
|
notice(`unknown command ${cmd} — try /help`, "error");
|
package/dist/tui/commands.js
CHANGED
|
@@ -3,29 +3,45 @@
|
|
|
3
3
|
* (src/tui/app.tsx). Pure parsing/formatting only — each frontend executes
|
|
4
4
|
* the command against its own context, so both stay in sync on the surface.
|
|
5
5
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
{
|
|
9
|
-
{
|
|
10
|
-
{
|
|
11
|
-
{
|
|
12
|
-
{
|
|
13
|
-
{
|
|
14
|
-
{
|
|
15
|
-
{
|
|
16
|
-
{
|
|
17
|
-
{
|
|
18
|
-
{
|
|
19
|
-
{
|
|
20
|
-
{
|
|
21
|
-
{
|
|
6
|
+
/** Built-in commands — the single catalog both help and completion read. */
|
|
7
|
+
export const BUILTIN_COMMANDS = [
|
|
8
|
+
{ name: "/help", aliases: [], description: "show this command list", usage: "/help", visibility: "public", source: "builtin" },
|
|
9
|
+
{ name: "/plan", aliases: [], description: "show the current plan — /plan done|start <id|n> to mark steps", usage: "/plan [done|start <id|n|text>]", visibility: "public", source: "builtin" },
|
|
10
|
+
{ name: "/agent", aliases: [], description: "list selectable agents — /agent <name> to switch (idle only)", usage: "/agent [name]", visibility: "public", source: "builtin" },
|
|
11
|
+
{ name: "/init", aliases: [], description: "create an AGENTS.md template in this project", usage: "/init", visibility: "public", source: "builtin" },
|
|
12
|
+
{ name: "/sessions", aliases: [], description: "list saved sessions — /sessions resume <id|last>", usage: "/sessions [resume <id|last>]", visibility: "public", source: "builtin" },
|
|
13
|
+
{ name: "/diff", aliases: [], description: "show diffs: /diff turn | session | branch", usage: "/diff [turn|session|branch]", visibility: "tui-only", source: "builtin" },
|
|
14
|
+
{ name: "/theme", aliases: [], description: "switch palette: /theme <name> — /theme list (mission|paper|mono|light|high-contrast|solarized)", usage: "/theme [name|list]", visibility: "tui-only", source: "builtin" },
|
|
15
|
+
{ name: "/clear", aliases: [], description: "clear the conversation and reset agent context", usage: "/clear", visibility: "public", source: "builtin" },
|
|
16
|
+
{ name: "/compact", aliases: [], description: "summarize older history now (manual compaction)", usage: "/compact", visibility: "public", source: "builtin" },
|
|
17
|
+
{ name: "/cost", aliases: [], description: "token usage of the current session", usage: "/cost", visibility: "public", source: "builtin" },
|
|
18
|
+
{ name: "/model", aliases: [], description: "show the active model & provider", usage: "/model [provider/model]", visibility: "public", source: "builtin" },
|
|
19
|
+
{ name: "/skills", aliases: [], description: "list discovered skills (SKILL.md)", usage: "/skills", visibility: "public", source: "builtin" },
|
|
20
|
+
{ name: "/skill", aliases: [], description: "load a skill into context: /skill <name>", usage: "/skill <name>", visibility: "public", source: "builtin" },
|
|
21
|
+
{ name: "/mcp", aliases: [], description: "MCP servers & tools: /mcp [server] [tool] [json|text]", usage: "/mcp [server [tool [json]]]", visibility: "public", source: "builtin" },
|
|
22
|
+
{ name: "/review", aliases: [], description: "prompt gate: rewrite + user approval — /review <prompt>", usage: "/review <prompt>", visibility: "tui-only", source: "builtin" },
|
|
23
|
+
{ name: "/permission", aliases: [], description: "view or switch permission mode: /permission <auto|ask|readonly>", usage: "/permission [auto|ask|readonly]", visibility: "public", source: "builtin" },
|
|
24
|
+
{ name: "/undo", aliases: [], description: "revert files: /undo [files|edit] — edit also trims the turn back for re-send", usage: "/undo [files|edit]", visibility: "tui-only", source: "builtin" },
|
|
25
|
+
{ name: "/commands", aliases: [], description: "list all commands (builtin + custom) — /commands <name> to inspect", usage: "/commands [name]", visibility: "public", source: "builtin" },
|
|
26
|
+
{ name: "/export", aliases: [], description: "export session transcript: /export [md|json]", usage: "/export [md|json]", visibility: "public", source: "builtin" },
|
|
27
|
+
{ name: "/search", aliases: [], description: "search transcript: /search <query>", usage: "/search <query>", visibility: "public", source: "builtin" },
|
|
28
|
+
{ name: "/exit", aliases: ["/quit"], description: "quit (aliases: /quit, exit, quit)", usage: "/exit", visibility: "public", source: "builtin" },
|
|
22
29
|
];
|
|
30
|
+
/** Back-compat view used by completion + /help — derived from BUILTIN_COMMANDS. */
|
|
31
|
+
export const SLASH_COMMANDS = BUILTIN_COMMANDS
|
|
32
|
+
.filter((c) => c.visibility !== "hidden")
|
|
33
|
+
.map((c) => ({ cmd: c.name, desc: c.description }));
|
|
34
|
+
/** Resolve a command name or alias → canonical CommandDef (case-insensitive). */
|
|
35
|
+
export function resolveCommandDef(name) {
|
|
36
|
+
const n = name.toLowerCase();
|
|
37
|
+
return BUILTIN_COMMANDS.find((c) => c.name === n || c.aliases.includes(n));
|
|
38
|
+
}
|
|
23
39
|
/** Parse "/cmd arg…"; returns null when the input is not a slash command. */
|
|
24
40
|
export function parseSlashCommand(input) {
|
|
25
|
-
const m = /^\/([a-zA-Z][a-zA-Z0-9]*)(?:\s+(
|
|
41
|
+
const m = /^\/([a-zA-Z][a-zA-Z0-9-]*)(?:\s+([\s\S]*\S))?\s*$/.exec(input.trim());
|
|
26
42
|
if (!m)
|
|
27
43
|
return null;
|
|
28
|
-
return { cmd: "/" + m[1].toLowerCase(), arg: m[2] ?? "" };
|
|
44
|
+
return { cmd: resolveCommandDef("/" + m[1].toLowerCase())?.name ?? "/" + m[1].toLowerCase(), arg: m[2] ?? "" };
|
|
29
45
|
}
|
|
30
46
|
/**
|
|
31
47
|
* Tab completion for a partial input starting with "/".
|
|
@@ -53,44 +69,215 @@ export function completeSlashCommand(input) {
|
|
|
53
69
|
}
|
|
54
70
|
return { completed: prefix + (matches.length === 1 ? " " : ""), candidates: matches };
|
|
55
71
|
}
|
|
72
|
+
import { readdirSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
73
|
+
import { homedir } from "node:os";
|
|
74
|
+
import { join, resolve, basename, extname } from "node:path";
|
|
56
75
|
import { suggest } from "./at-complete.js";
|
|
76
|
+
/** Substitute $ARGUMENTS, $1..$n, @file references in the body. */
|
|
77
|
+
function substituteTemplate(body, argStr) {
|
|
78
|
+
const args = argStr.trim().split(/\s+/).filter(Boolean);
|
|
79
|
+
let out = body;
|
|
80
|
+
out = out.replace(/\$ARGUMENTS/g, argStr.trim());
|
|
81
|
+
for (let i = 0; i < args.length; i++) {
|
|
82
|
+
out = out.replace(new RegExp(`\\$${i + 1}`, "g"), args[i]);
|
|
83
|
+
}
|
|
84
|
+
out = out.replace(/\$ARGUMENTS/g, argStr.trim()); // repeat in case $ARGUMENTS contains $1
|
|
85
|
+
// @file references — leave as-is (agent interprets them).
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Discover custom commands from ~/.scoder/commands/*.md + .scoder/commands/*.md. */
|
|
89
|
+
export function discoverCustomCommands(workdir) {
|
|
90
|
+
const dirs = [resolve(workdir, ".scoder", "commands"), join(homedir(), ".scoder", "commands")].filter((d) => existsSync(d));
|
|
91
|
+
const results = [];
|
|
92
|
+
for (const dir of dirs) {
|
|
93
|
+
let files;
|
|
94
|
+
try {
|
|
95
|
+
files = readdirSync(dir);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
if (extname(file) !== ".md")
|
|
102
|
+
continue;
|
|
103
|
+
const filePath = join(dir, file);
|
|
104
|
+
let stat;
|
|
105
|
+
try {
|
|
106
|
+
stat = statSync(filePath);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!stat.isFile())
|
|
112
|
+
continue;
|
|
113
|
+
const content = readFileSync(filePath, "utf8");
|
|
114
|
+
const parsed = parseCustomCommandFile(basename(file, ".md"), filePath, content, dir === resolve(workdir, ".scoder", "commands") ? "project" : "global");
|
|
115
|
+
if (parsed)
|
|
116
|
+
results.push(parsed);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return results;
|
|
120
|
+
}
|
|
121
|
+
/** Parse a custom command .md file. Returns null on error. */
|
|
122
|
+
function parseCustomCommandFile(defaultName, filePath, content, source) {
|
|
123
|
+
const frontMatch = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(content.trim());
|
|
124
|
+
if (!frontMatch) {
|
|
125
|
+
// No frontmatter: body is the entire file, name is filename.
|
|
126
|
+
return {
|
|
127
|
+
name: defaultName,
|
|
128
|
+
description: content.trim().slice(0, 80),
|
|
129
|
+
visibility: "public",
|
|
130
|
+
aliases: [],
|
|
131
|
+
body: content.trim(),
|
|
132
|
+
source,
|
|
133
|
+
filePath,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const frontRaw = frontMatch[1];
|
|
137
|
+
const body = frontMatch[2].trim();
|
|
138
|
+
// Parse YAML frontmatter manually (no external dependency).
|
|
139
|
+
const front = {};
|
|
140
|
+
for (const line of frontRaw.split("\n")) {
|
|
141
|
+
const m = /^(\w+):\s*(.*)$/.exec(line.trim());
|
|
142
|
+
if (m) {
|
|
143
|
+
const [, key, rawVal] = m;
|
|
144
|
+
front[key] = rawVal.startsWith('"') && rawVal.endsWith('"')
|
|
145
|
+
? rawVal.slice(1, -1)
|
|
146
|
+
: rawVal;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
name: String(front.name ?? defaultName),
|
|
151
|
+
description: String(front.description ?? body.split("\n")[0].slice(0, 80)),
|
|
152
|
+
visibility: front.visibility ?? "public",
|
|
153
|
+
aliases: parseFrontList(front.aliases),
|
|
154
|
+
exec: front.exec ?? "prompt",
|
|
155
|
+
agent: front.agent ? String(front.agent) : undefined,
|
|
156
|
+
model: front.model ? String(front.model) : undefined,
|
|
157
|
+
shell: front.shell ? String(front.shell) : undefined,
|
|
158
|
+
args: front.args
|
|
159
|
+
? (Array.isArray(front.args)
|
|
160
|
+
? front.args.map(String)
|
|
161
|
+
: String(front.args)
|
|
162
|
+
// YAML flow array "[a, b]" → strip brackets, split on comma.
|
|
163
|
+
.replace(/^\[|\]$/g, "")
|
|
164
|
+
.split(",")
|
|
165
|
+
.map((s) => s.trim())
|
|
166
|
+
.filter(Boolean))
|
|
167
|
+
: undefined,
|
|
168
|
+
body,
|
|
169
|
+
source,
|
|
170
|
+
filePath,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/** Parse the small YAML list subset used by command aliases/args metadata. */
|
|
174
|
+
function parseFrontList(value) {
|
|
175
|
+
if (Array.isArray(value))
|
|
176
|
+
return value.map(String).map((s) => s.trim()).filter(Boolean);
|
|
177
|
+
if (typeof value !== "string")
|
|
178
|
+
return [];
|
|
179
|
+
const raw = value.trim().replace(/^\[|\]$/g, "");
|
|
180
|
+
return raw.split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
|
|
181
|
+
}
|
|
182
|
+
/** Substitute + execute a custom command; returns the prompt text to inject. */
|
|
183
|
+
export function runCustomCommand(cmd, argStr) {
|
|
184
|
+
return substituteTemplate(cmd.body, argStr);
|
|
185
|
+
}
|
|
57
186
|
/** Build providers from runtime context (called once per keystroke burst). */
|
|
58
187
|
export function completionProviders(sources) {
|
|
59
|
-
|
|
188
|
+
const limit = sources.limit ?? 8;
|
|
189
|
+
const skills = sources.skills ?? [];
|
|
190
|
+
const agents = sources.agents ?? [];
|
|
191
|
+
const files = sources.files ?? [];
|
|
192
|
+
const commands = sources.commands ?? SLASH_COMMANDS;
|
|
193
|
+
const out = [
|
|
60
194
|
{
|
|
61
195
|
id: "commands",
|
|
62
196
|
match: (t) => t.startsWith("/") && !t.slice(1).includes(" "),
|
|
63
|
-
candidates: (t) =>
|
|
64
|
-
|
|
197
|
+
candidates: (t) => {
|
|
198
|
+
const q = t.toLowerCase();
|
|
199
|
+
return commands
|
|
200
|
+
.filter((c) => c.cmd.toLowerCase().startsWith(q) || c.cmd.toLowerCase().includes(q.slice(1)))
|
|
201
|
+
.slice(0, limit)
|
|
202
|
+
.map((c) => ({ label: c.cmd, desc: c.desc, insert: c.cmd + " " }));
|
|
203
|
+
},
|
|
65
204
|
},
|
|
66
|
-
|
|
205
|
+
];
|
|
206
|
+
if (files.length > 0) {
|
|
207
|
+
out.push({
|
|
67
208
|
id: "files",
|
|
68
209
|
match: (t) => t.startsWith("@") && t.length > 1,
|
|
69
|
-
candidates: (t) => suggest(
|
|
70
|
-
}
|
|
71
|
-
|
|
210
|
+
candidates: (t) => suggest(files, t.slice(1), limit).map((f) => ({ label: f, insert: "@" + f + (f.endsWith("/") ? "" : " ") })),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (skills.length > 0) {
|
|
214
|
+
out.push({
|
|
72
215
|
id: "skills",
|
|
73
216
|
match: (t) => t.startsWith("@") && t.length > 1,
|
|
74
217
|
candidates: (t) => {
|
|
75
218
|
const q = t.slice(1).toLowerCase();
|
|
76
|
-
return
|
|
77
|
-
.filter((s) => s.toLowerCase().includes(q))
|
|
78
|
-
.slice(0, 4)
|
|
79
|
-
.map((s) => ({ label: `@${s}`, desc: "skill", insert: `@${s} ` }));
|
|
219
|
+
return skills.filter((s) => s.toLowerCase().includes(q)).slice(0, limit).map((s) => ({ label: `@${s}`, desc: "skill", insert: `@${s} ` }));
|
|
80
220
|
},
|
|
81
|
-
}
|
|
82
|
-
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (agents.length > 0) {
|
|
224
|
+
out.push({
|
|
83
225
|
id: "agents",
|
|
84
226
|
match: (t) => t.startsWith("@") && t.length > 1,
|
|
85
227
|
candidates: (t) => {
|
|
86
228
|
const q = t.slice(1).toLowerCase();
|
|
87
|
-
return
|
|
88
|
-
.filter((a) => a.toLowerCase().includes(q))
|
|
89
|
-
.slice(0, 4)
|
|
90
|
-
.map((a) => ({ label: `@${a}`, desc: "agent", insert: `@${a} ` }));
|
|
229
|
+
return agents.filter((a) => a.toLowerCase().includes(q)).slice(0, limit).map((a) => ({ label: `@${a}`, desc: "agent", insert: `@${a} ` }));
|
|
91
230
|
},
|
|
92
|
-
}
|
|
93
|
-
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (sources.mcpServers && sources.mcpServers.length > 0) {
|
|
234
|
+
out.push({
|
|
235
|
+
id: "mcp",
|
|
236
|
+
match: (t) => t.startsWith("@") && t.length > 1,
|
|
237
|
+
candidates: (t) => {
|
|
238
|
+
const q = t.slice(1).toLowerCase();
|
|
239
|
+
const res = [];
|
|
240
|
+
for (const s of sources.mcpServers ?? []) {
|
|
241
|
+
if (s.name.toLowerCase().includes(q))
|
|
242
|
+
res.push({ label: `@${s.name}`, desc: "mcp server", insert: `@${s.name} ` });
|
|
243
|
+
for (const tool of s.tools ?? []) {
|
|
244
|
+
if (tool.toLowerCase().includes(q))
|
|
245
|
+
res.push({ label: `@${tool}`, desc: `mcp:${s.name}`, insert: `@${tool} ` });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return res.slice(0, limit);
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (sources.sessions && sources.sessions.length > 0) {
|
|
253
|
+
out.push({
|
|
254
|
+
id: "sessions",
|
|
255
|
+
match: (t) => t.startsWith("@") && t.length > 1,
|
|
256
|
+
candidates: (t) => {
|
|
257
|
+
const q = t.slice(1).toLowerCase();
|
|
258
|
+
return (sources.sessions ?? []).filter((s) => s.toLowerCase().includes(q)).slice(0, limit).map((s) => ({ label: `@${s}`, desc: "session", insert: `@${s} ` }));
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
/** Fuzzy score helper (Completion v2 ranking). Higher = better. */
|
|
265
|
+
export function fuzzyScore(query, target) {
|
|
266
|
+
const q = query.toLowerCase();
|
|
267
|
+
const t = target.toLowerCase();
|
|
268
|
+
if (!q)
|
|
269
|
+
return 0;
|
|
270
|
+
if (t.startsWith(q))
|
|
271
|
+
return 1000 - (t.length - q.length);
|
|
272
|
+
const idx = t.indexOf(q);
|
|
273
|
+
if (idx >= 0)
|
|
274
|
+
return 500 - idx;
|
|
275
|
+
// subsequence
|
|
276
|
+
let qi = 0;
|
|
277
|
+
for (let i = 0; i < t.length && qi < q.length; i++)
|
|
278
|
+
if (t[i] === q[qi])
|
|
279
|
+
qi++;
|
|
280
|
+
return qi === q.length ? 100 : 0;
|
|
94
281
|
}
|
|
95
282
|
/**
|
|
96
283
|
* Ghost text: when the current input has exactly ONE candidate across all
|