@crustjs/extensions 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/dist/index.js ADDED
@@ -0,0 +1,1857 @@
1
+ import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { CrustError, defineCommand, defineExtension, defineExtensionId } from "@crustjs/core";
4
+ import { buildCommandDocumentation, formatDescription, isListed, sectionsFor } from "@crustjs/core/tooling";
5
+ import { stripVTControlCharacters } from "node:util";
6
+ import { bold, cyan, dim, green, padEnd, stringWidth, yellow } from "@crustjs/style";
7
+ //#region src/completion/escape.ts
8
+ /**
9
+ * Per-shell quoting and validation helpers used by the completion
10
+ * templates and by the extension's output-path handling.
11
+ *
12
+ * Why this exists: the completion templates inline a lot of CLI-author
13
+ * provided text — command names, flag names, descriptions, choice values,
14
+ * `binName`, `version` — into emitted shell scripts. Those scripts are
15
+ * routinely installed via `eval "$(mycli completion bash)"` (it is the
16
+ * documented install path), so any unsanitised interpolation is at
17
+ * minimum a foot-gun and at worst arbitrary code execution at install
18
+ * time. Centralising the escaping rules in one module keeps the templates
19
+ * pure rendering code and gives us a single place to test the adversarial
20
+ * inputs.
21
+ *
22
+ * Two complementary strategies are used:
23
+ *
24
+ * 1. **Validate identifiers** — command/flag/alias/bin names are
25
+ * programmer-controlled identifiers in the source CLI definition.
26
+ * They have no business containing whitespace, quotes, semicolons, or
27
+ * control characters. We reject those at spec/render time with a
28
+ * clear error rather than try to escape them through three different
29
+ * shell grammars.
30
+ * 2. **Escape free-form text** — descriptions, choice values, version
31
+ * strings, and the embedded comment headers are user-facing prose.
32
+ * Those go through per-shell escape helpers so the templates can
33
+ * interpolate them safely.
34
+ */
35
+ /**
36
+ * Choice-value shape accepted for `flags[].choices` and `args[].choices`.
37
+ *
38
+ * Looser than {@link IDENT_PATTERN} so legitimate enumerated values like
39
+ * `us-east-1`, `1.0`, `text/plain`, or `node@20` flow through unchanged,
40
+ * but still excludes whitespace, quotes, and shell metacharacters that
41
+ * would force per-shell escaping inside the emitted action lists
42
+ * (`compgen -W`, zsh `(...)` action, fish `-a`).
43
+ *
44
+ * If a CLI legitimately needs choice values containing whitespace or
45
+ * shell-special characters, we'd need a richer per-shell escaping scheme;
46
+ * that's out of scope for v1. We fail fast with a clear message rather
47
+ * than silently mis-quote.
48
+ */
49
+ const CHOICE_VALUE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.+:@/-]*$/;
50
+ function assertSafeChoiceValue(value) {
51
+ if (!CHOICE_VALUE_PATTERN.test(value)) throw new Error(`completion extension: unsupported choice value ${JSON.stringify(value)} — must match /^[A-Za-z0-9_.+:@/-]+$/. Whitespace and shell metacharacters are not supported in v1.`);
52
+ return value;
53
+ }
54
+ /**
55
+ * Identifier shape accepted for command names, flag names, flag aliases,
56
+ * short flags, and arg names.
57
+ *
58
+ * - First char must be alphanumeric (avoids leading `-` which `complete`
59
+ * would interpret as an option in bash/fish).
60
+ * - Subsequent chars: alphanumeric, `_`, `.`, or `-`.
61
+ * - Single-character names are allowed (covers single-char short flags).
62
+ *
63
+ * This is deliberately conservative. CLI authors who want a command
64
+ * named `foo:bar` or `it's` are out of scope for v1 — every shell would
65
+ * need bespoke escaping for `case` patterns, `compdef`, and fish
66
+ * predicate code.
67
+ */
68
+ const IDENT_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*)?$/;
69
+ /** Throw if `name` is not a safe identifier; otherwise return it. */
70
+ function assertSafeIdentifier(name, kind) {
71
+ if (!IDENT_PATTERN.test(name)) throw new Error(`completion extension: invalid ${kind} ${JSON.stringify(name)} — must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/. Whitespace, quotes, and shell metacharacters are not supported.`);
72
+ return name;
73
+ }
74
+ /** Map a validated CLI identifier to a shell function identifier. */
75
+ function toShellIdent(name) {
76
+ return name.replace(/[^A-Za-z0-9_]/g, "_");
77
+ }
78
+ /**
79
+ * Validate `binName` for use as the program name in generated scripts and
80
+ * as a filesystem basename when `--output-dir` is set.
81
+ *
82
+ * Stricter than {@link assertSafeIdentifier} because `binName` also
83
+ * becomes a filename and a `complete -F`/`compdef` argument that's
84
+ * easier to break than option names.
85
+ */
86
+ function assertSafeBinName(binName) {
87
+ if (binName.length === 0) throw new Error("completion extension: binName must not be empty");
88
+ if (binName.includes("/") || binName.includes("\\") || binName === ".." || binName === ".") throw new Error(`completion extension: invalid binName ${JSON.stringify(binName)} — path separators and "."/".." are not allowed (used as a filename in --output-dir mode).`);
89
+ return assertSafeIdentifier(binName, "binName");
90
+ }
91
+ /**
92
+ * Strip control characters from `value` so it can be safely embedded in
93
+ * a shell comment line or shell-quoted string without smuggling a
94
+ * newline that would terminate the comment / break the quote nesting.
95
+ *
96
+ * Replaces NUL, CR, LF, vertical-tab, form-feed, and other C0/C1 controls
97
+ * with a single space. We keep horizontal tab as-is (descriptions
98
+ * occasionally use it for alignment).
99
+ */
100
+ function sanitizeFreeText(value) {
101
+ return value.replace(/[\x00-\x08\x0A-\x1F\x7F]/g, " ");
102
+ }
103
+ /**
104
+ * Wrap `value` as a bash single-quoted shell word.
105
+ *
106
+ * Single quotes have no escape sequence in bash, so embedded single quotes
107
+ * close-and-reopen the quote: `'foo'\''bar'`. This is the canonical
108
+ * `printf %q`-style safe form: the result is always exactly one shell
109
+ * token regardless of the input bytes (after sanitisation).
110
+ *
111
+ * Callers should pass values that have already had control characters
112
+ * scrubbed via {@link sanitizeFreeText} when the value is free-form
113
+ * (description, version, choice value).
114
+ */
115
+ function bashSingleQuote(value) {
116
+ return `'${value.replace(/'/g, "'\\''")}'`;
117
+ }
118
+ /**
119
+ * Escape a single value for inclusion as a literal-matched key in a bash
120
+ * `case` pattern, when the entire pattern is wrapped in double quotes.
121
+ *
122
+ * Inside `"..."` quotes, bash treats `*?[]{}|()` as literals, so the only
123
+ * remaining concern is the double-quote characters in the active set:
124
+ * `\`, `$`, `` ` ``, `"`. We escape those.
125
+ *
126
+ * The result is meant to be placed inside `"..."`; callers add the outer
127
+ * quotes themselves so they can build patterns like
128
+ * `"<path>|<word>"` from multiple escaped pieces.
129
+ */
130
+ function bashDoubleQuoteInner(value) {
131
+ return value.replace(/[\\$`"]/g, "\\$&");
132
+ }
133
+ /**
134
+ * Escape free-form text for use inside the `[...]` description bracket of
135
+ * a zsh `_arguments` spec.
136
+ *
137
+ * `_arguments` parses spec strings with `:` as the field separator and
138
+ * `[...]` as the description bracket; backslash escapes both. We also
139
+ * scrub newlines (descriptions are one-liners in completion menus) and
140
+ * single quotes (the spec is wrapped in single quotes by the caller).
141
+ */
142
+ function zshArgsDescription(value) {
143
+ return value.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/]/g, "\\]").replace(/:/g, "\\:").replace(/'/g, "'\\''").replace(/[\r\n]+/g, " ");
144
+ }
145
+ /**
146
+ * Escape a value for inclusion as the **name** field of a `_describe`
147
+ * item (the part before the `:` description separator). `_describe`
148
+ * splits each item on the first un-escaped `:`, so embedded colons
149
+ * must be escaped. Backslashes also need escaping because they're the
150
+ * escape character.
151
+ *
152
+ * The result is meant to be placed inside zsh single quotes by the
153
+ * caller (we do NOT include outer quotes); call {@link bashSingleQuote}
154
+ * on the assembled `name:desc` string when emitting.
155
+ */
156
+ function zshDescribeField(value) {
157
+ return value.replace(/\\/g, "\\\\").replace(/:/g, "\\:").replace(/[\r\n]+/g, " ");
158
+ }
159
+ /**
160
+ * Wrap `value` as a fish single-quoted shell word. Fish single quotes
161
+ * only escape `\\` and `\'`; everything else is literal.
162
+ */
163
+ function fishSingleQuote(value) {
164
+ return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
165
+ }
166
+ //#endregion
167
+ //#region src/completion/templates/bash.ts
168
+ /**
169
+ * Pure-static bash completion script renderer.
170
+ *
171
+ * Strategy: at generation time we walk the command tree and emit a
172
+ * self-contained bash function that performs the full completion logic
173
+ * locally — no `__complete` subprocess shell-out, no runtime callbacks.
174
+ *
175
+ * The generated script:
176
+ *
177
+ * 1. Defines a Cobra-style fallback init shim
178
+ * (`__<bin>_init_completion`) so the script works even when the
179
+ * `bash-completion` package is not installed (macOS default bash,
180
+ * Alpine, NixOS without the package).
181
+ * 2. Walks `COMP_WORDS` left-to-right, advancing a `cmd_path` through
182
+ * the static command tree. Stops walking at the `--` end-of-options
183
+ * terminator and skips value-taking flag pairs so we don't mistake a
184
+ * flag value for a subcommand.
185
+ * 3. Once the path is resolved, picks completion candidates:
186
+ * - if the user is mid-`--name=value`, splits on `=` and offers the
187
+ * static value list (or files) for that flag,
188
+ * - else if the previous token is a known flag-with-choices, offers
189
+ * the static value list,
190
+ * - else if the previous token is a known free-form value flag,
191
+ * falls back to file completion,
192
+ * - else if the current token starts with `-`, offers the flag set,
193
+ * - else offers the subcommand list and the resolved command's
194
+ * positional choices (or files for free-form positionals).
195
+ * 4. Registers via `complete -F _<bin> <bin>` with the bin name passed
196
+ * through {@link bashSingleQuote}.
197
+ */
198
+ /**
199
+ * Render the wordlist of subcommand candidates for a single command —
200
+ * each candidate as a bash-quoted shell word so values containing
201
+ * spaces (theoretical: identifier validation rejects them) or shell
202
+ * metacharacters (theoretical: same) survive `compgen -W` splitting.
203
+ *
204
+ * Includes canonical names and any declared aliases.
205
+ */
206
+ function subcmdWordlist(node) {
207
+ const words = [];
208
+ for (const sub of node.subCommands) {
209
+ words.push(sub.name);
210
+ if (sub.aliases !== void 0) for (const alias of sub.aliases) words.push(alias);
211
+ }
212
+ return words.join(" ");
213
+ }
214
+ /**
215
+ * Render the wordlist of flag candidates for a single command. Includes
216
+ * long names, short alias, extra long aliases, and `--no-<name>` for
217
+ * boolean flags whose snapshot marks them as negatable.
218
+ */
219
+ function flagWordlist(node) {
220
+ const words = [];
221
+ for (const flag of node.flags) {
222
+ words.push(`--${flag.name}`);
223
+ if (flag.short !== void 0) words.push(`-${flag.short}`);
224
+ if (flag.aliases !== void 0) for (const alias of flag.aliases) words.push(`--${alias}`);
225
+ if (flag.negatable) {
226
+ words.push(`--no-${flag.name}`);
227
+ if (flag.aliases !== void 0) for (const alias of flag.aliases) words.push(`--no-${alias}`);
228
+ }
229
+ }
230
+ return words.join(" ");
231
+ }
232
+ /**
233
+ * Recursively collect every (parent-path, child-word) edge in the command
234
+ * tree as a `case` branch the dispatch loop can consume. Aliases are
235
+ * surfaced as additional `case` keys that resolve to the same `cmd_path`,
236
+ * matching the router's alias-aware behaviour.
237
+ */
238
+ function collectPathCases(parentPath, parent, out) {
239
+ for (const sub of parent.subCommands) {
240
+ const newPath = parentPath === "" ? sub.name : `${parentPath}:${sub.name}`;
241
+ const subcmds = subcmdWordlist(sub);
242
+ const flags = flagWordlist(sub);
243
+ const valueFlags = valueFlagWordlist(sub);
244
+ out.push({
245
+ key: `${parentPath}|${sub.name}`,
246
+ cmdPath: newPath,
247
+ subcmds,
248
+ flags,
249
+ valueFlags
250
+ });
251
+ if (sub.aliases !== void 0) for (const alias of sub.aliases) out.push({
252
+ key: `${parentPath}|${alias}`,
253
+ cmdPath: newPath,
254
+ subcmds,
255
+ flags,
256
+ valueFlags
257
+ });
258
+ collectPathCases(newPath, sub, out);
259
+ }
260
+ }
261
+ /**
262
+ * Walk every flag at every depth and emit one {@link ValueTypeCase} per
263
+ * spelling for flags that declared `type: "path"` (file completion) or
264
+ * `type: "url" | "json"` (suppress file fallback).
265
+ */
266
+ function collectValueTypeCases(cmdPath, node, out) {
267
+ for (const flag of node.flags) {
268
+ if (flag.valueCompletion === void 0) continue;
269
+ const kind = flag.valueCompletion === "files" ? "path" : "suppress";
270
+ for (const spelling of flagSpellings(flag)) out.push({
271
+ key: `${cmdPath}|${spelling}`,
272
+ kind
273
+ });
274
+ }
275
+ for (const sub of node.subCommands) collectValueTypeCases(cmdPath === "" ? sub.name : `${cmdPath}:${sub.name}`, sub, out);
276
+ }
277
+ /**
278
+ * For every flag at every command depth that declares `choices`, emit a
279
+ * `case` branch mapping `<path>|<flag-spelling>` → values. Each spelling
280
+ * (long, short, alias) gets its own branch so the lookup is constant-time
281
+ * regardless of how the user wrote the flag.
282
+ */
283
+ function collectChoiceCases(cmdPath, node, out) {
284
+ for (const flag of node.flags) {
285
+ if (flag.choices === void 0) continue;
286
+ const values = flag.choices.join(" ");
287
+ const spellings = flagSpellings(flag);
288
+ for (const spelling of spellings) out.push({
289
+ key: `${cmdPath}|${spelling}`,
290
+ values
291
+ });
292
+ }
293
+ for (const sub of node.subCommands) collectChoiceCases(cmdPath === "" ? sub.name : `${cmdPath}:${sub.name}`, sub, out);
294
+ }
295
+ /**
296
+ * Collect per-command positional suppression entries. Each url/json
297
+ * positional contributes one slot index; a variadic url/json positional
298
+ * also sets `variadicFrom` so suppression extends past the declared slot.
299
+ */
300
+ function collectArgSuppressCases(cmdPath, node, out) {
301
+ const slots = [];
302
+ let variadicFrom;
303
+ node.args.forEach((arg, idx) => {
304
+ if (arg.valueCompletion !== "none") return;
305
+ if (arg.variadic) variadicFrom = idx;
306
+ else slots.push(idx);
307
+ });
308
+ if (slots.length > 0 || variadicFrom !== void 0) out.push({
309
+ cmdPath,
310
+ slots,
311
+ variadicFrom
312
+ });
313
+ for (const sub of node.subCommands) collectArgSuppressCases(cmdPath === "" ? sub.name : `${cmdPath}:${sub.name}`, sub, out);
314
+ }
315
+ /**
316
+ * Collect per-command positional choice entries, recursively. Returns
317
+ * one {@link ArgChoiceEntry} per command that has at least one positional
318
+ * arg with a `choices` list (variadic or otherwise). Commands with no
319
+ * positional choices are omitted so the rendered case-block stays
320
+ * tight.
321
+ */
322
+ function collectArgChoiceCases(cmdPath, node, out) {
323
+ const bySlot = [];
324
+ let variadicFrom;
325
+ let variadicValues;
326
+ let hasAny = false;
327
+ node.args.forEach((arg, idx) => {
328
+ if (arg.variadic) {
329
+ if (arg.choices !== void 0) {
330
+ variadicFrom = idx;
331
+ variadicValues = arg.choices.join(" ");
332
+ hasAny = true;
333
+ }
334
+ return;
335
+ }
336
+ if (arg.choices !== void 0) {
337
+ bySlot[idx] = arg.choices.join(" ");
338
+ hasAny = true;
339
+ } else bySlot[idx] = void 0;
340
+ });
341
+ if (hasAny) out.push({
342
+ cmdPath,
343
+ bySlot,
344
+ variadicFrom,
345
+ variadicValues
346
+ });
347
+ for (const sub of node.subCommands) collectArgChoiceCases(cmdPath === "" ? sub.name : `${cmdPath}:${sub.name}`, sub, out);
348
+ }
349
+ function flagSpellings(flag) {
350
+ const out = [`--${flag.name}`];
351
+ if (flag.short !== void 0) out.push(`-${flag.short}`);
352
+ if (flag.aliases !== void 0) for (const alias of flag.aliases) out.push(`--${alias}`);
353
+ return out;
354
+ }
355
+ /**
356
+ * Render the wordlist of *value-taking* flag spellings for a single
357
+ * command. Used to drive both flag-value context (after `--target`) and
358
+ * the path walker's "skip the next token" heuristic.
359
+ */
360
+ function valueFlagWordlist(node) {
361
+ const words = [];
362
+ for (const flag of node.flags) {
363
+ if (!flag.takesValue) continue;
364
+ for (const spelling of flagSpellings(flag)) words.push(spelling);
365
+ }
366
+ return words.join(" ");
367
+ }
368
+ /**
369
+ * Render a self-contained bash completion script for the given spec.
370
+ *
371
+ * @param spec The walker output describing the command tree.
372
+ * @param binName The user-facing binary name. Validated via
373
+ * {@link assertSafeBinName} upstream.
374
+ * @param version Free-form version string for the header comment;
375
+ * control characters are stripped before emission.
376
+ */
377
+ function renderBash(spec, binName, version) {
378
+ const ident = toShellIdent(binName);
379
+ const fnName = `_${ident}`;
380
+ const initFn = `__${ident}_init_completion`;
381
+ const rootSubcmds = subcmdWordlist(spec);
382
+ const rootFlags = flagWordlist(spec);
383
+ const rootValueFlags = valueFlagWordlist(spec);
384
+ const pathCases = [];
385
+ collectPathCases("", spec, pathCases);
386
+ const choiceCases = [];
387
+ collectChoiceCases("", spec, choiceCases);
388
+ const valueTypeCases = [];
389
+ collectValueTypeCases("", spec, valueTypeCases);
390
+ const argChoiceEntries = [];
391
+ collectArgChoiceCases("", spec, argChoiceEntries);
392
+ const argSuppressEntries = [];
393
+ collectArgSuppressCases("", spec, argSuppressEntries);
394
+ const lines = [];
395
+ lines.push(`# completion script for ${binName} v${version} — regenerate with: ${binName} completion bash`);
396
+ lines.push("");
397
+ lines.push(`${initFn}() {`);
398
+ lines.push(" COMPREPLY=()");
399
+ lines.push(" cur=\"${COMP_WORDS[COMP_CWORD]}\"");
400
+ lines.push(" if (( COMP_CWORD > 0 )); then prev=\"${COMP_WORDS[COMP_CWORD-1]}\"; else prev=\"\"; fi");
401
+ lines.push(" words=(\"${COMP_WORDS[@]}\")");
402
+ lines.push(" cword=$COMP_CWORD");
403
+ lines.push("}");
404
+ lines.push("");
405
+ lines.push(`__${ident}_prev_is_value_flag() {`);
406
+ lines.push(" local candidate");
407
+ lines.push(" for candidate in $valueFlags; do");
408
+ lines.push(" if [[ \"$candidate\" == \"$prev\" ]]; then return 0; fi");
409
+ lines.push(" done");
410
+ lines.push(" return 1");
411
+ lines.push("}");
412
+ lines.push("");
413
+ lines.push(`${fnName}() {`);
414
+ lines.push(" local cur prev words cword");
415
+ lines.push(" if declare -F _init_completion >/dev/null 2>&1; then");
416
+ lines.push(" _init_completion -n \"=\" || return");
417
+ lines.push(" else");
418
+ lines.push(`\t\t${initFn} || return`);
419
+ lines.push(" fi");
420
+ lines.push("");
421
+ lines.push(" local cmd_path=\"\"");
422
+ lines.push(`\tlocal subcmds="${bashDoubleQuoteInner(rootSubcmds)}"`);
423
+ lines.push(`\tlocal flags="${bashDoubleQuoteInner(rootFlags)}"`);
424
+ lines.push(`\tlocal valueFlags="${bashDoubleQuoteInner(rootValueFlags)}"`);
425
+ lines.push(" local i=1");
426
+ lines.push(" local end_of_options=0");
427
+ lines.push("");
428
+ lines.push(" while (( i < cword )); do");
429
+ lines.push(" local w=\"${words[$i]}\"");
430
+ lines.push(" if [[ \"$w\" == \"--\" ]]; then");
431
+ lines.push(" end_of_options=1");
432
+ lines.push(" ((i++)); break");
433
+ lines.push(" fi");
434
+ lines.push(" if [[ \"$w\" == --*=* ]]; then");
435
+ lines.push(" ((i++)); continue");
436
+ lines.push(" fi");
437
+ lines.push(" if [[ \"$w\" == -* ]]; then");
438
+ lines.push(" local candidate");
439
+ lines.push(" local _was_value_flag=0");
440
+ lines.push(" for candidate in $valueFlags; do");
441
+ lines.push(" if [[ \"$candidate\" == \"$w\" ]]; then _was_value_flag=1; break; fi");
442
+ lines.push(" done");
443
+ lines.push(" if (( _was_value_flag )); then ((i+=2)); else ((i++)); fi");
444
+ lines.push(" continue");
445
+ lines.push(" fi");
446
+ lines.push(" case \"$cmd_path|$w\" in");
447
+ for (const c of pathCases) {
448
+ lines.push(`\t\t\t"${bashDoubleQuoteInner(c.key)}")`);
449
+ lines.push(`\t\t\t\tcmd_path="${bashDoubleQuoteInner(c.cmdPath)}"`);
450
+ lines.push(`\t\t\t\tsubcmds="${bashDoubleQuoteInner(c.subcmds)}"`);
451
+ lines.push(`\t\t\t\tflags="${bashDoubleQuoteInner(c.flags)}"`);
452
+ lines.push(`\t\t\t\tvalueFlags="${bashDoubleQuoteInner(c.valueFlags)}"`);
453
+ lines.push(" ;;");
454
+ }
455
+ lines.push(" *) break ;;");
456
+ lines.push(" esac");
457
+ lines.push(" ((i++))");
458
+ lines.push(" done");
459
+ lines.push("");
460
+ lines.push(" if (( end_of_options )); then");
461
+ lines.push(" return");
462
+ lines.push(" fi");
463
+ lines.push("");
464
+ lines.push(" if [[ \"$cur\" == --*=* ]]; then");
465
+ lines.push(" local _flag=\"${cur%%=*}\"");
466
+ lines.push(" local _value=\"${cur#*=}\"");
467
+ if (choiceCases.length > 0) {
468
+ lines.push(" case \"$cmd_path|$_flag\" in");
469
+ for (const c of choiceCases) {
470
+ lines.push(`\t\t\t"${bashDoubleQuoteInner(c.key)}")`);
471
+ lines.push(`\t\t\t\tCOMPREPLY=( $(compgen -P "\${_flag}=" -W "${bashDoubleQuoteInner(c.values)}" -- "$_value") )`);
472
+ lines.push(" return");
473
+ lines.push(" ;;");
474
+ }
475
+ lines.push(" esac");
476
+ }
477
+ if (valueTypeCases.length > 0) {
478
+ lines.push(" case \"$cmd_path|$_flag\" in");
479
+ for (const c of valueTypeCases) {
480
+ lines.push(`\t\t\t"${bashDoubleQuoteInner(c.key)}")`);
481
+ if (c.kind === "path") lines.push(" COMPREPLY=( $(compgen -P \"${_flag}=\" -f -- \"$_value\") )");
482
+ else lines.push(" compopt +o default 2>/dev/null");
483
+ lines.push(" return");
484
+ lines.push(" ;;");
485
+ }
486
+ lines.push(" esac");
487
+ }
488
+ lines.push(" return");
489
+ lines.push(" fi");
490
+ lines.push("");
491
+ if (choiceCases.length > 0) {
492
+ lines.push(" case \"$cmd_path|$prev\" in");
493
+ for (const c of choiceCases) {
494
+ lines.push(`\t\t"${bashDoubleQuoteInner(c.key)}")`);
495
+ lines.push(`\t\t\tCOMPREPLY=( $(compgen -W "${bashDoubleQuoteInner(c.values)}" -- "$cur") )`);
496
+ lines.push(" return");
497
+ lines.push(" ;;");
498
+ }
499
+ lines.push(" esac");
500
+ lines.push("");
501
+ }
502
+ if (valueTypeCases.length > 0) {
503
+ lines.push(" case \"$cmd_path|$prev\" in");
504
+ for (const c of valueTypeCases) {
505
+ lines.push(`\t\t"${bashDoubleQuoteInner(c.key)}")`);
506
+ if (c.kind === "path") lines.push(" COMPREPLY=( $(compgen -f -- \"$cur\") )");
507
+ else lines.push(" compopt +o default 2>/dev/null");
508
+ lines.push(" return");
509
+ lines.push(" ;;");
510
+ }
511
+ lines.push(" esac");
512
+ lines.push("");
513
+ }
514
+ lines.push(`\tif __${ident}_prev_is_value_flag; then`);
515
+ lines.push(" return");
516
+ lines.push(" fi");
517
+ lines.push("");
518
+ lines.push(" if [[ \"$cur\" == -* ]]; then");
519
+ lines.push(" COMPREPLY=( $(compgen -W \"$flags\" -- \"$cur\") )");
520
+ lines.push(" else");
521
+ if (argChoiceEntries.length > 0 || argSuppressEntries.length > 0) {
522
+ lines.push(" local pos_idx=0");
523
+ lines.push(" local _pidx_j=$i");
524
+ lines.push(" local _pidx_skip_next=0");
525
+ lines.push(" while (( _pidx_j < cword )); do");
526
+ lines.push(" local _pidx_w=\"${words[$_pidx_j]}\"");
527
+ lines.push(" if (( _pidx_skip_next )); then");
528
+ lines.push(" _pidx_skip_next=0");
529
+ lines.push(" ((_pidx_j++)); continue");
530
+ lines.push(" fi");
531
+ lines.push(" if [[ \"$_pidx_w\" == \"--\" ]]; then");
532
+ lines.push(" ((_pidx_j++)); continue");
533
+ lines.push(" fi");
534
+ lines.push(" if [[ \"$_pidx_w\" == --*=* ]]; then");
535
+ lines.push(" ((_pidx_j++)); continue");
536
+ lines.push(" fi");
537
+ lines.push(" if [[ \"$_pidx_w\" == -* ]]; then");
538
+ lines.push(" local _pidx_cand");
539
+ lines.push(" for _pidx_cand in $valueFlags; do");
540
+ lines.push(" if [[ \"$_pidx_cand\" == \"$_pidx_w\" ]]; then _pidx_skip_next=1; break; fi");
541
+ lines.push(" done");
542
+ lines.push(" ((_pidx_j++)); continue");
543
+ lines.push(" fi");
544
+ lines.push(" pos_idx=$((pos_idx + 1))");
545
+ lines.push(" ((_pidx_j++))");
546
+ lines.push(" done");
547
+ lines.push(" local pos_choices=\"\"");
548
+ lines.push(" case \"$cmd_path\" in");
549
+ for (const entry of argChoiceEntries) {
550
+ lines.push(`\t\t\t"${bashDoubleQuoteInner(entry.cmdPath)}")`);
551
+ lines.push(" case \"$pos_idx\" in");
552
+ entry.bySlot.forEach((values, idx) => {
553
+ if (values === void 0) return;
554
+ lines.push(`\t\t\t\t\t${idx})`);
555
+ lines.push(`\t\t\t\t\t\tpos_choices="${bashDoubleQuoteInner(values)}"`);
556
+ lines.push(" ;;");
557
+ });
558
+ if (entry.variadicFrom !== void 0 && entry.variadicValues !== void 0) {
559
+ lines.push(" *)");
560
+ lines.push(`\t\t\t\t\t\tif (( pos_idx >= ${entry.variadicFrom} )); then pos_choices="${bashDoubleQuoteInner(entry.variadicValues)}"; fi`);
561
+ lines.push(" ;;");
562
+ }
563
+ lines.push(" esac");
564
+ lines.push(" ;;");
565
+ }
566
+ lines.push(" esac");
567
+ if (argSuppressEntries.length > 0) {
568
+ lines.push(" case \"$cmd_path\" in");
569
+ for (const entry of argSuppressEntries) {
570
+ lines.push(`\t\t\t"${bashDoubleQuoteInner(entry.cmdPath)}")`);
571
+ lines.push(" case \"$pos_idx\" in");
572
+ for (const slot of entry.slots) {
573
+ lines.push(`\t\t\t\t\t${slot})`);
574
+ lines.push(" compopt +o default 2>/dev/null");
575
+ lines.push(" ;;");
576
+ }
577
+ if (entry.variadicFrom !== void 0) {
578
+ lines.push(" *)");
579
+ lines.push(`\t\t\t\t\t\tif (( pos_idx >= ${entry.variadicFrom} )); then compopt +o default 2>/dev/null; fi`);
580
+ lines.push(" ;;");
581
+ }
582
+ lines.push(" esac");
583
+ lines.push(" ;;");
584
+ }
585
+ lines.push(" esac");
586
+ }
587
+ lines.push(" if (( pos_idx == 0 )); then");
588
+ lines.push(" COMPREPLY=( $(compgen -W \"$pos_choices $subcmds\" -- \"$cur\") )");
589
+ lines.push(" else");
590
+ lines.push(" COMPREPLY=( $(compgen -W \"$pos_choices\" -- \"$cur\") )");
591
+ lines.push(" fi");
592
+ } else lines.push(" COMPREPLY=( $(compgen -W \"$subcmds\" -- \"$cur\") )");
593
+ lines.push(" fi");
594
+ lines.push("}");
595
+ lines.push("");
596
+ lines.push(`complete -o default -F ${fnName} ${bashSingleQuote(binName)}`);
597
+ return `${lines.join("\n")}\n`;
598
+ }
599
+ //#endregion
600
+ //#region src/completion/templates/fish.ts
601
+ /**
602
+ * Pure-static fish completion script renderer.
603
+ *
604
+ * Strategy: emit declarative `complete -c <bin>` rules — one per
605
+ * subcommand candidate, one per flag of every reachable command. Fish
606
+ * accumulates rules into an in-memory table at `source` time and consults
607
+ * them on every TAB; there's no entry-point function, no subprocess, and
608
+ * no shell state to manage.
609
+ *
610
+ * **Subcommand routing.** We emit a single helper per script —
611
+ * `__<ident>_path_at_arg` — that walks `commandline -opc` left-to-right,
612
+ * skips flags and the `--` end-of-options terminator, and verifies that
613
+ * each consumed positional matches the expected canonical-or-alias set
614
+ * for its depth in order. This replaces the stock
615
+ * `__fish_seen_subcommand_from` chain (which is order-insensitive and
616
+ * misroutes when the same name appears at different depths).
617
+ */
618
+ /** Build the space-joined spelling list for a command (canonical + aliases). */
619
+ function spellingsOf(node) {
620
+ return [node.name, ...node.aliases ?? []].join(" ");
621
+ }
622
+ /** Build the space-joined "block" list — direct children of `node`. */
623
+ function childSpellings(node) {
624
+ const out = [];
625
+ for (const sub of node.subCommands) {
626
+ out.push(sub.name);
627
+ if (sub.aliases !== void 0) out.push(...sub.aliases);
628
+ }
629
+ return out.join(" ");
630
+ }
631
+ /**
632
+ * Render a single `complete -c <bin> ...` rule line.
633
+ *
634
+ * `binName` was validated upstream via `assertSafeBinName`, but we still
635
+ * single-quote it as defence-in-depth so the line works even if a future
636
+ * caller bypasses validation.
637
+ */
638
+ function renderRule(binName, parts) {
639
+ const segments = [`complete -c ${fishSingleQuote(binName)}`];
640
+ if (parts.condition !== void 0) segments.push(`-n ${fishSingleQuote(parts.condition)}`);
641
+ if (parts.exclusive) segments.push("-x");
642
+ else if (parts.requireParameter) segments.push("-r");
643
+ else if (parts.noFiles) segments.push("-f");
644
+ if (parts.short !== void 0) segments.push(`-s ${fishSingleQuote(parts.short)}`);
645
+ if (parts.long !== void 0) segments.push(`-l ${fishSingleQuote(parts.long)}`);
646
+ if (parts.arguments !== void 0) segments.push(`-a ${parts.arguments}`);
647
+ if (parts.description !== void 0) segments.push(`-d ${fishSingleQuote(parts.description)}`);
648
+ return segments.join(" ");
649
+ }
650
+ /**
651
+ * Build the `-n` path and positional-argument predicate.
652
+ *
653
+ * Calls `__<ident>_path_at_arg <spellings...> <pos_spec> <block>` where
654
+ * `pos_spec` is either `<N>` (exact: completion fires when exactly N
655
+ * positionals have been consumed past the path) or `*<N>` (fires when
656
+ * N-or-more positionals have been consumed).
657
+ */
658
+ function posPredicate(ident, path, leaf, posSpec) {
659
+ const args = [];
660
+ for (const node of path) args.push(fishSingleQuote(spellingsOf(node)));
661
+ args.push(fishSingleQuote(posSpec));
662
+ args.push(fishSingleQuote(childSpellings(leaf)));
663
+ return `__${ident}_path_at_arg ${args.join(" ")}`;
664
+ }
665
+ /**
666
+ * Recursively walk the command tree and emit:
667
+ * 1. one subcommand-listing rule per child of the current node, gated
668
+ * on the path predicate — these surface child names + aliases with
669
+ * descriptions in the completion menu;
670
+ * 2. one rule per flag of the current node;
671
+ * 3. one rule per positional arg that declares choices (only the first
672
+ * slot — fish's `complete -a` model is best at offering a single
673
+ * candidate set; further slots fall through to filename completion
674
+ * via `-r` on the rule).
675
+ */
676
+ function emitRules(binName, ident, path, current, out) {
677
+ const condition = posPredicate(ident, path, current, "*0");
678
+ for (const sub of current.subCommands) {
679
+ const desc = sub.description ?? "";
680
+ const spellings = [sub.name, ...sub.aliases ?? []];
681
+ for (const spelling of spellings) out.push(renderRule(binName, {
682
+ condition,
683
+ arguments: fishSingleQuote(spelling),
684
+ description: desc,
685
+ noFiles: true
686
+ }));
687
+ }
688
+ /**
689
+ * Emit choice values as a separate rule per candidate. See the note
690
+ * on {@link renderRule}'s `arguments` handling for why we don't
691
+ * pack them into one space-joined list.
692
+ */
693
+ const emitChoiceFlag = (rule, choices) => {
694
+ for (const choice of choices) out.push(renderRule(binName, {
695
+ ...rule,
696
+ exclusive: true,
697
+ arguments: fishSingleQuote(choice)
698
+ }));
699
+ };
700
+ for (const flag of current.flags) {
701
+ const desc = flag.description ?? "";
702
+ const baseRule = {
703
+ condition,
704
+ long: flag.name,
705
+ description: desc
706
+ };
707
+ if (flag.short !== void 0) baseRule.short = flag.short;
708
+ const emitValueRule = (rule) => {
709
+ if (flag.choices !== void 0 && flag.choices.length > 0) {
710
+ emitChoiceFlag(rule, flag.choices);
711
+ return;
712
+ }
713
+ if (flag.valueCompletion === "files") {
714
+ out.push(renderRule(binName, {
715
+ ...rule,
716
+ requireParameter: true,
717
+ arguments: fishSingleQuote("(__fish_complete_path)")
718
+ }));
719
+ return;
720
+ }
721
+ out.push(renderRule(binName, {
722
+ ...rule,
723
+ requireParameter: true
724
+ }));
725
+ };
726
+ if (flag.takesValue) emitValueRule(baseRule);
727
+ else out.push(renderRule(binName, baseRule));
728
+ if (flag.aliases !== void 0) for (const alias of flag.aliases) {
729
+ const aliasRule = {
730
+ condition,
731
+ long: alias,
732
+ description: desc
733
+ };
734
+ if (flag.takesValue) emitValueRule(aliasRule);
735
+ else out.push(renderRule(binName, aliasRule));
736
+ }
737
+ if (flag.negatable) {
738
+ const negDesc = `disable: ${desc}`.trim();
739
+ out.push(renderRule(binName, {
740
+ condition,
741
+ long: `no-${flag.name}`,
742
+ description: negDesc
743
+ }));
744
+ if (flag.aliases !== void 0) for (const alias of flag.aliases) out.push(renderRule(binName, {
745
+ condition,
746
+ long: `no-${alias}`,
747
+ description: negDesc
748
+ }));
749
+ }
750
+ }
751
+ current.args.forEach((arg, idx) => {
752
+ const posSpec = arg.variadic ? `*${idx}` : String(idx);
753
+ if (arg.choices !== void 0 && arg.choices.length > 0) {
754
+ const posCondition = posPredicate(ident, path, current, posSpec);
755
+ for (const choice of arg.choices) out.push(renderRule(binName, {
756
+ condition: posCondition,
757
+ arguments: fishSingleQuote(choice),
758
+ description: arg.description ?? "",
759
+ noFiles: true
760
+ }));
761
+ return;
762
+ }
763
+ if (arg.valueCompletion === "files") {
764
+ const posCondition = posPredicate(ident, path, current, posSpec);
765
+ out.push(renderRule(binName, {
766
+ condition: posCondition,
767
+ arguments: fishSingleQuote("(__fish_complete_path)"),
768
+ description: arg.description ?? ""
769
+ }));
770
+ }
771
+ });
772
+ for (const sub of current.subCommands) emitRules(binName, ident, [...path, sub], sub, out);
773
+ }
774
+ /**
775
+ * Emit the per-script `__<ident>_path_at_arg` helper. It takes a
776
+ * `pos_spec` argument before the block list. `pos_spec` is `<N>` (fires
777
+ * when exactly N positionals have
778
+ * been consumed past the path — the cursor is filling slot N) or
779
+ * `*<N>` (variadic; fires when N-or-more positionals have been
780
+ * consumed, used for variadic-with-choices args and path matching).
781
+ */
782
+ function emitPosHelper(ident) {
783
+ const fn = `__${ident}_path_at_arg`;
784
+ const lines = [];
785
+ lines.push(`function ${fn}`);
786
+ lines.push(" set -l total_argv (count $argv)");
787
+ lines.push(" set -l block (string split \" \" -- $argv[$total_argv])");
788
+ lines.push(" set -l pos_spec $argv[(math $total_argv - 1)]");
789
+ lines.push(" set -l n (math $total_argv - 2)");
790
+ lines.push(" set -l variadic 0");
791
+ lines.push(" set -l target $pos_spec");
792
+ lines.push(" if string match -q -- '\\**' $pos_spec");
793
+ lines.push(" set variadic 1");
794
+ lines.push(" set target (string sub --start 2 -- $pos_spec)");
795
+ lines.push(" end");
796
+ lines.push(" set -l tokens (commandline -opc)");
797
+ lines.push(" set -l total (count $tokens)");
798
+ lines.push(" set -l j 2");
799
+ lines.push(" set -l consumed 0");
800
+ lines.push(" set -l end_of_options 0");
801
+ lines.push(" while test $j -le $total");
802
+ lines.push(" set -l t $tokens[$j]");
803
+ lines.push(" if test \"$t\" = \"--\"");
804
+ lines.push(" set end_of_options 1");
805
+ lines.push(" set j (math $j + 1)");
806
+ lines.push(" continue");
807
+ lines.push(" end");
808
+ lines.push(" if test $end_of_options -eq 0; and string match -q -- '-*' $t");
809
+ lines.push(" set j (math $j + 1)");
810
+ lines.push(" continue");
811
+ lines.push(" end");
812
+ lines.push(" if test $consumed -lt $n");
813
+ lines.push(" set -l alts (string split \" \" -- $argv[(math $consumed + 1)])");
814
+ lines.push(" if not contains -- $t $alts");
815
+ lines.push(" return 1");
816
+ lines.push(" end");
817
+ lines.push(" set consumed (math $consumed + 1)");
818
+ lines.push(" else");
819
+ lines.push(" if contains -- $t $block");
820
+ lines.push(" return 1");
821
+ lines.push(" end");
822
+ lines.push(" set consumed (math $consumed + 1)");
823
+ lines.push(" end");
824
+ lines.push(" set j (math $j + 1)");
825
+ lines.push(" end");
826
+ lines.push(" set -l beyond (math $consumed - $n)");
827
+ lines.push(" if test $variadic -eq 1");
828
+ lines.push(" test $beyond -ge $target");
829
+ lines.push(" else");
830
+ lines.push(" test $beyond -eq $target");
831
+ lines.push(" end");
832
+ lines.push("end");
833
+ return lines;
834
+ }
835
+ /**
836
+ * Render a self-contained fish completion script for the given spec.
837
+ *
838
+ * The script is safe to drop into `~/.config/fish/completions/<bin>.fish`
839
+ * (auto-loaded the first time the user types `<bin>`) AND safe to source
840
+ * inline via `mycli completion fish | source` — both paths just register
841
+ * `complete` rules.
842
+ *
843
+ * @param spec Walker output.
844
+ * @param binName User-facing binary name; validated upstream.
845
+ * @param version Free-form version string for the header comment.
846
+ */
847
+ function renderFish(spec, binName, version) {
848
+ const ident = toShellIdent(binName);
849
+ const lines = [];
850
+ lines.push(`# completion script for ${binName} v${version} — regenerate with: ${binName} completion fish`);
851
+ lines.push("");
852
+ lines.push(...emitPosHelper(ident));
853
+ lines.push("");
854
+ lines.push(`complete -c ${fishSingleQuote(binName)} -f`);
855
+ lines.push("");
856
+ const rules = [];
857
+ emitRules(binName, ident, [], spec, rules);
858
+ lines.push(...rules);
859
+ return `${lines.join("\n")}\n`;
860
+ }
861
+ //#endregion
862
+ //#region src/completion/templates/zsh.ts
863
+ /**
864
+ * Pure-static zsh completion script renderer.
865
+ *
866
+ * Strategy: emit one `_<bin>_<path>` helper per command in the tree.
867
+ * Helpers for non-leaf commands declare an `_arguments -C` spec with
868
+ * `1: :->cmds` and `*::arg:->args`, then dispatch via `case "$line[1]"`
869
+ * into the child helper — the canonical `->state` routing pattern from
870
+ * `man zshcompsys` (and used by oclif's `ZshCompWithSpaces`).
871
+ *
872
+ * The first line is `#compdef <bin>` (required by zsh's autoload mechanism)
873
+ * and the entry-point function is invoked with `"$@"` at the end so the
874
+ * script works both when dropped into `$fpath` and when sourced directly
875
+ * (e.g. via `eval "$(mycli completion zsh)"`).
876
+ *
877
+ * **Quoting model.** Every spec string is wrapped via {@link bashSingleQuote}
878
+ * so it survives any character (description text, choice values) by going
879
+ * through the standard `'foo'\''bar'` close-and-reopen idiom. The spec
880
+ * **contents** are independently escaped via {@link zshArgsDescription}
881
+ * (for `_arguments` description brackets) and {@link zshDescribeField}
882
+ * (for the colon-separated `_describe` items).
883
+ */
884
+ function flagSpecs(flag) {
885
+ const desc = flag.description ?? "";
886
+ const descPart = `[${zshArgsDescription(desc)}]`;
887
+ let valueSuffix = "";
888
+ if (flag.takesValue) {
889
+ const valueLabel = flag.name;
890
+ if (flag.choices !== void 0 && flag.choices.length > 0) valueSuffix = `:${valueLabel}:(${flag.choices.join(" ")})`;
891
+ else if (flag.valueCompletion === "files") valueSuffix = `:${valueLabel}:_files`;
892
+ else if (flag.valueCompletion === "none") valueSuffix = `:${valueLabel}: `;
893
+ else if (flag.type === "string") valueSuffix = `:${valueLabel}:_files`;
894
+ else valueSuffix = `:${valueLabel}: `;
895
+ }
896
+ const repeat = flag.multiple === true ? "*" : "";
897
+ const allLong = [flag.name, ...flag.aliases ?? []];
898
+ const allShort = flag.short !== void 0 ? [flag.short] : [];
899
+ const specs = [];
900
+ if (allShort.length === 0 && allLong.length === 1) {
901
+ const eq = flag.takesValue ? "=" : "";
902
+ const body = `${repeat}--${flag.name}${eq}${descPart}${valueSuffix}`;
903
+ specs.push(bashSingleQuote(body));
904
+ } else {
905
+ const mutex = [...allShort.map((s) => `-${s}`), ...allLong.map((l) => `--${l}`)].join(" ");
906
+ const altGroup = [...allShort.map((s) => `-${s}`), ...allLong.map((l) => `--${l}${flag.takesValue ? "=" : ""}`)].join(",");
907
+ const headPrefix = flag.multiple === true ? "" : `(${mutex})`;
908
+ const repeatBrace = flag.multiple === true ? "*" : "";
909
+ const fragments = [
910
+ bashSingleQuote(headPrefix),
911
+ `${repeatBrace}{${altGroup}}`,
912
+ bashSingleQuote(`${descPart}${valueSuffix}`)
913
+ ];
914
+ specs.push(fragments.join(""));
915
+ }
916
+ if (flag.negatable) {
917
+ const negDesc = `[${zshArgsDescription(`disable: ${desc}`.trim())}]`;
918
+ const negNames = allLong.map((l) => `--no-${l}`);
919
+ if (negNames.length === 1) specs.push(bashSingleQuote(`${repeat}${negNames[0]}${negDesc}`));
920
+ else {
921
+ const negMutex = negNames.join(" ");
922
+ const negAlt = negNames.join(",");
923
+ const headPrefix = flag.multiple === true ? "" : `(${negMutex})`;
924
+ const repeatBrace = flag.multiple === true ? "*" : "";
925
+ specs.push([
926
+ bashSingleQuote(headPrefix),
927
+ `${repeatBrace}{${negAlt}}`,
928
+ bashSingleQuote(negDesc)
929
+ ].join(""));
930
+ }
931
+ }
932
+ return specs;
933
+ }
934
+ /**
935
+ * Render the positional-argument specs for a single command.
936
+ *
937
+ * Uses the same `'<idx>:NAME:<action>'` shape across leaf and non-leaf
938
+ * helpers. Variadic args expand the `<idx>` to `*` and run the action
939
+ * for every remaining word. Branches mirror {@link flagSpecs}:
940
+ * - choices → `(a b c)`
941
+ * - valueCompletion === "files" → `_files`
942
+ * - valueCompletion === "none" → ` ` (noop — url/json are not paths)
943
+ * - free-form string → `_files`
944
+ * - number/bool → ` ` (noop — rare positional case)
945
+ */
946
+ function renderArgSpecs(node) {
947
+ const specs = [];
948
+ node.args.forEach((arg, idx) => {
949
+ const idxToken = arg.variadic ? "*" : String(idx + 1);
950
+ const label = arg.name;
951
+ let action;
952
+ if (arg.choices !== void 0 && arg.choices.length > 0) action = `(${arg.choices.join(" ")})`;
953
+ else if (arg.valueCompletion === "none") action = " ";
954
+ else if (arg.valueCompletion === "files" || arg.type === "string") action = "_files";
955
+ else action = " ";
956
+ specs.push(bashSingleQuote(`${idxToken}:${label}:${action}`));
957
+ });
958
+ return specs;
959
+ }
960
+ /**
961
+ * Build the function name for the helper that handles a given command
962
+ * path. The root is `_<ident>`; nested children append `_<segment>` for
963
+ * each step.
964
+ */
965
+ function helperName(rootIdent, path) {
966
+ if (path.length === 0) return `_${rootIdent}`;
967
+ return `_${rootIdent}_${path.map(toShellIdent).join("_")}`;
968
+ }
969
+ /**
970
+ * Render a single command's helper function.
971
+ *
972
+ * - Leaf commands: declare flag specs and any positional arg specs.
973
+ * - Non-leaf commands: add the standard `->state` routing and a `case`
974
+ * over `$line[1]` (the first non-option positional) that dispatches to
975
+ * each child helper. Aliases reuse the same helper as their canonical
976
+ * sibling.
977
+ *
978
+ * Non-leaf commands with positional args are uncommon but supported:
979
+ * the routing emits `'1: :->cmds'` so the first slot is treated as a
980
+ * subcommand, plus any *additional* positional specs (slots 2+) for the
981
+ * declared args. If a CLI uses arg slot 1 AND has subcommands, the
982
+ * subcommand wins for that slot — matches the parser's behaviour where
983
+ * a known subcommand takes precedence over a positional value.
984
+ */
985
+ function renderHelper(rootIdent, path, node, out) {
986
+ const fnName = helperName(rootIdent, path);
987
+ const flagSpecLines = node.flags.flatMap(flagSpecs);
988
+ const argSpecLines = renderArgSpecs(node);
989
+ const hasChildren = node.subCommands.length > 0;
990
+ out.push(`${fnName}() {`);
991
+ if (hasChildren) {
992
+ out.push(" local context state state_descr line");
993
+ out.push(" typeset -A opt_args");
994
+ out.push("");
995
+ out.push(" _arguments -C \\");
996
+ for (const spec of flagSpecLines) out.push(`\t\t${spec} \\`);
997
+ out.push(" '1: :->cmds' \\");
998
+ out.push(" '*::arg:->args'");
999
+ out.push("");
1000
+ out.push(" case \"$state\" in");
1001
+ out.push(" cmds)");
1002
+ out.push(" local -a subcmds");
1003
+ out.push(" subcmds=(");
1004
+ for (const sub of node.subCommands) {
1005
+ const desc = zshDescribeField(sub.description ?? "");
1006
+ out.push(`\t\t\t\t${bashSingleQuote(`${zshDescribeField(sub.name)}:${desc}`)}`);
1007
+ if (sub.aliases !== void 0) for (const alias of sub.aliases) out.push(`\t\t\t\t${bashSingleQuote(`${zshDescribeField(alias)}:${desc}`)}`);
1008
+ }
1009
+ out.push(" )");
1010
+ out.push(" _describe 'subcommand' subcmds");
1011
+ out.push(" ;;");
1012
+ out.push(" args)");
1013
+ out.push(" case \"$line[1]\" in");
1014
+ for (const sub of node.subCommands) {
1015
+ const childFn = helperName(rootIdent, [...path, sub.name]);
1016
+ const alts = [sub.name, ...sub.aliases ?? []].map(bashSingleQuote).join("|");
1017
+ out.push(`\t\t\t\t${alts})`);
1018
+ out.push(`\t\t\t\t\t${childFn}`);
1019
+ out.push(" ;;");
1020
+ }
1021
+ out.push(" esac");
1022
+ out.push(" ;;");
1023
+ out.push(" esac");
1024
+ } else if (flagSpecLines.length === 0 && argSpecLines.length === 0) out.push(" :");
1025
+ else {
1026
+ out.push(" _arguments \\");
1027
+ const allSpecs = [...flagSpecLines, ...argSpecLines];
1028
+ allSpecs.forEach((spec, idx) => {
1029
+ const trailing = idx === allSpecs.length - 1 ? "" : " \\";
1030
+ out.push(`\t\t${spec}${trailing}`);
1031
+ });
1032
+ }
1033
+ out.push("}");
1034
+ out.push("");
1035
+ for (const sub of node.subCommands) renderHelper(rootIdent, [...path, sub.name], sub, out);
1036
+ }
1037
+ /**
1038
+ * Render a self-contained zsh completion script for the given spec.
1039
+ *
1040
+ * The script is safe to drop into `$fpath` as `_<bin>` (autoloaded via the
1041
+ * `#compdef` magic line) AND safe to source inline via
1042
+ * `eval "$(mycli completion zsh)"` — the trailing `_<bin> "$@"` makes the
1043
+ * inline form actually invoke completion when the file is sourced.
1044
+ *
1045
+ * @param spec Walker output.
1046
+ * @param binName User-facing binary name; validated upstream via
1047
+ * {@link assertSafeBinName}.
1048
+ * @param version Free-form version string for the header comment.
1049
+ */
1050
+ function renderZsh(spec, binName, version) {
1051
+ const ident = toShellIdent(binName);
1052
+ const lines = [];
1053
+ lines.push(`#compdef ${binName}`);
1054
+ lines.push(`# completion script for ${binName} v${version} — regenerate with: ${binName} completion zsh`);
1055
+ lines.push("");
1056
+ const helpers = [];
1057
+ renderHelper(ident, [], spec, helpers);
1058
+ lines.push(...helpers);
1059
+ lines.push(`if [ "$funcstack[1]" = "_${ident}" ]; then`);
1060
+ lines.push(`\t_${ident} "$@"`);
1061
+ lines.push("else");
1062
+ lines.push(`\tcompdef _${ident} ${bashSingleQuote(binName)}`);
1063
+ lines.push("fi");
1064
+ return `${lines.join("\n")}\n`;
1065
+ }
1066
+ //#endregion
1067
+ //#region src/completion/walker.ts
1068
+ /**
1069
+ * Normalise an optional description: strip ANSI, then drop empty results.
1070
+ * Returning `undefined` (rather than `""`) makes templates' presence checks
1071
+ * easy and keeps generated scripts tidy.
1072
+ */
1073
+ function normaliseDescription(value) {
1074
+ if (value === void 0) return void 0;
1075
+ const stripped = sanitizeFreeText(stripVTControlCharacters(value)).trim();
1076
+ return stripped.length === 0 ? void 0 : stripped;
1077
+ }
1078
+ /**
1079
+ * Project a single documentation flag onto a `CompletionFlag`.
1080
+ */
1081
+ function walkFlag(def) {
1082
+ assertSafeIdentifier(def.name, "flag name");
1083
+ const aliases = def.aliases.filter((alias) => alias.length > 0);
1084
+ for (const alias of aliases) assertSafeIdentifier(alias, "flag alias");
1085
+ if (def.short !== void 0 && def.short.length > 0) assertSafeIdentifier(def.short, "flag short alias");
1086
+ const description = normaliseDescription(def.description);
1087
+ const common = {
1088
+ name: def.name,
1089
+ ...def.short !== void 0 && def.short.length > 0 ? { short: def.short } : {},
1090
+ ...aliases.length > 0 ? { aliases } : {},
1091
+ ...description === void 0 ? {} : { description },
1092
+ ...def.multiple ? { multiple: true } : {},
1093
+ negatable: def.negatable
1094
+ };
1095
+ if (def.type === "boolean") return {
1096
+ ...common,
1097
+ type: "boolean",
1098
+ takesValue: false
1099
+ };
1100
+ if (def.type === "number") return {
1101
+ ...common,
1102
+ type: "number",
1103
+ takesValue: true
1104
+ };
1105
+ if (def.type === "path") return {
1106
+ ...common,
1107
+ type: "string",
1108
+ takesValue: true,
1109
+ valueCompletion: "files"
1110
+ };
1111
+ if (def.type === "url" || def.type === "json") return {
1112
+ ...common,
1113
+ type: "string",
1114
+ takesValue: true,
1115
+ valueCompletion: "none"
1116
+ };
1117
+ const choices = def.choices;
1118
+ if (choices !== void 0 && choices.length > 0) return {
1119
+ ...common,
1120
+ type: "string",
1121
+ takesValue: true,
1122
+ choices: choices.map(assertSafeChoiceValue)
1123
+ };
1124
+ return {
1125
+ ...common,
1126
+ type: "string",
1127
+ takesValue: true
1128
+ };
1129
+ }
1130
+ /** Project a single documentation argument onto a `CompletionArg`. */
1131
+ function walkArg(def) {
1132
+ assertSafeIdentifier(def.name, "arg name");
1133
+ const description = normaliseDescription(def.description);
1134
+ const common = {
1135
+ name: def.name,
1136
+ required: def.required,
1137
+ variadic: def.variadic,
1138
+ ...description === void 0 ? {} : { description }
1139
+ };
1140
+ if (def.type === "number" || def.type === "boolean") return {
1141
+ ...common,
1142
+ type: def.type
1143
+ };
1144
+ if (def.type === "path") return {
1145
+ ...common,
1146
+ type: "string",
1147
+ valueCompletion: "files"
1148
+ };
1149
+ if (def.type === "url" || def.type === "json") return {
1150
+ ...common,
1151
+ type: "string",
1152
+ valueCompletion: "none"
1153
+ };
1154
+ const choices = def.type === "string" ? def.choices : void 0;
1155
+ if (choices !== void 0 && choices.length > 0) return {
1156
+ ...common,
1157
+ type: "string",
1158
+ choices: choices.map(assertSafeChoiceValue)
1159
+ };
1160
+ return {
1161
+ ...common,
1162
+ type: "string"
1163
+ };
1164
+ }
1165
+ /**
1166
+ * Build a completion command from the shared documentation model.
1167
+ */
1168
+ function walkCommandNode(node) {
1169
+ assertSafeIdentifier(node.name, "command name");
1170
+ for (const alias of node.aliases) assertSafeIdentifier(alias, "command alias");
1171
+ const flags = node.flags.map(walkFlag);
1172
+ const args = node.args.map(walkArg);
1173
+ const subCommands = node.children.map(walkCommandNode);
1174
+ const result = {
1175
+ name: node.name,
1176
+ flags,
1177
+ args,
1178
+ subCommands
1179
+ };
1180
+ if (node.aliases.length > 0) result.aliases = node.aliases;
1181
+ const description = normaliseDescription(node.description);
1182
+ if (description !== void 0) result.description = description;
1183
+ return result;
1184
+ }
1185
+ //#endregion
1186
+ //#region src/completion/index.ts
1187
+ const COMPLETION = defineExtensionId("crust:completion");
1188
+ const SUPPORTED_SHELLS = [
1189
+ "bash",
1190
+ "zsh",
1191
+ "fish"
1192
+ ];
1193
+ /** Filename convention for each shell's drop-in completion file. */
1194
+ function filenameForShell(shell, binName) {
1195
+ switch (shell) {
1196
+ case "bash": return binName;
1197
+ case "zsh": return `_${binName}`;
1198
+ case "fish": return `${binName}.fish`;
1199
+ }
1200
+ }
1201
+ const SHELL_RENDERERS = {
1202
+ bash: renderBash,
1203
+ zsh: renderZsh,
1204
+ fish: renderFish
1205
+ };
1206
+ function prepareRender(root, options) {
1207
+ const binName = assertSafeBinName(options.binName ?? root.meta.name);
1208
+ const version = options.version ?? root.meta.version;
1209
+ if (version === void 0) throw new CrustError("DEFINITION", "The completion extension requires a version in new Crust(name, { version }) or completion({ version })");
1210
+ return {
1211
+ spec: walkCommandNode(buildCommandDocumentation(root)),
1212
+ binName,
1213
+ version: sanitizeFreeText(version)
1214
+ };
1215
+ }
1216
+ async function writeCompletionFiles(dir, root, options) {
1217
+ const { spec, binName, version } = prepareRender(root, options);
1218
+ await mkdir(dir, { recursive: true });
1219
+ const filenames = [];
1220
+ for (const shell of SUPPORTED_SHELLS) {
1221
+ const filename = filenameForShell(shell, binName);
1222
+ const script = SHELL_RENDERERS[shell](spec, binName, version);
1223
+ await writeFile(join(dir, filename), script, "utf8");
1224
+ filenames.push(filename);
1225
+ }
1226
+ return filenames;
1227
+ }
1228
+ function renderCompletionScript(shell, root, options = {}) {
1229
+ const { spec, binName, version } = prepareRender(root, options);
1230
+ return SHELL_RENDERERS[shell](spec, binName, version);
1231
+ }
1232
+ /** Render a bash completion script from a prepared root Command Snapshot. */
1233
+ function renderBashCompletion(root, options) {
1234
+ return renderCompletionScript("bash", root, options);
1235
+ }
1236
+ /** Render a zsh completion script from a prepared root Command Snapshot. */
1237
+ function renderZshCompletion(root, options) {
1238
+ return renderCompletionScript("zsh", root, options);
1239
+ }
1240
+ /** Render a fish completion script from a prepared root Command Snapshot. */
1241
+ function renderFishCompletion(root, options) {
1242
+ return renderCompletionScript("fish", root, options);
1243
+ }
1244
+ /**
1245
+ * Build an Extension that contributes a `completion <shell>` command
1246
+ * which emits a tab-completion script for bash, zsh, or fish.
1247
+ *
1248
+ * **Strategy: pure-static.** The action walks the final root snapshot, so
1249
+ * registration order is irrelevant — any commands or recursive flags added
1250
+ * by other Extensions are visible by the time we generate the script. The
1251
+ * walker projects Core's documentation model to a small completion model; per-shell
1252
+ * renderers turn that into a self-contained shell script with no runtime
1253
+ * callbacks.
1254
+ *
1255
+ * **Print vs `--output-dir`.**
1256
+ * - With no `--output-dir`: print the script for the requested `<shell>`
1257
+ * to stdout (the install pattern is
1258
+ * `mycli completion bash > ~/.local/share/...`).
1259
+ * - With `--output-dir <path>`: write **all** supported shells' files
1260
+ * into the directory using the canonical per-shell filename
1261
+ * (`<bin>` for bash, `_<bin>` for zsh, `<bin>.fish` for fish). This
1262
+ * is the artifact-generation path used by Homebrew, Nix, and similar
1263
+ * distribution channels — distributors run it once at packaging time
1264
+ * and the resulting files become drop-ins.
1265
+ *
1266
+ * **Build hook.** `crust build` writes the same three files under
1267
+ * `<outDir>/completions/`; `--package` stages that directory. The binary name
1268
+ * defaults to the snapshot's `meta.name`, unless `options.binName` is set.
1269
+ */
1270
+ const completion = defineExtension(COMPLETION, (options = {}) => {
1271
+ const subcommandName = options.command ?? "completion";
1272
+ return {
1273
+ commands: [defineCommand(subcommandName, { description: "Generate shell tab-completion scripts" }, (cmd) => cmd.args({
1274
+ name: "shell",
1275
+ type: "string",
1276
+ required: true,
1277
+ description: "Shell to generate completion for",
1278
+ choices: SUPPORTED_SHELLS
1279
+ }).flags({
1280
+ name: "output-dir",
1281
+ type: "string",
1282
+ description: "Write all supported shells' scripts into this directory instead of printing to stdout"
1283
+ }).action(async (context) => {
1284
+ const outputDir = context.flags["output-dir"];
1285
+ if (outputDir === void 0) {
1286
+ context.stdout(renderCompletionScript(context.args.shell, context.rootCommand, options));
1287
+ return;
1288
+ }
1289
+ await writeCompletionFiles(resolve(outputDir), context.rootCommand, options);
1290
+ }))],
1291
+ build: async ({ snapshot, outDir }) => {
1292
+ const dir = join(outDir, "completions");
1293
+ await mkdir(outDir, { recursive: true });
1294
+ const stagedDir = await mkdtemp(join(outDir, ".completions-"));
1295
+ try {
1296
+ const filenames = await writeCompletionFiles(stagedDir, snapshot, options);
1297
+ await rm(dir, {
1298
+ recursive: true,
1299
+ force: true
1300
+ });
1301
+ await rename(stagedDir, dir);
1302
+ return filenames.map((filename) => join("completions", filename));
1303
+ } finally {
1304
+ await rm(stagedDir, {
1305
+ recursive: true,
1306
+ force: true
1307
+ });
1308
+ }
1309
+ }
1310
+ };
1311
+ });
1312
+ //#endregion
1313
+ //#region src/help.ts
1314
+ const FLAG_COLUMN_WIDTH = 28;
1315
+ const ARG_COLUMN_WIDTH = 18;
1316
+ const COMMAND_COLUMN_WIDTH = 10;
1317
+ const HELP = defineExtensionId("crust:help");
1318
+ function formatArgToken(arg) {
1319
+ return arg.required ? yellow(arg.token) : dim(yellow(arg.token));
1320
+ }
1321
+ function formatUsageSegment(segment) {
1322
+ switch (segment.kind) {
1323
+ case "path":
1324
+ case "custom": return green(segment.text);
1325
+ case "command":
1326
+ case "options": return cyan(segment.text);
1327
+ case "arg": return segment.required ? yellow(segment.text) : dim(yellow(segment.text));
1328
+ }
1329
+ }
1330
+ function formatFlagsSection(flags) {
1331
+ if (flags.length === 0) return [];
1332
+ const lines = [bold(cyan("Options:"))];
1333
+ for (const flag of flags) {
1334
+ const rendered = `${padEnd(cyan(flag.spellings.join(", ")), FLAG_COLUMN_WIDTH, " ")} `;
1335
+ lines.push(` ${rendered}${formatDescription(flag.description, flag.default, flag.choices, dim)}`.trimEnd());
1336
+ }
1337
+ return lines;
1338
+ }
1339
+ function formatArgsSection(command) {
1340
+ if (command.args.length === 0) return [];
1341
+ const lines = [bold(cyan("Arguments:"))];
1342
+ for (const arg of command.args) {
1343
+ const rendered = `${padEnd(formatArgToken(arg), ARG_COLUMN_WIDTH, " ")} `;
1344
+ lines.push(` ${rendered}${formatDescription(arg.description, arg.default, arg.choices, dim)}`.trimEnd());
1345
+ }
1346
+ return lines;
1347
+ }
1348
+ function formatCommandLabel(command) {
1349
+ const name = green(command.name);
1350
+ return command.aliases.length === 0 ? name : `${name} (${command.aliases.join(", ")})`;
1351
+ }
1352
+ function formatCommandsSection(command) {
1353
+ if (command.children.length === 0) return [];
1354
+ const lines = [bold(cyan("Commands:"))];
1355
+ for (const child of command.children) {
1356
+ const rendered = `${padEnd(formatCommandLabel(child), COMMAND_COLUMN_WIDTH, " ")} `;
1357
+ lines.push(` ${rendered}${child.description ?? ""}`.trimEnd());
1358
+ }
1359
+ return lines;
1360
+ }
1361
+ function renderHelp(command, path) {
1362
+ const model = buildCommandDocumentation(command, path);
1363
+ const heading = model.path.join(" ");
1364
+ const lines = [
1365
+ model.description ? `${bold(heading)} - ${dim(model.description)}` : bold(heading),
1366
+ "",
1367
+ bold(cyan("Usage:")),
1368
+ ` ${model.usageSegments.map(formatUsageSegment).join(" ")}`
1369
+ ];
1370
+ for (const section of [
1371
+ formatCommandsSection(model),
1372
+ formatArgsSection(model),
1373
+ formatFlagsSection(model.flags)
1374
+ ]) if (section.length > 0) lines.push("", ...section);
1375
+ for (const section of sectionsFor(model.sections, HELP)) lines.push("", bold(cyan(`${section.title}:`)), ...section.body.split("\n").map((l) => ` ${l}`));
1376
+ return lines.join("\n");
1377
+ }
1378
+ const helpFlags = [{
1379
+ name: "help",
1380
+ type: "boolean",
1381
+ short: "h",
1382
+ noNegate: true,
1383
+ description: "Show help"
1384
+ }];
1385
+ const help = defineExtension(HELP, () => ({
1386
+ flags: helpFlags,
1387
+ hooks: { preRun(context) {
1388
+ if (context.flags.help !== true && context.command.hasAction) return;
1389
+ context.stdout(renderHelp(context.command, context.commandPath));
1390
+ return context.finish();
1391
+ } }
1392
+ }));
1393
+ //#endregion
1394
+ //#region src/did-you-mean.ts
1395
+ const DID_YOU_MEAN = defineExtensionId("crust:did-you-mean");
1396
+ function levenshtein(a, b) {
1397
+ const aLen = a.length;
1398
+ const bLen = b.length;
1399
+ if (aLen === 0) return bLen;
1400
+ if (bLen === 0) return aLen;
1401
+ const row = Uint32Array.from({ length: bLen + 1 }, (_, i) => i);
1402
+ for (let i = 1; i <= aLen; i++) {
1403
+ let prev = i;
1404
+ for (let j = 1; j <= bLen; j++) {
1405
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1406
+ const val = Math.min(row[j] + 1, prev + 1, row[j - 1] + cost);
1407
+ row[j - 1] = prev;
1408
+ prev = val;
1409
+ }
1410
+ row[bLen] = prev;
1411
+ }
1412
+ return row[bLen];
1413
+ }
1414
+ /**
1415
+ * Find canonical-name suggestions for `input` by matching against every
1416
+ * sibling's canonical name **and** any aliases declared on each sibling.
1417
+ *
1418
+ * Matched aliases are mapped back to their canonical, so suggestions only
1419
+ * ever report canonical names — mirroring `router.ts`, which records
1420
+ * canonicals on `commandPath`. When both a canonical and its alias score
1421
+ * within threshold, the better score wins for that command (a short alias
1422
+ * cannot lose to a more-distant canonical, and vice-versa).
1423
+ *
1424
+ * Subcommands marked `meta.hidden: true` are excluded from the candidate
1425
+ * set so internal commands (e.g. `__complete`) cannot leak into
1426
+ * user-facing typo suggestions. They remain invocable by direct name —
1427
+ * routing does not consult `meta.hidden`.
1428
+ *
1429
+ * The matching is limited to: (a) `candidate.startsWith(input)` (a
1430
+ * forward-completion hint, useful when the user typed a prefix) and
1431
+ * (b) Levenshtein distance ≤ 3. The reverse `input.startsWith(candidate)`
1432
+ * shortcut is intentionally omitted: with aliases in the candidate set,
1433
+ * any 1–2 char alias would falsely match every typo as distance 0.
1434
+ */
1435
+ function findSuggestions(input, subCommands) {
1436
+ const best = /* @__PURE__ */ new Map();
1437
+ const score = (text) => {
1438
+ if (text.startsWith(input)) return 0;
1439
+ const d = levenshtein(input, text);
1440
+ return d <= 3 ? d : null;
1441
+ };
1442
+ const record = (canonical, distance) => {
1443
+ const prev = best.get(canonical);
1444
+ if (prev === void 0 || distance < prev) best.set(canonical, distance);
1445
+ };
1446
+ for (const [name, node] of Object.entries(subCommands)) {
1447
+ if (!isListed(node)) continue;
1448
+ const d = score(name);
1449
+ if (d !== null) record(name, d);
1450
+ for (const alias of node.meta.aliases ?? []) {
1451
+ const da = score(alias);
1452
+ if (da !== null) record(name, da);
1453
+ }
1454
+ }
1455
+ return [...best.entries()].sort(([aName, aDist], [bName, bDist]) => aDist !== bDist ? aDist - bDist : aName.localeCompare(bName)).map(([name]) => name);
1456
+ }
1457
+ const didYouMean = defineExtension(DID_YOU_MEAN, (options = {}) => {
1458
+ const mode = options.mode ?? "error";
1459
+ return { hooks: { onError(error, context) {
1460
+ if (!(error instanceof CrustError) || !error.is("COMMAND_NOT_FOUND")) return;
1461
+ const details = error.details;
1462
+ const suggestions = findSuggestions(details.input, details.parentCommand.subCommands);
1463
+ let message = `Unknown command "${details.input}".`;
1464
+ if (suggestions.length > 0) message += ` Did you mean "${suggestions[0]}"?`;
1465
+ if (mode === "help") {
1466
+ context.stdout(message);
1467
+ context.stdout("");
1468
+ context.stdout(renderHelp(details.parentCommand, details.commandPath));
1469
+ return true;
1470
+ }
1471
+ if (details.available.length > 0) message += `\n\nAvailable commands: ${details.available.join(", ")}`;
1472
+ context.stderr(message);
1473
+ return true;
1474
+ } } };
1475
+ });
1476
+ //#endregion
1477
+ //#region src/no-color.ts
1478
+ const NO_COLOR = defineExtensionId("crust:no-color");
1479
+ let activeRuns = 0;
1480
+ let baseForceColor;
1481
+ let baseNoColor;
1482
+ const colorRuns = /* @__PURE__ */ new WeakSet();
1483
+ const colorFlags = [{
1484
+ name: "color",
1485
+ type: "boolean",
1486
+ description: "Enable colored output"
1487
+ }];
1488
+ /**
1489
+ * Adds a recursive `--color` / `--no-color` flag pair that scopes the
1490
+ * standard color environment variables around command execution:
1491
+ *
1492
+ * - `--color` sets `FORCE_COLOR=3` (and clears `NO_COLOR`, so strict
1493
+ * no-color.org-only child processes also comply) — forces all ANSI on
1494
+ * (truecolor), overriding non-TTY detection. Any color library that
1495
+ * honors `FORCE_COLOR` (including `@crustjs/style` and chalk) obeys it,
1496
+ * and child processes inherit it.
1497
+ * - `--no-color` sets `NO_COLOR=1` (and clears `FORCE_COLOR` so the flag
1498
+ * wins over ambient env) — suppresses colors while non-color modifiers
1499
+ * and hyperlinks keep following TTY detection, per
1500
+ * [no-color.org](https://no-color.org/).
1501
+ *
1502
+ * Previous values are restored after the command finishes. When overlapping
1503
+ * programmatic runs in one process use opposite flags, the later run wins
1504
+ * mid-flight (the env is process-global); the ambient values are restored
1505
+ * once all runs finish.
1506
+ */
1507
+ const noColor = defineExtension(NO_COLOR, () => ({
1508
+ flags: colorFlags,
1509
+ hooks: {
1510
+ preRun(context) {
1511
+ const flagValue = context.flags.color;
1512
+ if (flagValue !== true && flagValue !== false) return;
1513
+ if (activeRuns === 0) {
1514
+ baseForceColor = process.env.FORCE_COLOR;
1515
+ baseNoColor = process.env.NO_COLOR;
1516
+ }
1517
+ activeRuns++;
1518
+ colorRuns.add(context);
1519
+ if (flagValue) {
1520
+ delete process.env.NO_COLOR;
1521
+ process.env.FORCE_COLOR = "3";
1522
+ } else {
1523
+ delete process.env.FORCE_COLOR;
1524
+ process.env.NO_COLOR = "1";
1525
+ }
1526
+ },
1527
+ postRun(context) {
1528
+ if (!colorRuns.has(context)) return;
1529
+ colorRuns.delete(context);
1530
+ activeRuns--;
1531
+ if (activeRuns === 0) {
1532
+ if (baseForceColor === void 0) delete process.env.FORCE_COLOR;
1533
+ else process.env.FORCE_COLOR = baseForceColor;
1534
+ if (baseNoColor === void 0) delete process.env.NO_COLOR;
1535
+ else process.env.NO_COLOR = baseNoColor;
1536
+ }
1537
+ }
1538
+ }
1539
+ }));
1540
+ //#endregion
1541
+ //#region ../utils/src/process.ts
1542
+ /** Parse the package manager from npm's user-agent environment value. */
1543
+ function packageManagerFromUserAgent(userAgent) {
1544
+ if (userAgent?.startsWith("bun")) return "bun";
1545
+ if (userAgent?.startsWith("pnpm")) return "pnpm";
1546
+ if (userAgent?.startsWith("yarn")) return "yarn";
1547
+ if (userAgent?.startsWith("npm")) return "npm";
1548
+ return null;
1549
+ }
1550
+ //#endregion
1551
+ //#region src/update-notifier.ts
1552
+ const UPDATE_NOTIFIER = defineExtensionId("crust:update-notifier");
1553
+ /** Default check interval: 24 hours. */
1554
+ const DEFAULT_INTERVAL_MS = 864e5;
1555
+ /** Default network timeout: 5 seconds. */
1556
+ const DEFAULT_TIMEOUT_MS = 5e3;
1557
+ /** Default npm registry URL. */
1558
+ const DEFAULT_REGISTRY_URL = "https://registry.npmjs.org";
1559
+ function compareSemver(left, right) {
1560
+ const parse = (version) => {
1561
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
1562
+ if (!match) throw new TypeError(`Invalid semantic version: ${version}`);
1563
+ return {
1564
+ core: match.slice(1, 4).map(Number),
1565
+ prerelease: match[4]?.split(".") ?? []
1566
+ };
1567
+ };
1568
+ const a = parse(left);
1569
+ const b = parse(right);
1570
+ for (let index = 0; index < 3; index++) if (a.core[index] !== b.core[index]) return a.core[index] < b.core[index] ? -1 : 1;
1571
+ if (a.prerelease.length === 0 || b.prerelease.length === 0) return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
1572
+ for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index++) {
1573
+ const x = a.prerelease[index];
1574
+ const y = b.prerelease[index];
1575
+ if (x === void 0 || y === void 0) return x === void 0 ? -1 : 1;
1576
+ if (x === y) continue;
1577
+ const xNumeric = /^\d+$/.test(x);
1578
+ const yNumeric = /^\d+$/.test(y);
1579
+ if (xNumeric && yNumeric) return Number(x) < Number(y) ? -1 : 1;
1580
+ if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
1581
+ return x < y ? -1 : 1;
1582
+ }
1583
+ return 0;
1584
+ }
1585
+ /**
1586
+ * Returns whether `latest` is newer, or false when either version is invalid.
1587
+ *
1588
+ * @internal
1589
+ */
1590
+ function isNewerVersion(current, latest) {
1591
+ try {
1592
+ return compareSemver(latest, current) === 1;
1593
+ } catch {
1594
+ return false;
1595
+ }
1596
+ }
1597
+ function hasLatestDistTag(value) {
1598
+ if (typeof value !== "object" || value === null || Array.isArray(value) || !("dist-tags" in value)) return false;
1599
+ const tags = value["dist-tags"];
1600
+ return typeof tags === "object" && tags !== null && !Array.isArray(tags) && "latest" in tags && typeof tags.latest === "string" && tags.latest.length > 0;
1601
+ }
1602
+ /**
1603
+ * Fetch the `dist-tags.latest` version string for a package from an npm
1604
+ * registry.
1605
+ *
1606
+ * Uses the platform timeout signal so network stalls cannot hang the CLI process.
1607
+ *
1608
+ * Returns `null` on any failure (network error, timeout, non-OK status,
1609
+ * missing/malformed response body).
1610
+ *
1611
+ * @internal
1612
+ */
1613
+ async function fetchLatestVersion(packageName, registryUrl, timeoutMs) {
1614
+ try {
1615
+ const url = `${registryUrl.replace(/\/+$/, "")}/${encodeURIComponent(packageName)}`;
1616
+ const response = await fetch(url, {
1617
+ signal: AbortSignal.timeout(timeoutMs),
1618
+ headers: { Accept: "application/vnd.npm.install-v1+json" }
1619
+ });
1620
+ if (!response.ok) return null;
1621
+ const data = await response.json();
1622
+ if (!hasLatestDistTag(data)) return null;
1623
+ return data["dist-tags"].latest;
1624
+ } catch {
1625
+ return null;
1626
+ }
1627
+ }
1628
+ function normalizeNotifierState(input) {
1629
+ if (!input) return { lastCheckedAt: 0 };
1630
+ return {
1631
+ lastCheckedAt: Number.isFinite(input.lastCheckedAt) ? input.lastCheckedAt : 0,
1632
+ latestVersion: input.latestVersion?.length ? input.latestVersion : void 0,
1633
+ lastNotifiedVersion: input.lastNotifiedVersion?.length ? input.lastNotifiedVersion : void 0
1634
+ };
1635
+ }
1636
+ const NO_CACHE_ADAPTER = {
1637
+ read: async () => null,
1638
+ write: async () => {}
1639
+ };
1640
+ /** @internal */
1641
+ async function createStoreCacheAdapter(packageName, registryUrl) {
1642
+ const { createStore, stateDir } = await import("@crustjs/store");
1643
+ const store = createStore({
1644
+ dirPath: stateDir(encodeURIComponent(packageName)),
1645
+ name: "update-notifier",
1646
+ fields: {
1647
+ lastCheckedAt: {
1648
+ type: "number",
1649
+ default: 0
1650
+ },
1651
+ latestVersion: { type: "string" },
1652
+ lastNotifiedVersion: { type: "string" },
1653
+ registryUrl: { type: "string" }
1654
+ }
1655
+ });
1656
+ return {
1657
+ read: async () => {
1658
+ const state = await store.read();
1659
+ if (state.registryUrl !== registryUrl) return null;
1660
+ return {
1661
+ lastCheckedAt: state.lastCheckedAt,
1662
+ latestVersion: state.latestVersion,
1663
+ lastNotifiedVersion: state.lastNotifiedVersion
1664
+ };
1665
+ },
1666
+ write: async (state) => {
1667
+ await store.write({
1668
+ lastCheckedAt: state.lastCheckedAt,
1669
+ latestVersion: state.latestVersion,
1670
+ lastNotifiedVersion: state.lastNotifiedVersion,
1671
+ registryUrl
1672
+ });
1673
+ }
1674
+ };
1675
+ }
1676
+ function detectPackageManager() {
1677
+ const detectedFromUserAgent = packageManagerFromUserAgent(process.env.npm_config_user_agent);
1678
+ if (detectedFromUserAgent) return detectedFromUserAgent;
1679
+ const detectedFromExecPath = detectPackageManagerFromExecPath(process.env.npm_execpath);
1680
+ if (detectedFromExecPath) return detectedFromExecPath;
1681
+ const detectedFromRuntime = detectPackageManagerFromExecPath(process.execPath);
1682
+ if (detectedFromRuntime) return detectedFromRuntime;
1683
+ return "npm";
1684
+ }
1685
+ function detectPackageManagerFromExecPath(execPath) {
1686
+ if (!execPath) return null;
1687
+ const executable = basename(execPath).toLowerCase();
1688
+ if (executable === "bun" || executable.startsWith("bun-")) return "bun";
1689
+ if (executable === "pnpm" || executable.startsWith("pnpm-")) return "pnpm";
1690
+ if (executable === "yarn" || executable.startsWith("yarn-")) return "yarn";
1691
+ if (executable === "npm" || executable.startsWith("npm-")) return "npm";
1692
+ return null;
1693
+ }
1694
+ function defaultUpdateCommand(packageName, packageManager, scope) {
1695
+ if (packageManager === "pnpm") return scope === "global" ? `pnpm add -g ${packageName}@latest` : `pnpm add ${packageName}@latest`;
1696
+ if (packageManager === "yarn") return scope === "global" ? `npm install -g ${packageName}@latest` : `yarn add ${packageName}@latest`;
1697
+ if (packageManager === "bun") return scope === "global" ? `bun add -g ${packageName}@latest` : `bun add ${packageName}@latest`;
1698
+ return scope === "global" ? `npm install -g ${packageName}@latest` : `npm install ${packageName}@latest`;
1699
+ }
1700
+ function resolveUpdateCommand(packageName, updateCommand) {
1701
+ if (updateCommand === void 0 || typeof updateCommand === "string") return updateCommand;
1702
+ const packageManager = detectPackageManager();
1703
+ if (typeof updateCommand === "function") return updateCommand({
1704
+ packageName,
1705
+ packageManager
1706
+ });
1707
+ return defaultUpdateCommand(packageName, packageManager, updateCommand.scope);
1708
+ }
1709
+ /**
1710
+ * Creates an update notifier extension that checks the npm registry after a
1711
+ * successful command action and displays a notice when a newer version is available.
1712
+ *
1713
+ * **Behavior:**
1714
+ * - By default, checks are cached for 24 hours in the package's state directory.
1715
+ * - `cache: false` disables cross-run persistence.
1716
+ * - A custom cache adapter can override the built-in persistence.
1717
+ * - The notice is command-less unless `updateCommand` is configured.
1718
+ * - The postRun hook awaits the network check and cache reads and writes,
1719
+ * delaying invocation completion after the action.
1720
+ * - `timeoutMs` bounds only the network request, defaulting to 5 seconds.
1721
+ * - Network, cache, and parsing errors are silently swallowed. A missing
1722
+ * current version throws a DEFINITION error before that recovery block.
1723
+ * - Update notices are intentionally written to the invocation's stderr callback.
1724
+ * - Duplicate notifications for the same version are suppressed.
1725
+ *
1726
+ * @param options - Extension configuration. `packageName` is required.
1727
+ * @returns An Extension registered with `.extend()`.
1728
+ *
1729
+ * @example
1730
+ * ```ts
1731
+ * import { Crust } from "@crustjs/core";
1732
+ * import { updateNotifier } from "@crustjs/extensions";
1733
+ *
1734
+ * const app = new Crust("my-cli", { description: "My awesome CLI", version: "1.2.3" })
1735
+ * .extend(updateNotifier({ packageName: "my-cli" }))
1736
+ * .action(() => {
1737
+ * console.log("Hello!");
1738
+ * });
1739
+ *
1740
+ * await app.execute();
1741
+ * ```
1742
+ */
1743
+ const updateNotifier = defineExtension(UPDATE_NOTIFIER, (options) => {
1744
+ const { currentVersion, packageName, timeoutMs = DEFAULT_TIMEOUT_MS, registryUrl = DEFAULT_REGISTRY_URL, updateCommand, updateDocsUrl, cache } = options;
1745
+ const intervalMs = (cache === false ? void 0 : cache?.intervalMs) ?? DEFAULT_INTERVAL_MS;
1746
+ return { hooks: { async postRun(context, outcome) {
1747
+ if (outcome.status !== "completed") return;
1748
+ const resolvedCurrentVersion = currentVersion ?? context.rootCommand.meta.version;
1749
+ if (resolvedCurrentVersion === void 0) throw new CrustError("DEFINITION", "The update notifier extension requires a version in new Crust(name, { version }) or currentVersion");
1750
+ try {
1751
+ let cacheAdapter = NO_CACHE_ADAPTER;
1752
+ if (cache !== false) {
1753
+ if (cache?.adapter) cacheAdapter = cache.adapter;
1754
+ else cacheAdapter = await createStoreCacheAdapter(packageName, registryUrl);
1755
+ }
1756
+ const state = normalizeNotifierState(await cacheAdapter.read().catch(() => null));
1757
+ const resolvedUpdateCommand = resolveUpdateCommand(packageName, updateCommand);
1758
+ const now = Date.now();
1759
+ const elapsed = now - state.lastCheckedAt;
1760
+ if (cache !== false && elapsed >= 0 && elapsed < intervalMs) {
1761
+ if (state.latestVersion && isNewerVersion(resolvedCurrentVersion, state.latestVersion) && state.lastNotifiedVersion !== state.latestVersion) {
1762
+ emitUpdateNotice(resolvedCurrentVersion, state.latestVersion, resolvedUpdateCommand, updateDocsUrl, context.stderr);
1763
+ await cacheAdapter.write({
1764
+ ...state,
1765
+ lastNotifiedVersion: state.latestVersion
1766
+ });
1767
+ }
1768
+ return;
1769
+ }
1770
+ const latestVersion = await fetchLatestVersion(packageName, registryUrl, timeoutMs);
1771
+ if (latestVersion === null) {
1772
+ await cacheAdapter.write({
1773
+ ...state,
1774
+ lastCheckedAt: now
1775
+ });
1776
+ return;
1777
+ }
1778
+ const nextState = {
1779
+ ...state,
1780
+ lastCheckedAt: now,
1781
+ latestVersion
1782
+ };
1783
+ if (isNewerVersion(resolvedCurrentVersion, latestVersion) && state.lastNotifiedVersion !== latestVersion) {
1784
+ emitUpdateNotice(resolvedCurrentVersion, latestVersion, resolvedUpdateCommand, updateDocsUrl, context.stderr);
1785
+ nextState.lastNotifiedVersion = latestVersion;
1786
+ }
1787
+ await cacheAdapter.write(nextState);
1788
+ } catch {}
1789
+ } } };
1790
+ });
1791
+ /**
1792
+ * Emits a styled, boxed update notice to stderr.
1793
+ *
1794
+ * Uses stderr so the notice does not interfere with piped stdout.
1795
+ *
1796
+ * The notice uses rounded-corner box-drawing characters and ANSI colors:
1797
+ * - Yellow box border
1798
+ * - Dim current version, bold green latest version
1799
+ * - Optional cyan update command and documentation URL
1800
+ *
1801
+ * @internal
1802
+ */
1803
+ function emitUpdateNotice(currentVersion, latestVersion, updateCommand, updateDocsUrl, stderr) {
1804
+ const PADDING = 3;
1805
+ const contentLines = [
1806
+ `Update available ${dim(currentVersion)} ${yellow("→")} ${bold(green(latestVersion))}`,
1807
+ ...updateCommand !== void 0 ? [`Run ${cyan(updateCommand)}`] : [],
1808
+ ...updateDocsUrl !== void 0 ? [`See ${cyan(updateDocsUrl)} to update`] : []
1809
+ ];
1810
+ const contentWidth = Math.max(...contentLines.map((line) => stringWidth(line)));
1811
+ const innerWidth = contentWidth + 6;
1812
+ const border = "─".repeat(innerWidth);
1813
+ const pad = " ".repeat(PADDING);
1814
+ const emptyLine = `${yellow("│")}${" ".repeat(innerWidth)}${yellow("│")}`;
1815
+ stderr([
1816
+ "",
1817
+ `${yellow("╭")}${yellow(border)}${yellow("╮")}`,
1818
+ emptyLine,
1819
+ ...contentLines.map((line) => `${yellow("│")}${pad}${padEnd(line, contentWidth)}${pad}${yellow("│")}`),
1820
+ emptyLine,
1821
+ `${yellow("╰")}${yellow(border)}${yellow("╯")}`,
1822
+ ""
1823
+ ].join("\n"));
1824
+ }
1825
+ //#endregion
1826
+ //#region src/version.ts
1827
+ const VERSION = defineExtensionId("crust:version");
1828
+ const versionFlags = [{
1829
+ name: "version",
1830
+ type: "boolean",
1831
+ short: "v",
1832
+ noNegate: true,
1833
+ description: "Show version number",
1834
+ recursive: false
1835
+ }];
1836
+ function makeVersion(resolve, options) {
1837
+ const { format } = options;
1838
+ return defineExtension()(VERSION, {
1839
+ flags: versionFlags,
1840
+ hooks: { preRun(context) {
1841
+ if (context.commandPath.length !== 1 || context.flags.version !== true) return;
1842
+ const resolvedVersion = resolve(context);
1843
+ const line = format === "plain" ? resolvedVersion : format ? format(resolvedVersion, context) : `${context.rootCommand.meta.name} v${resolvedVersion}`;
1844
+ context.stdout(line);
1845
+ return context.finish();
1846
+ } }
1847
+ });
1848
+ }
1849
+ function createVersion(value, options = {}) {
1850
+ if (value === void 0) return makeVersion((context) => context.rootCommand.meta.version, options);
1851
+ return makeVersion(() => {
1852
+ return typeof value === "function" ? value() : value;
1853
+ }, options);
1854
+ }
1855
+ const version = Object.assign(createVersion, { id: VERSION });
1856
+ //#endregion
1857
+ export { completion, didYouMean, help, noColor, renderBashCompletion, renderFishCompletion, renderHelp, renderZshCompletion, updateNotifier, version };