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
@@ -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
+ }