s-app-scoder-agent 0.9.3 → 0.9.6
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 +615 -638
- package/dist/{guardrails.js → agent/guardrails.js} +1 -1
- package/dist/agent/interaction.js +70 -0
- package/dist/agent/mcp-bridge.js +136 -0
- package/dist/agent/prompt-context.js +83 -0
- package/dist/agent/run-loop.js +318 -0
- package/dist/agent/session-persistence.js +61 -0
- package/dist/agent/tool-executor.js +158 -0
- package/dist/agent/types.js +88 -0
- package/dist/agent/verification.js +99 -0
- package/dist/agent.js +239 -1002
- package/dist/cli/args.js +230 -0
- package/dist/cli/init.js +79 -0
- package/dist/cli/main.js +211 -0
- package/dist/cli/mcp-command.js +185 -0
- package/dist/cli/repl.js +77 -0
- package/dist/cli/run-once.js +26 -0
- package/dist/{tui/commands.js → commands/definitions.js} +6 -16
- package/dist/commands/registry.js +260 -0
- package/dist/{config.js → config/config.js} +1 -1
- package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
- package/dist/{prompt.js → context/prompt.js} +111 -9
- package/dist/{skills.js → context/skills.js} +41 -2
- package/dist/index.js +5 -798
- package/dist/io/console-reporter.js +24 -0
- package/dist/io/reporter.js +1 -0
- package/dist/mcp/connection.js +227 -0
- package/dist/mcp/env.js +8 -0
- package/dist/{mcp.js → mcp/manager.js} +3 -515
- package/dist/mcp/sse.js +28 -0
- package/dist/mcp/transport-http.js +140 -0
- package/dist/mcp/transport-stdio.js +98 -0
- package/dist/mcp/types.js +11 -0
- package/dist/media/image.js +149 -0
- package/dist/{server.js → server/server.js} +1 -1
- package/dist/{cost.js → session/cost.js} +1 -1
- package/dist/{session.js → session/session.js} +1 -1
- package/dist/{version.js → shared/version.js} +1 -1
- package/dist/tools/apply-patch.js +62 -0
- package/dist/tools/edit-file.js +29 -0
- package/dist/tools/file-edit-common.js +124 -0
- package/dist/tools/list-files.js +64 -0
- package/dist/tools/path-safety.js +75 -0
- package/dist/tools/read-file.js +64 -0
- package/dist/tools/registry.js +297 -0
- package/dist/tools/run-command.js +192 -0
- package/dist/tools/schemas.js +8 -0
- package/dist/tools/search.js +237 -0
- package/dist/tools/task-tool.js +48 -0
- package/dist/tools/types.js +1 -0
- package/dist/tools/write-file.js +34 -0
- package/dist/tui/app.js +59 -532
- package/dist/tui/ask-user-modal.js +2 -1
- package/dist/tui/at-complete.js +2 -93
- package/dist/tui/charset.js +41 -0
- package/dist/tui/chat-views.js +72 -16
- package/dist/tui/chrome.js +11 -5
- package/dist/tui/hooks/use-command-host.js +26 -0
- package/dist/tui/hooks/use-event-queue.js +69 -0
- package/dist/tui/hooks/use-keybinds.js +87 -0
- package/dist/tui/hooks/use-scroll.js +38 -0
- package/dist/tui/hooks/use-session-resume.js +47 -0
- package/dist/tui/hooks/use-submit.js +106 -0
- package/dist/tui/image.js +2 -149
- package/dist/tui/input.js +1 -1
- package/dist/tui/markdown/ast.js +5 -0
- package/dist/tui/markdown/diagram.js +239 -0
- package/dist/tui/markdown/inline-render.js +24 -0
- package/dist/tui/markdown/inline.js +93 -0
- package/dist/tui/markdown/parse.js +139 -0
- package/dist/tui/markdown/render.js +146 -0
- package/dist/tui/markdown/table.js +106 -0
- package/dist/tui/markdown.js +32 -379
- package/dist/tui/permission-modal.js +2 -1
- package/dist/tui/selectors.js +157 -0
- package/dist/tui/session-picker.js +4 -3
- package/dist/tui/sidebar.js +130 -15
- package/dist/tui/state.js +47 -118
- package/dist/tui/text-width.js +139 -0
- package/dist/tui/theme.js +7 -2
- package/dist/{build.js → workspace/build.js} +1 -1
- package/dist/workspace/file-index.js +93 -0
- package/package.json +5 -1
- package/dist/tools.js +0 -1223
- package/dist/tui/command-registry.js +0 -477
- package/dist/tui/review.js +0 -64
- package/dist/tui/transcript.js +0 -145
- /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
- /package/dist/{agents.js → context/agents.js} +0 -0
- /package/dist/{compaction.js → context/compaction.js} +0 -0
- /package/dist/{context.js → context/context.js} +0 -0
- /package/dist/{memory.js → context/memory.js} +0 -0
- /package/dist/{repo-map.js → context/repo-map.js} +0 -0
- /package/dist/{ui.js → io/terminal.js} +0 -0
- /package/dist/{llm.js → llm/llm.js} +0 -0
- /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
- /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
- /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
- /package/dist/{retry.js → llm/retry.js} +0 -0
- /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
- /package/dist/{permissions.js → security/permissions.js} +0 -0
- /package/dist/{sandbox.js → security/sandbox.js} +0 -0
- /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
- /package/dist/{logger.js → session/logger.js} +0 -0
- /package/dist/{steering.js → session/steering.js} +0 -0
- /package/dist/{constants.js → shared/constants.js} +0 -0
- /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
- /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
- /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
- /package/dist/{diff.js → workspace/diff.js} +0 -0
- /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
- /package/dist/{watch.js → workspace/watch.js} +0 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
export async function runMcpCommand(cfg, cmd, restArgs) {
|
|
2
|
+
const { McpManager } = await import("../mcp/manager.js");
|
|
3
|
+
const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
|
|
4
|
+
const { join, dirname } = await import("node:path");
|
|
5
|
+
const { mkdirSync } = await import("node:fs");
|
|
6
|
+
const rcPath = join(cfg.workdir, ".scoderrc.json");
|
|
7
|
+
// Load existing mcpServers from workdir rc.
|
|
8
|
+
const existing = {};
|
|
9
|
+
if (existsSync(rcPath)) {
|
|
10
|
+
try {
|
|
11
|
+
Object.assign(existing, JSON.parse(readFileSync(rcPath, "utf8")).mcpServers ?? {});
|
|
12
|
+
}
|
|
13
|
+
catch { /* ignore */ }
|
|
14
|
+
}
|
|
15
|
+
if (cmd === "list") {
|
|
16
|
+
const servers = Object.entries(cfg.mcpServers ?? existing);
|
|
17
|
+
if (servers.length === 0) {
|
|
18
|
+
console.log("no MCP servers configured — add mcpServers to .scoderrc.json");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
for (const [name, cfg_srv] of servers) {
|
|
22
|
+
const trust = cfg_srv.trust ?? "auto";
|
|
23
|
+
const enabled = cfg_srv.enabled === false ? " (disabled)" : "";
|
|
24
|
+
const target = cfg_srv.url ?? cfg_srv.command ?? "?";
|
|
25
|
+
console.log(`${name}: ${target}${trust !== "auto" ? ` [${trust}]` : ""}${enabled}`);
|
|
26
|
+
}
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (cmd === "add") {
|
|
30
|
+
// scoder mcp add <name> --command "..." [--url "..."] [--trust auto|ask|readonly|deny]
|
|
31
|
+
const name = restArgs[0];
|
|
32
|
+
if (!name) {
|
|
33
|
+
console.error("usage: scoder mcp add <name> --command '...' [--url '...'] [--trust readonly]");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const opts = {};
|
|
37
|
+
for (let i = 1; i < restArgs.length; i++) {
|
|
38
|
+
if (restArgs[i] === "--command" || restArgs[i] === "-c")
|
|
39
|
+
opts.command = restArgs[++i];
|
|
40
|
+
else if (restArgs[i] === "--url" || restArgs[i] === "-u")
|
|
41
|
+
opts.url = restArgs[++i];
|
|
42
|
+
else if (restArgs[i] === "--trust" || restArgs[i] === "-t")
|
|
43
|
+
opts.trust = restArgs[++i];
|
|
44
|
+
else if (restArgs[i] === "--args" || restArgs[i] === "-a")
|
|
45
|
+
opts.args = restArgs[++i];
|
|
46
|
+
}
|
|
47
|
+
if (!opts.command && !opts.url) {
|
|
48
|
+
console.error("error: --command or --url required");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const newEntry = { ...(opts.command ? { command: opts.command } : {}), ...(opts.url ? { url: opts.url } : {}), ...(opts.args ? { args: opts.args.split(" ") } : {}), ...(opts.trust ? { trust: opts.trust } : {}) };
|
|
52
|
+
const allServers = { ...existing, [name]: newEntry };
|
|
53
|
+
const rc = existsSync(rcPath) ? JSON.parse(readFileSync(rcPath, "utf8")) : {};
|
|
54
|
+
mkdirSync(dirname(rcPath) || ".", { recursive: true });
|
|
55
|
+
writeFileSync(rcPath, JSON.stringify({ ...rc, mcpServers: allServers }, null, 2) + "\n", "utf8");
|
|
56
|
+
console.log(`MCP server "${name}" added to ${rcPath}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (cmd === "remove") {
|
|
60
|
+
const name = restArgs[0];
|
|
61
|
+
if (!name) {
|
|
62
|
+
console.error("usage: scoder mcp remove <name>");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!existing[name] && !(cfg.mcpServers ?? {})[name]) {
|
|
66
|
+
console.error(`MCP server "${name}" not found in config`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const allServers = { ...existing };
|
|
70
|
+
delete allServers[name];
|
|
71
|
+
const rc = existsSync(rcPath) ? JSON.parse(readFileSync(rcPath, "utf8")) : {};
|
|
72
|
+
mkdirSync(dirname(rcPath) || ".", { recursive: true });
|
|
73
|
+
writeFileSync(rcPath, JSON.stringify({ ...rc, mcpServers: allServers }, null, 2) + "\n", "utf8");
|
|
74
|
+
console.log(`MCP server "${name}" removed from ${rcPath}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (cmd === "test") {
|
|
78
|
+
const name = restArgs[0];
|
|
79
|
+
if (!name) {
|
|
80
|
+
console.error("usage: scoder mcp test <name>");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const srvCfg = (cfg.mcpServers ?? {})[name] ?? existing[name];
|
|
84
|
+
if (!srvCfg) {
|
|
85
|
+
console.error(`MCP server "${name}" not found`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (srvCfg.enabled === false) {
|
|
89
|
+
console.log(`✘ "${name}" is disabled (enabled:false)`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (srvCfg.trust === "readonly") {
|
|
93
|
+
console.log(`✘ "${name}" is trust:readonly — not spawning`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
console.log(`connecting to "${name}"...`);
|
|
97
|
+
const m = new McpManager({ [name]: srvCfg });
|
|
98
|
+
try {
|
|
99
|
+
await m.connectAll();
|
|
100
|
+
const states = m.serverStates();
|
|
101
|
+
const s = states[0];
|
|
102
|
+
if (s?.state === "ready") {
|
|
103
|
+
console.log(`✓ "${name}" connected — ${s.toolCount} tools`);
|
|
104
|
+
console.log(` serverInfo: ${s.serverInfo.name ?? "?"} v${s.serverInfo.version ?? "?"}`);
|
|
105
|
+
console.log(` capabilities: ${Object.keys(s.capabilities).join(", ") || "(none)"}`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
console.log(`✘ "${name}" failed to connect (state: ${s?.state})`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
m.killAll();
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (cmd === "import") {
|
|
117
|
+
// scoder mcp import [path] [--dry-run] [--force]
|
|
118
|
+
const { loadConfigFile, discoverConfigs, mergeConfigs } = await import("../mcp/mcp-import.js");
|
|
119
|
+
const dryRun = restArgs.includes("--dry-run");
|
|
120
|
+
const force = restArgs.includes("--force");
|
|
121
|
+
const paths = restArgs.filter((a) => !a.startsWith("--"));
|
|
122
|
+
let discovered = [];
|
|
123
|
+
try {
|
|
124
|
+
discovered = discoverConfigs();
|
|
125
|
+
}
|
|
126
|
+
catch { /* ignore */ }
|
|
127
|
+
if (paths.length === 0 && discovered.length === 0) {
|
|
128
|
+
console.log("No config files found. Provide a path or ensure one of these exists:");
|
|
129
|
+
console.log(" ~/.claude/config.json — Claude Desktop MCP servers");
|
|
130
|
+
console.log(" opencode.json — OpenCode MCP config");
|
|
131
|
+
console.log(" .mcp.json — standard MCP server list");
|
|
132
|
+
console.log("\nUsage: scoder mcp import [path] [--dry-run] [--force]");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Auto-discover when no path given
|
|
136
|
+
const toCheck = paths.length > 0 ? paths : discovered.map((d) => d.source);
|
|
137
|
+
if (paths.length === 0 && discovered.length > 0) {
|
|
138
|
+
console.log("Auto-discovered MCP config files:");
|
|
139
|
+
for (const d of discovered) {
|
|
140
|
+
console.log(` ${d.format}: ${d.source} (${d.serverCount} server${d.serverCount === 1 ? "" : "s"})`);
|
|
141
|
+
}
|
|
142
|
+
console.log();
|
|
143
|
+
}
|
|
144
|
+
const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
|
|
145
|
+
const { mkdirSync } = await import("node:fs");
|
|
146
|
+
let totalAdded = 0;
|
|
147
|
+
let totalSkipped = 0;
|
|
148
|
+
for (const srcPath of toCheck) {
|
|
149
|
+
const servers = loadConfigFile(srcPath);
|
|
150
|
+
if (!servers) {
|
|
151
|
+
console.log(` skip: ${srcPath} not found`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const { added, skipped } = mergeConfigs(existing, servers, { force, dryRun });
|
|
155
|
+
totalAdded += added.length;
|
|
156
|
+
totalSkipped += skipped.length;
|
|
157
|
+
console.log(`${srcPath}:`);
|
|
158
|
+
for (const s of added) {
|
|
159
|
+
const target = s.config.url ?? s.config.command ?? "?";
|
|
160
|
+
console.log(` + ${s.name}: ${target} (from ${s.originalKey})`);
|
|
161
|
+
}
|
|
162
|
+
for (const s of skipped) {
|
|
163
|
+
console.log(` ~ ${s.name}: skipped (already exists; use --force to overwrite)`);
|
|
164
|
+
}
|
|
165
|
+
if (added.length === 0 && skipped.length === 0) {
|
|
166
|
+
console.log(` (no servers found)`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (dryRun) {
|
|
170
|
+
console.log(`\n[DRY RUN] Would import ${totalAdded} server${totalAdded === 1 ? "" : "s"} (${totalSkipped} skipped).`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (totalAdded > 0) {
|
|
174
|
+
const rc = existsSync(rcPath) ? JSON.parse(readFileSync(rcPath, "utf8")) : {};
|
|
175
|
+
mkdirSync(dirname(rcPath) || ".", { recursive: true });
|
|
176
|
+
writeFileSync(rcPath, JSON.stringify({ ...rc, mcpServers: existing }, null, 2) + "\n", "utf8");
|
|
177
|
+
console.log(`\nImported ${totalAdded} server${totalAdded === 1 ? "" : "s"} into ${rcPath} (${totalSkipped} skipped).`);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
console.log(`\nNothing to import.`);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
console.error(`unknown mcp command: ${cmd}`);
|
|
185
|
+
}
|
package/dist/cli/repl.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Classic interactive REPL (`scoder` with no task). */
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { Agent } from "../agent.js";
|
|
5
|
+
import { dispatchCommand } from "../commands/registry.js";
|
|
6
|
+
import * as ui from "../io/terminal.js";
|
|
7
|
+
export async function repl(cfg) {
|
|
8
|
+
ui.header(`scoder interactive — model: ${cfg.model}`);
|
|
9
|
+
ui.info('Type a task and press Enter. "exit" or Ctrl+D to quit.');
|
|
10
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "you> " });
|
|
11
|
+
// One shared session for the whole REPL conversation (plan P0.5 §4):
|
|
12
|
+
// every input is a follow-up turn, context carries over, saved continuously.
|
|
13
|
+
// /clear swaps in a fresh Agent (the /clear branch reassigns this).
|
|
14
|
+
let agent = new Agent(cfg);
|
|
15
|
+
process.on("SIGINT", () => {
|
|
16
|
+
ui.warn("\nSIGINT — aborting current task...");
|
|
17
|
+
agent.abort();
|
|
18
|
+
});
|
|
19
|
+
let first = true;
|
|
20
|
+
for (;;) {
|
|
21
|
+
const line = await new Promise((res) => rl.question("you> ", res));
|
|
22
|
+
const input = line.trim();
|
|
23
|
+
if (!input)
|
|
24
|
+
continue;
|
|
25
|
+
if (input === "exit" || input === "quit")
|
|
26
|
+
break;
|
|
27
|
+
// Slash commands — registry dùng chung với TUI (T1). Command riêng của
|
|
28
|
+
// TUI (/theme) do TUI tự xử lý trước.
|
|
29
|
+
const host = {
|
|
30
|
+
agent: () => agent,
|
|
31
|
+
config: cfg,
|
|
32
|
+
updateConfig: (patch) => {
|
|
33
|
+
Object.assign(cfg, patch); // mutate the shared cfg so new Agents see it
|
|
34
|
+
},
|
|
35
|
+
replaceAgent: (next) => {
|
|
36
|
+
agent.abort();
|
|
37
|
+
agent.dispose();
|
|
38
|
+
agent = next ?? new Agent(cfg);
|
|
39
|
+
first = true;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const { handled, outcome } = await dispatchCommand(input, host, (text, level) => {
|
|
43
|
+
if (level === "error")
|
|
44
|
+
ui.error(text);
|
|
45
|
+
else
|
|
46
|
+
console.log(text);
|
|
47
|
+
});
|
|
48
|
+
if (handled) {
|
|
49
|
+
if (outcome?.notice)
|
|
50
|
+
ui.info(outcome.notice);
|
|
51
|
+
if (!outcome?.task)
|
|
52
|
+
continue;
|
|
53
|
+
try {
|
|
54
|
+
const result = first ? await agent.run(outcome.task) : await agent.runTurn(outcome.task);
|
|
55
|
+
first = false;
|
|
56
|
+
if (!result.success)
|
|
57
|
+
ui.warn("task did not complete — try refining it");
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
ui.error(err.message);
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const result = first ? await agent.run(input) : await agent.runTurn(input);
|
|
66
|
+
first = false;
|
|
67
|
+
if (!result.success)
|
|
68
|
+
ui.warn("task did not complete — try refining it");
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
ui.error(err.message);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
agent.dispose();
|
|
75
|
+
ui.info("bye");
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** One-shot `scoder "<task>"` mode: run to completion, exit with the result code. */
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { Agent } from "../agent.js";
|
|
4
|
+
import { pruneSessions } from "../session/session.js";
|
|
5
|
+
import * as ui from "../io/terminal.js";
|
|
6
|
+
export async function runOnce(cfg, task) {
|
|
7
|
+
const agent = new Agent(cfg);
|
|
8
|
+
process.on("SIGINT", () => {
|
|
9
|
+
ui.warn("\nSIGINT received — finishing current tool, then stopping...");
|
|
10
|
+
agent.abort();
|
|
11
|
+
});
|
|
12
|
+
try {
|
|
13
|
+
const result = await agent.run(task);
|
|
14
|
+
const pruned = pruneSessions(cfg.workdir, 7);
|
|
15
|
+
if (pruned > 0)
|
|
16
|
+
ui.info(`pruned ${pruned} old session(s)`);
|
|
17
|
+
return result.success ? 0 : 1;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
ui.error(err.message);
|
|
21
|
+
return 2;
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
agent.dispose();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -5,27 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/** Built-in commands — the single catalog both help and completion read. */
|
|
7
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" },
|
|
8
|
+
{ name: "/help", aliases: [], description: "show this command list (builtin + custom)", usage: "/help", visibility: "public", source: "builtin" },
|
|
10
9
|
{ name: "/agent", aliases: [], description: "list selectable agents — /agent <name> to switch (idle only)", usage: "/agent [name]", visibility: "public", source: "builtin" },
|
|
11
10
|
{ 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
11
|
{ 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
12
|
{ name: "/clear", aliases: [], description: "clear the conversation and reset agent context", usage: "/clear", visibility: "public", source: "builtin" },
|
|
16
13
|
{ name: "/compact", aliases: [], description: "summarize older history now (manual compaction)", usage: "/compact", visibility: "public", source: "builtin" },
|
|
17
14
|
{ name: "/cost", aliases: [], description: "token usage of the current session", usage: "/cost", visibility: "public", source: "builtin" },
|
|
18
15
|
{ name: "/model", aliases: [], description: "show the active model & provider", usage: "/model [provider/model]", visibility: "public", source: "builtin" },
|
|
19
|
-
{ name: "/
|
|
20
|
-
{ name: "/skill", aliases: [], description: "load a skill into context: /skill <name>", usage: "/skill <name>", visibility: "public", source: "builtin" },
|
|
16
|
+
{ name: "/skill", aliases: [], description: "list skills, or load one into context: /skill <name>", usage: "/skill [name]", visibility: "public", source: "builtin" },
|
|
21
17
|
{ 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
18
|
{ 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" },
|
|
29
19
|
];
|
|
30
20
|
/** Back-compat view used by completion + /help — derived from BUILTIN_COMMANDS. */
|
|
31
21
|
export const SLASH_COMMANDS = BUILTIN_COMMANDS
|
|
@@ -59,7 +49,7 @@ export function completeSlashCommand(input) {
|
|
|
59
49
|
// Exact match — complete to it (e.g. typing "/help" + Tab still completes).
|
|
60
50
|
if (matches.length === 1)
|
|
61
51
|
return { completed: matches[0].cmd + " ", candidates: matches };
|
|
62
|
-
// Common prefix of all matches (e.g. "/
|
|
52
|
+
// Common prefix of all matches (e.g. "/m" → "/m" prefix of /model+/mcp).
|
|
63
53
|
let prefix = matches[0].cmd;
|
|
64
54
|
for (const m of matches.slice(1)) {
|
|
65
55
|
let i = 0;
|
|
@@ -72,7 +62,7 @@ export function completeSlashCommand(input) {
|
|
|
72
62
|
import { readdirSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
73
63
|
import { homedir } from "node:os";
|
|
74
64
|
import { join, resolve, basename, extname } from "node:path";
|
|
75
|
-
import { suggest } from "
|
|
65
|
+
import { suggest } from "../workspace/file-index.js";
|
|
76
66
|
/** Substitute $ARGUMENTS, $1..$n, @file references in the body. */
|
|
77
67
|
function substituteTemplate(body, argStr) {
|
|
78
68
|
const args = argStr.trim().split(/\s+/).filter(Boolean);
|
|
@@ -309,8 +299,8 @@ export function ghostText(value, providers) {
|
|
|
309
299
|
return { rest, insert };
|
|
310
300
|
}
|
|
311
301
|
/** Format the /help listing (shared so both frontends render identically). */
|
|
312
|
-
export function formatSlashHelp(commandName) {
|
|
313
|
-
const rows = SLASH_COMMANDS.map((c) => ` ${c.cmd.padEnd(12)} ${c.desc}`).join("\n");
|
|
302
|
+
export function formatSlashHelp(commandName, custom = []) {
|
|
303
|
+
const rows = [...SLASH_COMMANDS, ...custom].map((c) => ` ${c.cmd.padEnd(12)} ${c.desc}`).join("\n");
|
|
314
304
|
return `Commands (${commandName}):\n${rows}\n Anything else is sent to the agent as a task.\n Tab completes commands; ↑/↓ recalls history.`;
|
|
315
305
|
}
|
|
316
306
|
/**
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { discoverSkills, matchSkillPrefix } from "../context/skills.js";
|
|
2
|
+
import { selectableAgents } from "../context/agents.js";
|
|
3
|
+
import { agentsMdTemplate } from "../context/agents-guide.js";
|
|
4
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { formatSlashHelp, parseMcpExec, BUILTIN_COMMANDS, resolveCommandDef, discoverCustomCommands, runCustomCommand } from "./definitions.js";
|
|
7
|
+
/** Format usage/cost block (dùng chung — REPL console.log, TUI notice). */
|
|
8
|
+
export function formatUsage(u) {
|
|
9
|
+
return u.calls > 0
|
|
10
|
+
? `session usage: ${u.calls} LLM calls · tokens in ${u.inputTokens} / out ${u.outputTokens}` +
|
|
11
|
+
(u.cachedTokens > 0 ? ` (+${u.cachedTokens} cached)` : "")
|
|
12
|
+
: "no LLM calls yet in this session";
|
|
13
|
+
}
|
|
14
|
+
/** Format skills listing (dùng chung). */
|
|
15
|
+
export function formatSkills(skills) {
|
|
16
|
+
if (skills.length === 0) {
|
|
17
|
+
return "no skills discovered — add .scoder/skills/<name>/SKILL.md (name + description frontmatter)";
|
|
18
|
+
}
|
|
19
|
+
return ("Skills:\n" +
|
|
20
|
+
skills
|
|
21
|
+
.map((s) => ` ${s.name.padEnd(20)} ${s.description || "(no description)"} [${s.scope}]`)
|
|
22
|
+
.join("\n"));
|
|
23
|
+
}
|
|
24
|
+
/** Format MCP status listing (dùng chung). */
|
|
25
|
+
export function formatMcpStatus(status) {
|
|
26
|
+
if (status.length === 0)
|
|
27
|
+
return "no MCP servers configured — add mcpServers to .scoderrc.json";
|
|
28
|
+
return status
|
|
29
|
+
.map((srv) => {
|
|
30
|
+
const capParts = [];
|
|
31
|
+
if (srv.serverInfo?.name)
|
|
32
|
+
capParts.push(srv.serverInfo.name + (srv.serverInfo.version ? " v" + srv.serverInfo.version : ""));
|
|
33
|
+
if (srv.capabilities?.ping === true)
|
|
34
|
+
capParts.push("ping");
|
|
35
|
+
if (srv.capabilities?.tools)
|
|
36
|
+
capParts.push("tools");
|
|
37
|
+
if (srv.capabilities?.resources)
|
|
38
|
+
capParts.push("resources");
|
|
39
|
+
if (srv.capabilities?.prompts)
|
|
40
|
+
capParts.push("prompts");
|
|
41
|
+
const capTag = capParts.length > 0 ? ` [${capParts.join(", ")}]` : "";
|
|
42
|
+
const counts = [
|
|
43
|
+
`${srv.tools.length} tools`,
|
|
44
|
+
...(srv.resourceCount ? [`${srv.resourceCount} resources`] : []),
|
|
45
|
+
...(srv.promptCount ? [`${srv.promptCount} prompts`] : []),
|
|
46
|
+
].join(", ");
|
|
47
|
+
return (`${srv.connected ? "✓" : "·"} ${srv.name} — ${srv.command}${capTag}` +
|
|
48
|
+
(srv.connected
|
|
49
|
+
? ` (${counts})\n` +
|
|
50
|
+
srv.tools.map((t) => ` mcp__${srv.name}__${t.name} ${t.description ?? ""}`).join("\n")
|
|
51
|
+
: " (not connected)"));
|
|
52
|
+
})
|
|
53
|
+
.join("\n");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Handlers dùng chung. Command đặc thù frontend (/theme — chỉ TUI) KHÔNG nằm
|
|
57
|
+
* đây — frontend xử lý trước khi dispatch, rồi mới gọi dispatchCommand() cho
|
|
58
|
+
* phần còn lại.
|
|
59
|
+
*/
|
|
60
|
+
export function coreCommands() {
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
name: "/help",
|
|
64
|
+
description: "show this command list (builtin + custom)",
|
|
65
|
+
run(_slash, host, notice) {
|
|
66
|
+
const custom = discoverCustomCommands(host.config.workdir).map((c) => ({ cmd: "/" + c.name, desc: c.description }));
|
|
67
|
+
notice(formatSlashHelp("scoder tui", custom));
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "/cost",
|
|
72
|
+
description: "token usage of the current session",
|
|
73
|
+
run(_slash, host, notice) {
|
|
74
|
+
notice(formatUsage(host.agent()?.getUsage() ?? { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 }));
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "/compact",
|
|
79
|
+
description: "summarize older history now (manual compaction)",
|
|
80
|
+
async run(_slash, host, notice) {
|
|
81
|
+
const agent = host.agent();
|
|
82
|
+
if (!agent)
|
|
83
|
+
return notice("agent not ready — start a task first", "warn");
|
|
84
|
+
if (host.isRunning?.())
|
|
85
|
+
return notice("agent is running — wait or Esc first", "warn");
|
|
86
|
+
const r = await agent.compactHistory();
|
|
87
|
+
notice(r.message, r.ok ? "info" : "warn");
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "/skill",
|
|
92
|
+
description: "list skills, or load one into context: /skill <name> [task]",
|
|
93
|
+
run(slash, host, notice) {
|
|
94
|
+
if (!slash.arg) {
|
|
95
|
+
notice(formatSkills(host.agent()?.getSkills() ?? discoverSkills(host.config.workdir)));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const agent = host.agent();
|
|
99
|
+
if (!agent)
|
|
100
|
+
return notice("agent not ready — start a task first", "warn");
|
|
101
|
+
// "/skill <name> <task…>": stage rồi CHẠY task luôn — skill áp dụng
|
|
102
|
+
// ngay cho prompt đó (trước đây task text bị nuốt vào tham số name).
|
|
103
|
+
const m = matchSkillPrefix(host.config.workdir, slash.arg);
|
|
104
|
+
const r = agent.stageSkill(m ? m.skill.name : slash.arg);
|
|
105
|
+
notice(r.message, r.ok ? "info" : "error");
|
|
106
|
+
if (r.ok && m?.rest)
|
|
107
|
+
return { task: m.rest };
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "/mcp",
|
|
112
|
+
description: "MCP servers & tools: /mcp [server] [tool] [json|text]",
|
|
113
|
+
async run(slash, host, notice) {
|
|
114
|
+
const agent = host.agent();
|
|
115
|
+
if (!agent)
|
|
116
|
+
return notice("agent not ready", "warn");
|
|
117
|
+
const exec = parseMcpExec(slash.arg);
|
|
118
|
+
if (exec === null) {
|
|
119
|
+
notice(formatMcpStatus(await agent.mcpStatus()));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if ("error" in exec)
|
|
123
|
+
return notice(exec.error, "warn");
|
|
124
|
+
notice(`→ ${exec.fullName}`);
|
|
125
|
+
const r = await agent.callMcpTool(exec.fullName, exec.args);
|
|
126
|
+
if (r.ok)
|
|
127
|
+
notice(r.output.length > 4000 ? r.output.slice(0, 4000) + "\n… (truncated)" : r.output);
|
|
128
|
+
else
|
|
129
|
+
notice(r.output, "error");
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "/model",
|
|
134
|
+
description: "show the active model & provider — /model <provider/model> to hot-swap",
|
|
135
|
+
run(slash, host, notice) {
|
|
136
|
+
if (slash.arg) {
|
|
137
|
+
const agent = host.agent();
|
|
138
|
+
if (!agent)
|
|
139
|
+
return notice("agent not ready", "warn");
|
|
140
|
+
const msg = agent.setModel(slash.arg.trim());
|
|
141
|
+
notice(msg, msg.startsWith("ERROR") ? "error" : "info");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const cfg = host.config;
|
|
145
|
+
notice(`model: ${cfg.model} · provider: ${cfg.provider} · build: ${cfg.buildCmd ?? "auto-detect"}`);
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "/permission",
|
|
150
|
+
description: "view or switch permission mode: /permission <auto|ask|readonly>",
|
|
151
|
+
run(slash, host, notice) {
|
|
152
|
+
const mode = slash.arg.toLowerCase();
|
|
153
|
+
if (!["auto", "ask", "readonly"].includes(mode)) {
|
|
154
|
+
notice(`permission mode: ${host.config.permissionMode} — usage: /permission <auto|ask|readonly>`, "warn");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
host.agent()?.setPermissionMode(mode);
|
|
158
|
+
host.updateConfig?.({ permissionMode: mode });
|
|
159
|
+
host.config = withPermissionMode(host.config, mode);
|
|
160
|
+
notice(`permission mode → ${mode}`);
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "/agent",
|
|
165
|
+
description: "list selectable agents — /agent <name> to switch (idle only)",
|
|
166
|
+
run(slash, host, notice) {
|
|
167
|
+
const agents = selectableAgents(host.config.workdir);
|
|
168
|
+
const current = host.config.agent ?? "build";
|
|
169
|
+
if (!slash.arg) {
|
|
170
|
+
notice("Agents:\n" +
|
|
171
|
+
agents.map((a) => ` ${a.name === current ? "*" : " "} ${a.name.padEnd(10)} ${a.description}`).join("\n") +
|
|
172
|
+
"\nswitch with /agent <name> (idle only; resets the conversation)");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const name = slash.arg.trim();
|
|
176
|
+
const def = agents.find((a) => a.name === name);
|
|
177
|
+
if (!def) {
|
|
178
|
+
return notice(`agent "${name}" not selectable — available: ${agents.map((a) => a.name).join(", ")}`, "error");
|
|
179
|
+
}
|
|
180
|
+
if (current === name)
|
|
181
|
+
return notice(`already on agent "${name}"`);
|
|
182
|
+
if (host.isRunning?.())
|
|
183
|
+
return notice("agent is running — wait or Esc first", "warn");
|
|
184
|
+
host.updateConfig?.({ agent: name });
|
|
185
|
+
host.config = { ...host.config, agent: name };
|
|
186
|
+
host.replaceAgent(null); // fresh agent with the new def; old conversation ends here
|
|
187
|
+
notice(`agent → ${name} (${def.mode}) — conversation reset; \`scoder resume last\` resumes the previous one`);
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "/init",
|
|
192
|
+
description: "create an AGENTS.md template in this project",
|
|
193
|
+
run(_slash, host, notice) {
|
|
194
|
+
const target = join(host.config.workdir, "AGENTS.md");
|
|
195
|
+
if (existsSync(target)) {
|
|
196
|
+
notice(`AGENTS.md already exists: ${target}`, "warn");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
writeFileSync(target, agentsMdTemplate(host.config.workdir), "utf8");
|
|
200
|
+
notice(`created ${target} — review and commit it`);
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
/** Merged catalog of builtin command metadata — /help & completion read this. */
|
|
206
|
+
export function commandCatalog() {
|
|
207
|
+
return BUILTIN_COMMANDS.map((def) => ({ ...def }));
|
|
208
|
+
}
|
|
209
|
+
/** Clone config với permissionMode mới (host.config thay thế được). */
|
|
210
|
+
function withPermissionMode(cfg, mode) {
|
|
211
|
+
return { ...cfg, permissionMode: mode };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Dispatch một input qua registry. Trả về {handled:false} khi không phải
|
|
215
|
+
* slash command. Frontend xử lý command riêng (TUI: /theme; REPL: không có)
|
|
216
|
+
* TRƯỚC khi gọi hàm này.
|
|
217
|
+
*/
|
|
218
|
+
export async function dispatchCommand(input, host, notice, extra = []) {
|
|
219
|
+
const m = /^\/([a-zA-Z][a-zA-Z0-9-]*)(?:\s+([\s\S]*\S))?\s*$/.exec(input.trim());
|
|
220
|
+
if (!m)
|
|
221
|
+
return { handled: false, outcome: null };
|
|
222
|
+
const raw = "/" + m[1].toLowerCase();
|
|
223
|
+
const arg = m[2] ?? "";
|
|
224
|
+
// Resolve aliases → canonical command.
|
|
225
|
+
const resolved = resolveCommandDef(raw);
|
|
226
|
+
const cmd = resolved?.name ?? raw;
|
|
227
|
+
const slash = { cmd, arg };
|
|
228
|
+
if (cmd === "/clear") {
|
|
229
|
+
host.agent()?.abort();
|
|
230
|
+
host.replaceAgent(null);
|
|
231
|
+
return { handled: true, outcome: { notice: "conversation cleared — context reset" } };
|
|
232
|
+
}
|
|
233
|
+
const custom = discoverCustomCommands(host.config.workdir);
|
|
234
|
+
const customMatch = custom.find((c) => `/${c.name.toLowerCase()}` === cmd || c.aliases.some((a) => `/${a.toLowerCase().replace(/^\//, "")}` === cmd));
|
|
235
|
+
if (customMatch) {
|
|
236
|
+
if (customMatch.exec === "shell") {
|
|
237
|
+
notice(`custom command "${customMatch.name}" declares shell execution; run it explicitly through a reviewed agent task`, "warn");
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
const prompt = runCustomCommand(customMatch, arg);
|
|
241
|
+
notice(`custom command "${customMatch.name}"`, "info");
|
|
242
|
+
return { handled: true, outcome: { task: prompt } };
|
|
243
|
+
}
|
|
244
|
+
return { handled: true, outcome: {} };
|
|
245
|
+
}
|
|
246
|
+
const entry = [...extra, ...coreCommands()].find((c) => c.name === cmd);
|
|
247
|
+
if (!entry) {
|
|
248
|
+
notice(`unknown command ${cmd} — try /help`, "error");
|
|
249
|
+
return { handled: true, outcome: {} };
|
|
250
|
+
}
|
|
251
|
+
let outcome;
|
|
252
|
+
try {
|
|
253
|
+
outcome = await entry.run(slash, host, notice);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
notice(err.message, "error");
|
|
257
|
+
return { handled: true, outcome: {} };
|
|
258
|
+
}
|
|
259
|
+
return { handled: true, outcome: outcome ?? {} };
|
|
260
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, resolve, dirname } from "node:path";
|
|
4
|
-
import { parseCustomProviders, resolveProvider, splitProviderModel } from "
|
|
4
|
+
import { parseCustomProviders, resolveProvider, splitProviderModel } from "../llm/provider-registry.js";
|
|
5
5
|
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
6
6
|
const DEFAULT_MODEL = "gpt-4o-mini";
|
|
7
7
|
const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
|
|
@@ -5,8 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
7
|
import { formatSkillIndex } from "./skills.js";
|
|
8
|
+
// P2: memoize per (workdir, date, platform, node version) — the git probe is a
|
|
9
|
+
// spawned process and the output cannot change for a given key within a run.
|
|
10
|
+
// The date is part of the key, so nothing is cached across a day boundary.
|
|
11
|
+
const envInfoCache = new Map();
|
|
8
12
|
/** One-line environment snapshot for the system prompt (cache-stable per run). */
|
|
9
13
|
export function buildEnvInfo(workdir) {
|
|
14
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
15
|
+
const cacheKey = `${workdir}|${process.platform}|${process.version}|${today}`;
|
|
16
|
+
const hit = envInfoCache.get(cacheKey);
|
|
17
|
+
if (hit !== undefined)
|
|
18
|
+
return hit;
|
|
10
19
|
let git = "";
|
|
11
20
|
try {
|
|
12
21
|
const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: workdir, encoding: "utf8" });
|
|
@@ -16,7 +25,9 @@ export function buildEnvInfo(workdir) {
|
|
|
16
25
|
catch {
|
|
17
26
|
/* git unavailable */
|
|
18
27
|
}
|
|
19
|
-
|
|
28
|
+
const info = `Environment: ${process.platform} · node ${process.version}${git} · today ${today}`;
|
|
29
|
+
envInfoCache.set(cacheKey, info);
|
|
30
|
+
return info;
|
|
20
31
|
}
|
|
21
32
|
/** Skills advertised in system prompt (passive listing only — agent does NOT auto-load). */
|
|
22
33
|
export function buildSkillsBlock(skills) {
|