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.
@@ -1,6 +1,11 @@
1
1
  import { listSessions, resolveSessionId } from "../session.js";
2
- import { discoverSkills } from "../skills.js";
3
- import { formatSlashHelp, parseMcpExec } from "./commands.js";
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) => `${srv.connected ? "✓" : "·"} ${srv.name} — ${srv.command}` +
34
- (srv.connected ? ` (${srv.tools.length} tools)\n` + srv.tools.map((t) => ` mcp__${srv.name}__${t.name} ${t.description ?? ""}`).join("\n") : " (not connected)"))
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
  /**
@@ -55,11 +82,73 @@ export function coreCommands() {
55
82
  notice(formatUsage(host.agent()?.getUsage() ?? { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 }));
56
83
  },
57
84
  },
85
+ {
86
+ name: "/compact",
87
+ description: "summarize older history now (manual compaction)",
88
+ async run(_slash, host, notice) {
89
+ const agent = host.agent();
90
+ if (!agent)
91
+ return notice("agent not ready — start a task first", "warn");
92
+ if (host.isRunning?.())
93
+ return notice("agent is running — wait or Esc first", "warn");
94
+ const r = await agent.compactHistory();
95
+ notice(r.message, r.ok ? "info" : "warn");
96
+ },
97
+ },
58
98
  {
59
99
  name: "/skills",
60
- description: "list discovered skills (SKILL.md)",
61
- run(_slash, host, notice) {
62
- notice(formatSkills(host.agent()?.getSkills() ?? discoverSkills(host.config.workdir)));
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");
63
152
  },
64
153
  },
65
154
  {
@@ -122,10 +211,86 @@ export function coreCommands() {
122
211
  return;
123
212
  }
124
213
  host.agent()?.setPermissionMode(mode);
214
+ host.updateConfig?.({ permissionMode: mode });
125
215
  host.config = withPermissionMode(host.config, mode);
126
216
  notice(`permission mode → ${mode}`);
127
217
  },
128
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
+ },
129
294
  {
130
295
  name: "/sessions",
131
296
  description: "list saved sessions — /sessions resume <id|last>",
@@ -147,8 +312,115 @@ export function coreCommands() {
147
312
  notice(formatSessions(listSessions(host.config.workdir)));
148
313
  },
149
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
+ },
150
418
  ];
151
419
  }
420
+ /** Merged catalog of builtin command metadata — /help & completion read this. */
421
+ export function commandCatalog() {
422
+ return BUILTIN_COMMANDS.map((def) => ({ ...def }));
423
+ }
152
424
  /** Clone config với permissionMode mới (host.config thay thế được). */
153
425
  function withPermissionMode(cfg, mode) {
154
426
  return { ...cfg, permissionMode: mode };
@@ -159,11 +431,14 @@ function withPermissionMode(cfg, mode) {
159
431
  * /diff; REPL: không có) TRƯỚC khi gọi hàm này.
160
432
  */
161
433
  export async function dispatchCommand(input, host, notice, extra = []) {
162
- const m = /^\/([a-zA-Z][a-zA-Z0-9]*)(?:\s+(.*\S))?\s*$/.exec(input.trim());
434
+ const m = /^\/([a-zA-Z][a-zA-Z0-9-]*)(?:\s+([\s\S]*\S))?\s*$/.exec(input.trim());
163
435
  if (!m)
164
436
  return { handled: false, outcome: null };
165
- const cmd = "/" + m[1].toLowerCase();
437
+ const raw = "/" + m[1].toLowerCase();
166
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;
167
442
  const slash = { cmd, arg };
168
443
  if (cmd === "/exit" || cmd === "/quit")
169
444
  return { handled: true, outcome: { quit: true } };
@@ -172,6 +447,19 @@ export async function dispatchCommand(input, host, notice, extra = []) {
172
447
  host.replaceAgent(null);
173
448
  return { handled: true, outcome: { notice: "conversation cleared — context reset" } };
174
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
+ }
175
463
  const entry = [...extra, ...coreCommands()].find((c) => c.name === cmd);
176
464
  if (!entry) {
177
465
  notice(`unknown command ${cmd} — try /help`, "error");