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.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- 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
|
+
}
|