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
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐ SHELL COMPLETION, AND THE ONE WAY IT IS WORSE THAN HAVING NONE ────────
|
|
3
|
+
*
|
|
4
|
+
* A completion script is the only part of a CLI that makes a claim about the
|
|
5
|
+
* tool while the tool is not running. Nothing checks it at runtime, nobody reads
|
|
6
|
+
* it, and it keeps making that claim for as long as it sits in the user's
|
|
7
|
+
* `~/.bashrc`. So the failure mode is not "TAB does nothing" — it is:
|
|
8
|
+
*
|
|
9
|
+
* $ acuvo --no-auto-le<TAB>
|
|
10
|
+
* $ acuvo --no-auto-lease
|
|
11
|
+
* Unknown option --no-auto-lease. Run with --help.
|
|
12
|
+
*
|
|
13
|
+
* The shell taught them a flag we deleted, and the tool called them wrong for
|
|
14
|
+
* typing what it offered. ⚠️ **A completion script that offers a flag we removed
|
|
15
|
+
* is worse than no completion at all**, so `test/terminal-ergonomics.test.mjs`
|
|
16
|
+
* takes the flags back OUT of the generated scripts and drives every one of them
|
|
17
|
+
* through the real `parseArgv`. Behaviour, not a grep — see the header of
|
|
18
|
+
* `test/cli-flags-parse.test.mjs` for the day a source-grep guard reported green
|
|
19
|
+
* about a flag that did not work.
|
|
20
|
+
*
|
|
21
|
+
* ── ⚠️ THE SUBTLE CORRECTNESS BUG: COMPLETING A FLAG'S *VALUE* ───────────────
|
|
22
|
+
*
|
|
23
|
+
* The obvious implementation offers the flag list at every position. Then:
|
|
24
|
+
*
|
|
25
|
+
* $ acuvo --model <TAB> → --dir
|
|
26
|
+
*
|
|
27
|
+
* and `--model --dir` is not a parse error here, because `cli-args.mjs` refuses
|
|
28
|
+
* only values that begin with `--`… which `--dir` does, so it errors — but
|
|
29
|
+
* `--holder --json` would sail through and record a lease holder called
|
|
30
|
+
* "--json". Every value-taking flag therefore gets its own arm that offers
|
|
31
|
+
* VALUES and returns, and a flag whose value we cannot guess (`--budget`,
|
|
32
|
+
* `--issue`) offers *nothing at all* rather than falling through to the flags.
|
|
33
|
+
*
|
|
34
|
+
* ── WHAT IS DELIBERATELY NOT HERE ───────────────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ `bin/acuvo.mjs` pre-parses five more flags of its own before `parseArgv`
|
|
37
|
+
* ever sees the argv — `--doctor`, `--replay`, `--design`, `--task-audio`,
|
|
38
|
+
* `--say` (bin/acuvo.mjs:85). They are real and they are not completed here,
|
|
39
|
+
* because `parseArgv` answers "Unknown option" for all five and the drift test
|
|
40
|
+
* above would (correctly) reject them. That is a split argument surface, not a
|
|
41
|
+
* completion bug, and the fix belongs in `bin/`. Until it moves, `extraFlags`
|
|
42
|
+
* lets the caller add them in one line without this file guessing.
|
|
43
|
+
*
|
|
44
|
+
* ⭐ PURE. Every function returns a STRING. No filesystem, no `$SHELL` sniffing,
|
|
45
|
+
* no writing to a user's rc file — the script goes to stdout and the user
|
|
46
|
+
* decides where it lands, which is also the only version of this that works
|
|
47
|
+
* over ssh, in a Dockerfile, and under a package manager.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ IMPORTS `cli-args.mjs`, SO NOTHING IN `cli-args.mjs` MAY IMPORT THIS. The
|
|
50
|
+
* flag descriptions are read out of `USAGE` precisely so they cannot drift from
|
|
51
|
+
* the help text; wiring `acuvo completion <shell>` therefore belongs in `bin/`,
|
|
52
|
+
* where the cycle does not exist. An ESM cycle here would fail as an undefined
|
|
53
|
+
* `USAGE` at module-init — which reads as "the help text is empty", not as an
|
|
54
|
+
* import problem.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { USAGE } from './cli-args.mjs';
|
|
58
|
+
import { TIERS } from './escalate.mjs';
|
|
59
|
+
import { selectableModels } from './acuvo-models.mjs';
|
|
60
|
+
import { CREATIVE_ENGINES } from './creative-engines.mjs';
|
|
61
|
+
|
|
62
|
+
/** The shells we emit. A name outside this set is refused by `completionScript`. */
|
|
63
|
+
export const SUPPORTED_SHELLS = Object.freeze(['bash', 'zsh', 'fish']);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* ⭐ THE SUBCOMMANDS ARE THE FIVE THE PARSER CLAIMS, AND NO MORE.
|
|
67
|
+
* `leases` and `spend` come from `COMMANDS`; `verify`, `board` and `rewind` each
|
|
68
|
+
* have their own clause in `parseArgv` because they take arguments. Anything
|
|
69
|
+
* else a user types as a first word is a TASK, and offering it here would teach
|
|
70
|
+
* people that acuvo has verbs it does not have.
|
|
71
|
+
*/
|
|
72
|
+
export const SUBCOMMANDS = Object.freeze([
|
|
73
|
+
Object.freeze({ name: 'verify', description: 'Re-check a past claim by running it again. No model call.' }),
|
|
74
|
+
Object.freeze({ name: 'rewind', description: 'Put your files back the way they were before a run.' }),
|
|
75
|
+
Object.freeze({ name: 'leases', description: 'Who holds which file in this workspace, and since when.' }),
|
|
76
|
+
Object.freeze({ name: 'spend', description: 'What runs in this workspace have cost.' }),
|
|
77
|
+
Object.freeze({ name: 'board', description: 'The shared task list several terminals claim work from.' }),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* ⭐ Sub-verbs, kept beside the subcommand that owns them. `board` is the only
|
|
82
|
+
* one with any: `bin/acuvo.mjs:726` accepts `add` and `done`, and a bare `board`
|
|
83
|
+
* lists. Offering a third verb here would produce
|
|
84
|
+
* `unknown board command "…"` from a shell that promised it worked.
|
|
85
|
+
*/
|
|
86
|
+
export const SUBCOMMAND_VERBS = Object.freeze({
|
|
87
|
+
board: Object.freeze(['add', 'done']),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which flags belong to which subcommand, for the shells that can express it.
|
|
92
|
+
* Everything else is offered everywhere, which is what `parseArgv` actually
|
|
93
|
+
* does — it has no per-command flag tables.
|
|
94
|
+
*/
|
|
95
|
+
export const SUBCOMMAND_FLAGS = Object.freeze({
|
|
96
|
+
verify: Object.freeze(['--all', '--json']),
|
|
97
|
+
rewind: Object.freeze(['--force', '--dry-run']),
|
|
98
|
+
spend: Object.freeze(['--since', '--json']),
|
|
99
|
+
leases: Object.freeze(['--json']),
|
|
100
|
+
board: Object.freeze(['--json']),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const modelChoices = () => selectableModels().map((m) => m.name);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* ── THE FLAG TABLE ──────────────────────────────────────────────────────────
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ WRITTEN OUT, NOT SCRAPED FROM `USAGE`. A list derived from help-text
|
|
109
|
+
* formatting fails SILENTLY and completely: one changed indent and the regex
|
|
110
|
+
* matches nothing, the script emits an empty flag list, and TAB quietly stops
|
|
111
|
+
* working with no error anywhere. An explicit table fails LOUDLY instead —
|
|
112
|
+
* the drift test names every flag that is documented and missing here.
|
|
113
|
+
*
|
|
114
|
+
* ⭐ Descriptions ARE scraped, because the failure mode is the opposite: a
|
|
115
|
+
* missing description is cosmetic, and a description copied by hand is a second
|
|
116
|
+
* place for the help text to be wrong.
|
|
117
|
+
*
|
|
118
|
+
* `value: null` = boolean. Otherwise `{ hint, choices?, complete? }`, where
|
|
119
|
+
* `complete` is 'dir' | 'file' and `choices` is a closed set.
|
|
120
|
+
*/
|
|
121
|
+
const flag = (name, value = null, alias = null) => Object.freeze({ name, value, alias });
|
|
122
|
+
|
|
123
|
+
export const FLAGS = Object.freeze([
|
|
124
|
+
flag('--help', null, '-h'),
|
|
125
|
+
flag('--version', null, '-v'),
|
|
126
|
+
|
|
127
|
+
flag('--dir', Object.freeze({ hint: 'directory', complete: 'dir' })),
|
|
128
|
+
flag('--model', Object.freeze({ hint: 'model', choices: Object.freeze(modelChoices()) })),
|
|
129
|
+
/**
|
|
130
|
+
* ⭐ THE ENGINE CHOICE COMPLETES TO THE REAL IDS, and it is one line here
|
|
131
|
+
* because the ids are the only part of an engine this package knows —
|
|
132
|
+
* the CREDIT PRICE of each is an account fact the gateway serves, never
|
|
133
|
+
* something a published npm package may carry. See creative-engines.mjs.
|
|
134
|
+
*
|
|
135
|
+
* ⚠️ Added by the engine-choice lane, whose `--engine` flag made
|
|
136
|
+
* `every documented flag IS offered` go red the moment it was documented.
|
|
137
|
+
*/
|
|
138
|
+
flag('--engine', Object.freeze({ hint: 'engine', choices: Object.freeze(CREATIVE_ENGINES.map((e) => e.id)) })),
|
|
139
|
+
flag('--max-rounds', Object.freeze({ hint: 'rounds' })),
|
|
140
|
+
flag('--max-tokens', Object.freeze({ hint: 'tokens' })),
|
|
141
|
+
flag('--timeout', Object.freeze({ hint: 'seconds' })),
|
|
142
|
+
flag('--command-timeout', Object.freeze({ hint: 'seconds' })),
|
|
143
|
+
/**
|
|
144
|
+
* ⭐ `none` IS OFFERED FIRST because it is the one value a user cannot guess.
|
|
145
|
+
* A $0.02 ceiling is on by default (`budget.mjs:172`), and the only way back
|
|
146
|
+
* to the old unbounded behaviour is a word, not a number.
|
|
147
|
+
*/
|
|
148
|
+
flag('--budget', Object.freeze({ hint: 'usd', choices: Object.freeze(['none', '0.10', '0.50', '1.00', '5.00']) })),
|
|
149
|
+
flag('--fleet-budget', Object.freeze({ hint: 'usd', choices: Object.freeze(['none', '1.00', '5.00', '20.00']) })),
|
|
150
|
+
flag('--budget-window', Object.freeze({ hint: 'period', choices: Object.freeze(['24h', '7d', '30d']) })),
|
|
151
|
+
flag('--since', Object.freeze({ hint: 'period', choices: Object.freeze(['24h', '7d', '30d']) })),
|
|
152
|
+
flag('--lease', Object.freeze({ hint: 'file', complete: 'file' })),
|
|
153
|
+
flag('--holder', Object.freeze({ hint: 'name' })),
|
|
154
|
+
flag('--issue', Object.freeze({ hint: 'number' })),
|
|
155
|
+
flag('--concurrency', Object.freeze({ hint: 'count', choices: Object.freeze(['1', '2', '3', '4']) })),
|
|
156
|
+
flag('--best-of', Object.freeze({ hint: 'count', choices: Object.freeze(['2', '3', '4', '5']) })),
|
|
157
|
+
/** ⚠️ FROM `TIERS`, never typed out — `cli-args.mjs:564` validates against the
|
|
158
|
+
* same array, and a completion offering a fourth tier would be offering a
|
|
159
|
+
* value the parser refuses by name. */
|
|
160
|
+
flag('--max-tier', Object.freeze({ hint: 'tier', choices: TIERS })),
|
|
161
|
+
|
|
162
|
+
flag('--parallel'),
|
|
163
|
+
flag('--until-done'),
|
|
164
|
+
flag('--json'),
|
|
165
|
+
flag('--dry-run'),
|
|
166
|
+
flag('--strict'),
|
|
167
|
+
flag('--offline'),
|
|
168
|
+
flag('--no-run'),
|
|
169
|
+
flag('--no-auto-lease'),
|
|
170
|
+
flag('--no-checkpoint'),
|
|
171
|
+
flag('--force'),
|
|
172
|
+
flag('--claim'),
|
|
173
|
+
flag('--unattended'),
|
|
174
|
+
flag('--refute'),
|
|
175
|
+
flag('--all'),
|
|
176
|
+
flag('--shell'),
|
|
177
|
+
flag('--plan'),
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
// ── descriptions, read out of the help text ────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Rows in `USAGE` look like one of:
|
|
184
|
+
*
|
|
185
|
+
* " --dir <path> Workspace root (default: the current directory)."
|
|
186
|
+
* " --command-timeout <s> Kill a command after this long (default: 120)."
|
|
187
|
+
* " -h, --help This."
|
|
188
|
+
*
|
|
189
|
+
* ⚠️ The middle one is why the separator is not simply `\s{2,}`: that row has a
|
|
190
|
+
* SINGLE space after `<s>`, because the placeholder ate the column. Requiring
|
|
191
|
+
* two spaces silently dropped exactly one description, which is the kind of bug
|
|
192
|
+
* nobody ever reports.
|
|
193
|
+
*/
|
|
194
|
+
const DESCRIPTION_ROW = /^ {2}(-{1,2}[a-z][a-z0-9-]*)(?:,\s+(--[a-z0-9-]+))?(?:(?:\s+<[^>]*>\s+)|\s{2,})(\S.*)$/gm;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {string} [usage]
|
|
198
|
+
* @returns {Map<string, string>} flag spelling → its first line of help
|
|
199
|
+
*/
|
|
200
|
+
export function usageDescriptions(usage = USAGE) {
|
|
201
|
+
const out = new Map();
|
|
202
|
+
for (const m of String(usage ?? '').matchAll(DESCRIPTION_ROW)) {
|
|
203
|
+
const desc = m[3].trim();
|
|
204
|
+
if (m[1]) out.set(m[1], desc);
|
|
205
|
+
if (m[2]) out.set(m[2], desc);
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* ⚠️ A DESCRIPTION IS UNTRUSTED TEXT AS FAR AS THE SHELL IS CONCERNED. It comes
|
|
212
|
+
* from `USAGE`, which is ours — but it contains backticks (`` `acuvo rewind` ``),
|
|
213
|
+
* apostrophes, `$`, colons and brackets, and each of those breaks a DIFFERENT
|
|
214
|
+
* one of the three shells: a backtick is command substitution in bash, an
|
|
215
|
+
* apostrophe closes the single-quoted string it sits in, and `[`/`]`/`:` are the
|
|
216
|
+
* field separators of a zsh `_arguments` spec. Generating a script that a user
|
|
217
|
+
* SOURCES means a stray character is not a cosmetic bug.
|
|
218
|
+
*
|
|
219
|
+
* So the set is narrowed to something safe in all three rather than escaped
|
|
220
|
+
* three different ways — one function to audit instead of three.
|
|
221
|
+
*/
|
|
222
|
+
export function safeDescription(text, max = 68) {
|
|
223
|
+
let s = String(text ?? '');
|
|
224
|
+
s = s.replace(/[`\\$"']/g, '');
|
|
225
|
+
s = s.replace(/[[\]]/g, '');
|
|
226
|
+
s = s.replace(/:/g, ' -');
|
|
227
|
+
s = s.replace(/\s+/g, ' ').trim();
|
|
228
|
+
/**
|
|
229
|
+
* ⚠️ TRUNCATE AT A WORD BOUNDARY. A blind `slice` cuts a flag name in half,
|
|
230
|
+
* and a description reading "…see --fleet-bud" puts a string that looks like a
|
|
231
|
+
* flag and is not into a file whose whole job is to be right about flags.
|
|
232
|
+
*/
|
|
233
|
+
if (s.length > max) {
|
|
234
|
+
const cut = s.slice(0, max - 1);
|
|
235
|
+
const space = cut.lastIndexOf(' ');
|
|
236
|
+
s = `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
237
|
+
}
|
|
238
|
+
return s;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Every spelling the scripts should offer, aliases included. */
|
|
242
|
+
export function allFlagNames(extraFlags = []) {
|
|
243
|
+
const names = [];
|
|
244
|
+
for (const f of FLAGS) {
|
|
245
|
+
if (f.alias) names.push(f.alias);
|
|
246
|
+
names.push(f.name);
|
|
247
|
+
}
|
|
248
|
+
for (const extra of extraFlags) {
|
|
249
|
+
const name = String(extra ?? '').trim();
|
|
250
|
+
if (name && !names.includes(name)) names.push(name);
|
|
251
|
+
}
|
|
252
|
+
return names;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* ── ⚠️ HAND-WRITTEN DESCRIPTIONS, FOR THE FLAGS `--help` DOES NOT DOCUMENT ───
|
|
257
|
+
*
|
|
258
|
+
* Four real, parseable flags have no row in `USAGE`: `-v`, `--strict`,
|
|
259
|
+
* `--offline`, `--all` and `--since` (the last two are mentioned in prose but
|
|
260
|
+
* not as rows). Leaving them described as "a flag" in three shells is worse
|
|
261
|
+
* than writing the sentence.
|
|
262
|
+
*
|
|
263
|
+
* ⚠️ THIS IS A DRIFT SURFACE AND IT IS FENCED. The test asserts that no entry
|
|
264
|
+
* here names a flag `USAGE` documents — so the day someone adds `--strict` to
|
|
265
|
+
* the help text, the suite says "delete the fallback" instead of letting two
|
|
266
|
+
* descriptions of one flag quietly disagree.
|
|
267
|
+
*
|
|
268
|
+
* ⭐ AND THE FENCE FIRED, 2026-08-19. `--version` was added to `USAGE` — it had
|
|
269
|
+
* been parsed, working, and mentioned nowhere a person reads — and this test
|
|
270
|
+
* immediately named the now-shadowed fallback. The entry below is deleted, so
|
|
271
|
+
* the description a shell shows comes from the same string `--help` prints.
|
|
272
|
+
* `-v` stays: it is a distinct name with no row of its own.
|
|
273
|
+
*/
|
|
274
|
+
const FALLBACK_DESCRIPTIONS = Object.freeze({
|
|
275
|
+
'-v': 'Print the version and exit.',
|
|
276
|
+
'--strict': 'Exit 1 if the run wrote nothing and ran nothing. Armed automatically in CI.',
|
|
277
|
+
/**
|
|
278
|
+
* ⚠️ THIS SENTENCE USED TO SAY "With --doctor: …" AND THE DRIFT TEST CAUGHT IT.
|
|
279
|
+
* A description is pasted verbatim into the generated script, so a flag name
|
|
280
|
+
* inside one is a flag the script appears to offer — and `--doctor` is parsed
|
|
281
|
+
* by `bin/`, not by `parseArgv`, so zsh and fish were advertising a flag the
|
|
282
|
+
* parser answers "Unknown option" to. Descriptions must not name flags.
|
|
283
|
+
*/
|
|
284
|
+
'--offline': 'Skip every network probe when reporting what works here.',
|
|
285
|
+
'--all': 'With acuvo verify: re-check every recorded claim, deduplicated by command.',
|
|
286
|
+
'--since': 'With acuvo spend: only runs since this period.',
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const describe = (name, descriptions) => safeDescription(descriptions.get(name) ?? FALLBACK_DESCRIPTIONS[name] ?? '');
|
|
290
|
+
|
|
291
|
+
/** Exported so the drift test can prove no fallback shadows a documented flag. */
|
|
292
|
+
export const fallbackDescriptions = () => ({ ...FALLBACK_DESCRIPTIONS });
|
|
293
|
+
|
|
294
|
+
const identifier = (command) => `_${String(command).replace(/[^A-Za-z0-9_]/g, '_')}`;
|
|
295
|
+
|
|
296
|
+
// ── bash ───────────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* @param {{ command?: string, extraFlags?: readonly string[] }} [opts]
|
|
300
|
+
* @returns {string}
|
|
301
|
+
*/
|
|
302
|
+
export function bashCompletion({ command = 'acuvo', extraFlags = [] } = {}) {
|
|
303
|
+
const fn = `${identifier(command)}_complete`;
|
|
304
|
+
const flags = allFlagNames(extraFlags).join(' ');
|
|
305
|
+
const subs = SUBCOMMANDS.map((s) => s.name).join(' ');
|
|
306
|
+
|
|
307
|
+
const arms = [];
|
|
308
|
+
const silent = [];
|
|
309
|
+
for (const f of FLAGS) {
|
|
310
|
+
if (!f.value) continue;
|
|
311
|
+
const spellings = f.alias ? `${f.alias}|${f.name}` : f.name;
|
|
312
|
+
if (f.value.complete === 'dir') {
|
|
313
|
+
arms.push(` ${spellings}) COMPREPLY=( $(compgen -d -- "$cur") ); return 0 ;;`);
|
|
314
|
+
} else if (f.value.complete === 'file') {
|
|
315
|
+
arms.push(` ${spellings}) COMPREPLY=( $(compgen -f -- "$cur") ); return 0 ;;`);
|
|
316
|
+
} else if (f.value.choices?.length) {
|
|
317
|
+
arms.push(` ${spellings}) COMPREPLY=( $(compgen -W "${f.value.choices.join(' ')}" -- "$cur") ); return 0 ;;`);
|
|
318
|
+
} else {
|
|
319
|
+
silent.push(spellings);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* ⚠️ THIS ARM IS THE POINT OF THE WHOLE `case`. Without it, `--holder <TAB>`
|
|
324
|
+
* falls through to the flag list and offers `--json` as a holder name.
|
|
325
|
+
* Offering nothing is the honest answer for a value only the user knows.
|
|
326
|
+
*/
|
|
327
|
+
if (silent.length) arms.push(` ${silent.join('|')}) return 0 ;;`);
|
|
328
|
+
|
|
329
|
+
const verbArms = Object.entries(SUBCOMMAND_VERBS).map(
|
|
330
|
+
([sub, verbs]) => ` ${sub}) COMPREPLY=( $(compgen -W "${verbs.join(' ')}" -- "$cur") ); return 0 ;;`,
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
return [
|
|
334
|
+
`# ${command} completion for bash. Generated by \`${command} completion bash\`.`,
|
|
335
|
+
`# Install: ${command} completion bash > /etc/bash_completion.d/${command}`,
|
|
336
|
+
`# or: ${command} completion bash >> ~/.bashrc`,
|
|
337
|
+
'',
|
|
338
|
+
`${fn}() {`,
|
|
339
|
+
' local cur prev',
|
|
340
|
+
' COMPREPLY=()',
|
|
341
|
+
' cur="${COMP_WORDS[COMP_CWORD]}"',
|
|
342
|
+
' prev="${COMP_WORDS[COMP_CWORD-1]}"',
|
|
343
|
+
'',
|
|
344
|
+
' # A flag that takes a value completes the VALUE and stops. Falling through',
|
|
345
|
+
' # here would offer the flag list, and `--holder --json` is a lease held by',
|
|
346
|
+
' # something called "--json".',
|
|
347
|
+
' case "$prev" in',
|
|
348
|
+
...arms,
|
|
349
|
+
' esac',
|
|
350
|
+
'',
|
|
351
|
+
' if [ "$COMP_CWORD" -eq 1 ]; then',
|
|
352
|
+
` COMPREPLY=( $(compgen -W "${subs} ${flags}" -- "$cur") )`,
|
|
353
|
+
' return 0',
|
|
354
|
+
' fi',
|
|
355
|
+
'',
|
|
356
|
+
' if [ "$COMP_CWORD" -eq 2 ]; then',
|
|
357
|
+
' case "${COMP_WORDS[1]}" in',
|
|
358
|
+
...verbArms,
|
|
359
|
+
' esac',
|
|
360
|
+
' fi',
|
|
361
|
+
'',
|
|
362
|
+
` COMPREPLY=( $(compgen -W "${flags}" -- "$cur") )`,
|
|
363
|
+
' return 0',
|
|
364
|
+
'}',
|
|
365
|
+
`complete -F ${fn} ${command}`,
|
|
366
|
+
'',
|
|
367
|
+
].join('\n');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── zsh ────────────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @param {{ command?: string, extraFlags?: readonly string[] }} [opts]
|
|
374
|
+
* @returns {string}
|
|
375
|
+
*/
|
|
376
|
+
export function zshCompletion({ command = 'acuvo', extraFlags = [] } = {}) {
|
|
377
|
+
const fn = identifier(command);
|
|
378
|
+
const descriptions = usageDescriptions();
|
|
379
|
+
|
|
380
|
+
const specs = [];
|
|
381
|
+
const push = (name) => {
|
|
382
|
+
const f = FLAGS.find((x) => x.name === name || x.alias === name);
|
|
383
|
+
const d = describe(name, descriptions);
|
|
384
|
+
const head = d ? `${name}[${d}]` : `${name}[a flag]`;
|
|
385
|
+
if (!f?.value) { specs.push(`'${head}'`); return; }
|
|
386
|
+
const { hint, choices, complete } = f.value;
|
|
387
|
+
if (complete === 'dir') specs.push(`'${head}:${hint}:_files -/'`);
|
|
388
|
+
else if (complete === 'file') specs.push(`'${head}:${hint}:_files'`);
|
|
389
|
+
else if (choices?.length) specs.push(`'${head}:${hint}:(${choices.join(' ')})'`);
|
|
390
|
+
// ⚠️ An EMPTY action, deliberately: zsh then completes nothing for this
|
|
391
|
+
// value, which is right for a dollar amount or an issue number. An action
|
|
392
|
+
// of `_default` would offer filenames, i.e. `--budget ./src`.
|
|
393
|
+
else specs.push(`'${head}:${hint}:'`);
|
|
394
|
+
};
|
|
395
|
+
for (const name of allFlagNames(extraFlags)) push(name);
|
|
396
|
+
|
|
397
|
+
const cmds = SUBCOMMANDS.map((s) => ` '${s.name}:${safeDescription(s.description)}'`);
|
|
398
|
+
const verbCases = Object.entries(SUBCOMMAND_VERBS).map(
|
|
399
|
+
([sub, verbs]) => ` ${sub}) _values '${sub} command' ${verbs.map((v) => `'${v}'`).join(' ')} ;;`,
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
return [
|
|
403
|
+
`#compdef ${command}`,
|
|
404
|
+
`# ${command} completion for zsh. Generated by \`${command} completion zsh\`.`,
|
|
405
|
+
`# Install: ${command} completion zsh > "\${fpath[1]}/_${command}"`,
|
|
406
|
+
'',
|
|
407
|
+
`${fn}() {`,
|
|
408
|
+
' local state',
|
|
409
|
+
' local -a acuvo_cmds',
|
|
410
|
+
' acuvo_cmds=(',
|
|
411
|
+
...cmds,
|
|
412
|
+
' )',
|
|
413
|
+
'',
|
|
414
|
+
' _arguments -s -S \\',
|
|
415
|
+
...specs.map((s) => ` ${s} \\`),
|
|
416
|
+
" '1:command or task:->acuvo_cmd' \\",
|
|
417
|
+
" '*::arguments:->acuvo_args'",
|
|
418
|
+
'',
|
|
419
|
+
' case $state in',
|
|
420
|
+
' acuvo_cmd)',
|
|
421
|
+
" _describe -t commands 'acuvo command' acuvo_cmds",
|
|
422
|
+
" _message 'or the task to do, in quotes'",
|
|
423
|
+
' ;;',
|
|
424
|
+
' acuvo_args)',
|
|
425
|
+
' case $words[1] in',
|
|
426
|
+
...verbCases,
|
|
427
|
+
' esac',
|
|
428
|
+
' ;;',
|
|
429
|
+
' esac',
|
|
430
|
+
'}',
|
|
431
|
+
'',
|
|
432
|
+
`compdef ${fn} ${command}`,
|
|
433
|
+
'',
|
|
434
|
+
].join('\n');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── fish ───────────────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* @param {{ command?: string, extraFlags?: readonly string[] }} [opts]
|
|
441
|
+
* @returns {string}
|
|
442
|
+
*/
|
|
443
|
+
export function fishCompletion({ command = 'acuvo', extraFlags = [] } = {}) {
|
|
444
|
+
const descriptions = usageDescriptions();
|
|
445
|
+
const lines = [
|
|
446
|
+
`# ${command} completion for fish. Generated by \`${command} completion fish\`.`,
|
|
447
|
+
`# Install: ${command} completion fish > ~/.config/fish/completions/${command}.fish`,
|
|
448
|
+
'',
|
|
449
|
+
// ⚠️ FIRST LINE OF BEHAVIOUR, NOT DECORATION. Without `-f`, fish completes
|
|
450
|
+
// filenames for every argument of every flag, so `--budget <TAB>` offers the
|
|
451
|
+
// contents of the directory. Files are re-enabled per flag with `-F`.
|
|
452
|
+
`complete -c ${command} -f`,
|
|
453
|
+
'',
|
|
454
|
+
];
|
|
455
|
+
|
|
456
|
+
for (const s of SUBCOMMANDS) {
|
|
457
|
+
lines.push(`complete -c ${command} -n '__fish_use_subcommand' -a '${s.name}' -d '${safeDescription(s.description)}'`);
|
|
458
|
+
}
|
|
459
|
+
for (const [sub, verbs] of Object.entries(SUBCOMMAND_VERBS)) {
|
|
460
|
+
lines.push(`complete -c ${command} -n '__fish_seen_subcommand_from ${sub}' -a '${verbs.join(' ')}' -d 'a ${sub} command'`);
|
|
461
|
+
}
|
|
462
|
+
lines.push('');
|
|
463
|
+
|
|
464
|
+
for (const f of FLAGS) {
|
|
465
|
+
const d = describe(f.name, descriptions);
|
|
466
|
+
const parts = [`complete -c ${command}`];
|
|
467
|
+
if (f.alias) parts.push(`-s ${f.alias.replace(/^-/, '')}`);
|
|
468
|
+
parts.push(`-l ${f.name.replace(/^--/, '')}`);
|
|
469
|
+
if (f.value?.complete === 'dir') parts.push("-x -a '(__fish_complete_directories)'");
|
|
470
|
+
else if (f.value?.complete === 'file') parts.push('-r -F');
|
|
471
|
+
else if (f.value?.choices?.length) parts.push(`-x -a '${f.value.choices.join(' ')}'`);
|
|
472
|
+
// `-x` = takes an argument AND no file completion for it. See the `-f` note.
|
|
473
|
+
else if (f.value) parts.push('-x');
|
|
474
|
+
if (d) parts.push(`-d '${d}'`);
|
|
475
|
+
lines.push(parts.join(' '));
|
|
476
|
+
}
|
|
477
|
+
for (const extra of extraFlags) {
|
|
478
|
+
const name = String(extra ?? '').trim();
|
|
479
|
+
if (!name.startsWith('--')) continue;
|
|
480
|
+
lines.push(`complete -c ${command} -l ${name.slice(2)}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
lines.push('');
|
|
484
|
+
return lines.join('\n');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── the one entry point ────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* @param {string} shell
|
|
491
|
+
* @param {{ command?: string, extraFlags?: readonly string[] }} [opts]
|
|
492
|
+
* @returns {{ ok: true, script: string } | { ok: false, error: string }}
|
|
493
|
+
*/
|
|
494
|
+
export function completionScript(shell, opts = {}) {
|
|
495
|
+
const name = String(shell ?? '').trim().toLowerCase();
|
|
496
|
+
if (!name) {
|
|
497
|
+
return {
|
|
498
|
+
ok: false,
|
|
499
|
+
/**
|
|
500
|
+
* ⚠️ NO `$SHELL` SNIFFING, AND THE REFUSAL SAYS SO. `$SHELL` is the LOGIN
|
|
501
|
+
* shell, not the one you are typing into — a zsh user in a bash subshell
|
|
502
|
+
* would be handed the wrong script and it would fail silently, which is
|
|
503
|
+
* the one outcome a completion script must never produce. Naming the
|
|
504
|
+
* shell costs one word and cannot be wrong.
|
|
505
|
+
*/
|
|
506
|
+
error: `name the shell: ${SUPPORTED_SHELLS.map((s) => `${opts.command ?? 'acuvo'} completion ${s}`).join(' · ')}`,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
if (!SUPPORTED_SHELLS.includes(name)) {
|
|
510
|
+
return { ok: false, error: `no completion for ${JSON.stringify(name)} — this emits ${SUPPORTED_SHELLS.join(', ')}. Pick the closest one; bash output works in any POSIX shell with bash-completion loaded.` };
|
|
511
|
+
}
|
|
512
|
+
if (name === 'bash') return { ok: true, script: bashCompletion(opts) };
|
|
513
|
+
if (name === 'zsh') return { ok: true, script: zshCompletion(opts) };
|
|
514
|
+
return { ok: true, script: fishCompletion(opts) };
|
|
515
|
+
}
|