acuvo-code 0.2.0

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 (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/slash.mjs ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * ── ⭐ THE `/` COMMAND SURFACE ───────────────────────────────────────────────
3
+ *
4
+ * ⚠️ MEASURED 2026-08-16: `grep -c "'/'" lib/chat.mjs` → **0**. The interactive
5
+ * session understood exactly four words (`exit`, `quit`, `:q`, `bye`) and
6
+ * nothing else. Every rival terminal agent opens on a `/` menu, and the gap is
7
+ * not cosmetic — it is the difference between a session that ADMITS what it can
8
+ * do and one where every capability is a thing you had to read a README to know
9
+ * about.
10
+ *
11
+ * ⭐ AND THE FEATURES WERE ALREADY THERE. `skills.mjs` discovers and loads
12
+ * `.acuvo/skills/*.md` today; `mcp.mjs` connects servers today; the budget
13
+ * ledger counts dollars today. `WHAT-NEEDS-TO-HAPPEN.md` item 14 closed the
14
+ * half of this that was about `--help` text and left this half open with the
15
+ * note *"chat.mjs has no slash handling at all"*. This is that half: the
16
+ * capabilities do not change, only whether a person sitting at the prompt can
17
+ * find them.
18
+ *
19
+ * ── ⚠️⚠️ DISCOVERABILITY IS THE POINT, SO AN UNKNOWN COMMAND IS THE FEATURE ──
20
+ *
21
+ * A `/` surface whose failure mode is `unknown command` has solved nothing: the
22
+ * person who typed `/skill` did not need to be told they were wrong, they needed
23
+ * to be told the word is `/skills`. `suggestCommands` exists for that one
24
+ * sentence, and it is the reason this module has an edit distance in it.
25
+ *
26
+ * ── ⚠️⚠️ A LEADING SLASH IS NOT ALWAYS A COMMAND, AND GUESSING WRONG EATS THE
27
+ * USER'S SENTENCE ──────────────────────────────────────────────────────────
28
+ *
29
+ * `/etc/hosts is wrong, fix it` and `/usr/local/bin/node is the wrong version`
30
+ * are ordinary tasks that begin with `/`. If a naive `line.startsWith('/')`
31
+ * claimed them, the session would answer "unknown command /etc" and the
32
+ * instruction would be gone — a check that fails correct work, which this
33
+ * codebase has paid for four times in one day and written down.
34
+ *
35
+ * ⭐ THE DISCRIMINATOR IS CHEAP AND EXACT: a command is a single token with NO
36
+ * further `/` and no `.` in it. Every absolute path a person would type has a
37
+ * second slash or an extension; no command has either. So `/mcp` is a command,
38
+ * `/etc/hosts` is a sentence, and neither has to be guessed at.
39
+ *
40
+ * ── PURE ────────────────────────────────────────────────────────────────────
41
+ *
42
+ * No filesystem, no clock, no model, no terminal. Everything about the outside
43
+ * world — which skills exist, which MCP servers are configured, what this run
44
+ * has cost — arrives through the `context` argument as plain data or as a
45
+ * provider function the caller supplies. That is the same rule `repo-map.mjs`
46
+ * and `diff-preview.mjs` are built on, and it is why every branch below is
47
+ * reachable from a test with none of those things.
48
+ */
49
+
50
+ /**
51
+ * ⚠️ THE REGISTRY IS DATA, NOT A SWITCH STATEMENT, because `/help` has to be
52
+ * generated FROM it. A hand-written help text is a second copy of the command
53
+ * list, and the second copy is the one that goes stale — which is precisely the
54
+ * failure `--help` had before item 14: the feature worked and nothing a person
55
+ * would read mentioned it.
56
+ */
57
+ export const SLASH_COMMANDS = Object.freeze([
58
+ Object.freeze({ name: 'help', usage: '/help', summary: 'list these commands' }),
59
+ Object.freeze({ name: 'skills', usage: '/skills [name]', summary: 'list the skills in .acuvo/skills, or load one into the next turn' }),
60
+ Object.freeze({ name: 'mcp', usage: '/mcp', summary: 'the MCP servers this workspace is configured with, and their status' }),
61
+ Object.freeze({ name: 'cost', usage: '/cost', summary: 'what this session has spent so far, and against which ceiling' }),
62
+ Object.freeze({ name: 'model', usage: '/model', summary: 'which model is answering, and where that choice came from' }),
63
+ Object.freeze({ name: 'clear', usage: '/clear', summary: 'forget the conversation so far and start the next turn cold' }),
64
+ ]);
65
+
66
+ /** Name → entry, built once. */
67
+ const BY_NAME = new Map(SLASH_COMMANDS.map((c) => [c.name, c]));
68
+
69
+ /**
70
+ * Is this line an attempt at a command, and if so which?
71
+ *
72
+ * ⚠️ RETURNS `null` FOR A SENTENCE, and that is the load-bearing case — see the
73
+ * header. A bare `/` is a sentence too (somebody hit a key), not a command with
74
+ * an empty name.
75
+ *
76
+ * @returns {{ name: string, args: string } | null}
77
+ */
78
+ export function parseSlash(line) {
79
+ const raw = String(line ?? '');
80
+ // ⚠️ NOT trimmed at the start: a leading space means the user is typing prose,
81
+ // and " /help" is far more likely to be part of a sentence than a command.
82
+ if (!raw.startsWith('/')) return null;
83
+ const m = /^\/([A-Za-z][A-Za-z0-9-]*)(?:\s+([\s\S]*))?$/.exec(raw.replace(/\s+$/, ''));
84
+ if (!m) return null;
85
+ return { name: m[1].toLowerCase(), args: (m[2] ?? '').trim() };
86
+ }
87
+
88
+ /**
89
+ * Levenshtein, capped.
90
+ *
91
+ * ⚠️ WRITTEN OUT RATHER THAN IMPORTED because this package has zero
92
+ * dependencies and that is not negotiable. It runs over a six-item list against
93
+ * one short word, so the O(n·m) table is free.
94
+ */
95
+ export function editDistance(a, b) {
96
+ const s = String(a);
97
+ const t = String(b);
98
+ if (s === t) return 0;
99
+ if (s.length === 0) return t.length;
100
+ if (t.length === 0) return s.length;
101
+ let prev = Array.from({ length: t.length + 1 }, (_, i) => i);
102
+ for (let i = 1; i <= s.length; i += 1) {
103
+ const row = [i];
104
+ for (let j = 1; j <= t.length; j += 1) {
105
+ row[j] = Math.min(
106
+ prev[j] + 1,
107
+ row[j - 1] + 1,
108
+ prev[j - 1] + (s[i - 1] === t[j - 1] ? 0 : 1),
109
+ );
110
+ }
111
+ prev = row;
112
+ }
113
+ return prev[t.length];
114
+ }
115
+
116
+ /**
117
+ * The commands a typo probably meant.
118
+ *
119
+ * ⭐ A PREFIX COUNTS AS A MATCH AT ANY LENGTH, and that is not the same rule as
120
+ * the distance one. `/s` is distance 5 from `skills` and would be suggested by
121
+ * nothing, but it is obviously a person reaching for `/skills`; a prefix is the
122
+ * commonest half-typed shape there is.
123
+ *
124
+ * ⚠️ AND AN EMPTY RESULT IS RETURNED HONESTLY rather than padded out with the
125
+ * whole list. `/xyzzy` resembles nothing, and offering `/cost` for it is noise
126
+ * that teaches people to ignore the suggestion line — at which point the real
127
+ * suggestions stop working too.
128
+ */
129
+ export function suggestCommands(name, { commands = SLASH_COMMANDS, maxDistance = 3 } = {}) {
130
+ const want = String(name ?? '').toLowerCase();
131
+ if (!want) return [];
132
+ const scored = [];
133
+ for (const c of commands) {
134
+ if (c.name.startsWith(want) || want.startsWith(c.name)) { scored.push([0, c.name]); continue; }
135
+ const d = editDistance(want, c.name);
136
+ // Scaled to the word: one wrong letter in `mcp` is a bigger signal than one
137
+ // wrong letter in `skills`, so a flat threshold over-suggests for short names.
138
+ if (d <= Math.min(maxDistance, Math.max(1, Math.ceil(c.name.length / 2)))) scored.push([d, c.name]);
139
+ }
140
+ return scored.sort((a, b) => a[0] - b[0] || (a[1] < b[1] ? -1 : 1)).map((s) => s[1]);
141
+ }
142
+
143
+ /** `/help`, generated from the registry so it can never fall out of date. */
144
+ export function helpLines({ commands = SLASH_COMMANDS } = {}) {
145
+ /**
146
+ * ⚠️ `exit` IS IN THE SAME COLUMN AS THE REST, and it is measured from the
147
+ * same width rather than spelled with hand-counted spaces. A hand-aligned
148
+ * line is a second copy of the column width; it looked right when it was
149
+ * written and drifts the moment a command with a longer usage is added.
150
+ */
151
+ const rows = [...commands.map((c) => [c.usage, c.summary]),
152
+ ['exit', 'end the session (also: quit, bye, :q, Ctrl-D)']];
153
+ const width = rows.reduce((n, r) => Math.max(n, r[0].length), 0);
154
+ return [
155
+ 'Commands — type one at the prompt. Anything else is a task for the agent.',
156
+ ...rows.map(([usage, summary]) => ` ${usage.padEnd(width)} ${summary}`),
157
+ ];
158
+ }
159
+
160
+ /**
161
+ * ⚠️ EVERY PROVIDER IS OPTIONAL AND ITS ABSENCE IS AN ANSWER, NOT A CRASH.
162
+ * `runChat` is called from one place today, but it is also the piece a test and
163
+ * a future embedder drive directly. A `/cost` that throws because nobody wired
164
+ * a ledger would take the whole session down over a status line.
165
+ *
166
+ * ⭐ AND "NOT WIRED UP HERE" IS PRINTED AS ITSELF rather than as an empty list.
167
+ * An empty list means "you have no MCP servers", which is a claim; silence about
168
+ * a provider that was never supplied is a different fact and the user needs the
169
+ * difference.
170
+ */
171
+ function unavailable(what) {
172
+ return [` ${what} is not available in this session.`];
173
+ }
174
+
175
+ function renderSkills(context, args) {
176
+ if (typeof context.skills !== 'function') return { output: unavailable('The skill catalogue') };
177
+ const found = context.skills() ?? [];
178
+ if (!args) {
179
+ if (found.length === 0) {
180
+ return {
181
+ output: [
182
+ ' No skills found. A skill is a markdown file in .acuvo/skills/ with a',
183
+ ' `name:` and `description:` at the top; drop one in and it is picked up',
184
+ ' on the next turn — no restart, no registration.',
185
+ ],
186
+ };
187
+ }
188
+ const width = found.reduce((n, s) => Math.max(n, String(s.name).length), 0);
189
+ return {
190
+ output: [
191
+ /**
192
+ * ⚠️ NOT "in .acuvo/skills". Most of these ship INSIDE the CLI package;
193
+ * naming a directory they are mostly not in sends a curious user to an
194
+ * empty folder to look for the thing they can already see listed. The
195
+ * count is true, the location was not.
196
+ */
197
+ ` ${found.length} skill${found.length === 1 ? '' : 's'} available` +
198
+ ` (bundled with Acuvo, plus any in .acuvo/skills):`,
199
+ ...found.map((s) => ` ${String(s.name).padEnd(width)} ${s.description ?? ''}`.replace(/\s+$/, '')),
200
+ ' /skills <name> loads one into the next turn.',
201
+ ],
202
+ };
203
+ }
204
+ if (typeof context.loadSkill !== 'function') return { output: unavailable('Loading a skill') };
205
+ const loaded = context.loadSkill(args);
206
+ if (!loaded?.ok) {
207
+ const names = found.map((s) => String(s.name));
208
+ const near = suggestCommands(args, { commands: names.map((n) => ({ name: n })) });
209
+ return {
210
+ output: [
211
+ ` ${loaded?.error ?? `no skill called "${args}"`}`,
212
+ ...(near.length > 0 ? [` Did you mean: ${near.map((n) => `/skills ${n}`).join(' ')}`] : []),
213
+ ],
214
+ };
215
+ }
216
+ /**
217
+ * ⭐⭐ IT IS QUEUED FOR THE NEXT TURN, NOT PRINTED AND FORGOTTEN. Printing the
218
+ * skill to the terminal would look exactly like loading it and would do
219
+ * nothing at all — the model never sees the terminal. `inject` is what makes
220
+ * this verb real, and the sentence below is what stops the user believing
221
+ * something happened that did not.
222
+ */
223
+ /**
224
+ * ⚠️ `body` IS WHAT `loadSkill` ACTUALLY RETURNS — checked against
225
+ * `lib/skills.mjs`, not assumed. `text` is accepted first only so a caller
226
+ * that pre-renders with `formatSkillForModel` can pass one straight through.
227
+ * Reading a field the producer does not emit is how a feature ships loading
228
+ * an empty string and reporting success.
229
+ */
230
+ const text = typeof loaded.text === 'string' ? loaded.text : String(loaded.body ?? '');
231
+ if (!text) {
232
+ return { output: [` Skill "${loaded.name ?? args}" loaded but is empty — nothing was attached.`] };
233
+ }
234
+ return {
235
+ inject: text,
236
+ output: [
237
+ ` Loaded skill "${loaded.name ?? args}" (${text.length} characters).`,
238
+ ' It is attached to your NEXT message, not to the ones already sent.',
239
+ ],
240
+ };
241
+ }
242
+
243
+ function renderMcp(context) {
244
+ if (typeof context.mcp !== 'function') return { output: unavailable('MCP status') };
245
+ const info = context.mcp() ?? {};
246
+ const servers = Array.isArray(info.servers) ? info.servers : [];
247
+ if (servers.length === 0) {
248
+ /**
249
+ * ⚠️⚠️ AN EMPTY LIST IS NOT ALWAYS "YOU HAVE NONE". `readMcpConfig` returns
250
+ * no servers both when there is no config AND when the config failed to
251
+ * parse. Collapsing the two tells a user whose `mcp.json` has a trailing
252
+ * comma to go and write the file they already wrote — the wrong problem, in
253
+ * the wrong file. Caught by its own test; the first version of this function
254
+ * had exactly that bug.
255
+ */
256
+ if (info.source) return { output: [` No MCP servers are usable — ${info.source}`] };
257
+ return {
258
+ output: [
259
+ ' No MCP servers configured for this workspace.',
260
+ ' Add them to .acuvo/mcp.json (or .mcp.json) under an `mcpServers` key.',
261
+ ],
262
+ };
263
+ }
264
+ const width = servers.reduce((n, s) => Math.max(n, String(s.name).length), 0);
265
+ return {
266
+ output: [
267
+ ` ${servers.length} MCP server${servers.length === 1 ? '' : 's'}${info.source ? ` from ${info.source}` : ''}:`,
268
+ ...servers.map((s) => {
269
+ const bits = [String(s.name).padEnd(width), s.status ?? 'not connected'];
270
+ if (s.transport) bits.push(s.transport);
271
+ // ⚠️ A tool COUNT only when we actually have one. `0 tools` and "we never
272
+ // connected, so we do not know" are different facts and must not share a
273
+ // rendering — the first would read as a broken server.
274
+ if (typeof s.tools === 'number') bits.push(`${s.tools} tool${s.tools === 1 ? '' : 's'}`);
275
+ return ` ${bits.join(' ')}`;
276
+ }),
277
+ ],
278
+ };
279
+ }
280
+
281
+ function renderCost(context) {
282
+ if (typeof context.cost !== 'function') return { output: unavailable('Spend for this session') };
283
+ const c = context.cost() ?? {};
284
+ const spent = Number(c.spentUsd);
285
+ if (!Number.isFinite(spent)) return { output: unavailable('Spend for this session') };
286
+ const lines = [` This session has spent $${spent.toFixed(6)}` + (typeof c.turns === 'number' ? ` over ${c.turns} turn${c.turns === 1 ? '' : 's'}.` : '.')];
287
+ if (Number.isFinite(Number(c.limitUsd))) {
288
+ const limit = Number(c.limitUsd);
289
+ const left = Math.max(0, limit - spent);
290
+ lines.push(` Ceiling $${limit.toFixed(6)}${c.limitIsDefault ? ' (the default — nobody set one)' : ''}, $${left.toFixed(6)} left.`);
291
+ }
292
+ /**
293
+ * ⚠️ THE CEILING IS PER SESSION AND SAYING SO MATTERS. `bin/acuvo.mjs` records
294
+ * that this exact number was once handed out fresh every turn, so a forty-turn
295
+ * conversation permitted forty times the agreed limit. A `/cost` that printed
296
+ * only a turn's spend would put that misreading back in front of the user.
297
+ */
298
+ return { output: lines };
299
+ }
300
+
301
+ function renderModel(context) {
302
+ if (typeof context.model !== 'function') return { output: unavailable('The model name') };
303
+ const m = context.model();
304
+ if (!m) return { output: unavailable('The model name') };
305
+ if (typeof m === 'string') return { output: [` ${m}`] };
306
+ return {
307
+ output: [
308
+ ` ${m.name ?? 'unknown'}${m.source ? ` (${m.source})` : ''}`,
309
+ ...(m.note ? [` ${m.note}`] : []),
310
+ ],
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Run one command.
316
+ *
317
+ * ⭐ RETURNS A DESCRIPTION OF WHAT SHOULD HAPPEN, and performs none of it. The
318
+ * loop in `chat.mjs` owns the history and the output stream; this function owns
319
+ * the wording. Keeping the two apart is what lets every command below be
320
+ * asserted without a terminal.
321
+ *
322
+ * @returns {{ output: string[], effect?: 'clear', inject?: string, unknown?: boolean }}
323
+ */
324
+ export function runSlashCommand(parsed, context = {}) {
325
+ const name = parsed?.name;
326
+ if (!BY_NAME.has(name)) {
327
+ const near = suggestCommands(name);
328
+ return {
329
+ unknown: true,
330
+ output: [
331
+ ` /${name} is not a command.`,
332
+ ...(near.length > 0
333
+ ? [` Did you mean ${near.map((n) => `/${n}`).join(' or ')}?`]
334
+ : [' /help lists everything this prompt understands.']),
335
+ ],
336
+ };
337
+ }
338
+ switch (name) {
339
+ case 'help': return { output: helpLines() };
340
+ case 'skills': return renderSkills(context, parsed.args);
341
+ case 'mcp': return renderMcp(context);
342
+ case 'cost': return renderCost(context);
343
+ case 'model': return renderModel(context);
344
+ case 'clear': return {
345
+ effect: 'clear',
346
+ output: [' Conversation cleared. The next turn starts with no history.'],
347
+ };
348
+ /**
349
+ * ⚠️ UNREACHABLE BY CONSTRUCTION — `BY_NAME` is built from the same list the
350
+ * switch covers. It is here because the two CAN drift: adding a registry
351
+ * entry without a case would otherwise return `undefined` and crash the
352
+ * session on a command that `/help` had just advertised.
353
+ */
354
+ default: return { output: [` /${name} is listed but not implemented — that is a bug in ${'slash.mjs'}.`] };
355
+ }
356
+ }