argsbarg 0.1.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.
@@ -0,0 +1,523 @@
1
+ /*
2
+
3
+ This file walks the CLI tree into scopes and emits bash and zsh completion scripts.
4
+ It builds the shell-side candidate lists, simulates argv state, and chooses the right
5
+ items for each completion context.
6
+
7
+ It keeps completion aligned with the runtime schema so the generated commands,
8
+ options, and descriptions stay in sync with the CLI definition.
9
+ */
10
+
11
+ import { CliCommand, CliOptionDef } from "./types.ts";
12
+
13
+ // ── Shared Types ───────────────────────────────────────────────────────────────
14
+
15
+ interface ScopeRec {
16
+ kids: CliCommand[];
17
+ opts: CliOptionDef[];
18
+ path: string;
19
+ wantsFiles: boolean;
20
+ }
21
+
22
+ function hasPositionalArguments(cmd: CliCommand): boolean {
23
+ return (cmd.positionals ?? []).some((p) => p.positional);
24
+ }
25
+
26
+ function walkScopes(cmdPath: string, cmd: CliCommand, acc: ScopeRec[]): void {
27
+ acc.push({
28
+ kids: cmd.children ?? [],
29
+ opts: cmd.options ?? [],
30
+ path: cmdPath,
31
+ wantsFiles: hasPositionalArguments(cmd),
32
+ });
33
+ for (const ch of cmd.children ?? []) {
34
+ const nextPath = cmdPath === "" ? ch.key : cmdPath + "/" + ch.key;
35
+ walkScopes(nextPath, ch, acc);
36
+ }
37
+ }
38
+
39
+ function collectScopes(schema: CliCommand): ScopeRec[] {
40
+ const acc: ScopeRec[] = [];
41
+ acc.push({
42
+ kids: schema.children ?? [],
43
+ opts: schema.options ?? [],
44
+ path: "",
45
+ wantsFiles: false,
46
+ });
47
+ for (const c of schema.children ?? []) {
48
+ walkScopes(c.key, c, acc);
49
+ }
50
+ return acc;
51
+ }
52
+
53
+ // ── Helpers ────────────────────────────────────────────────────────────────────
54
+
55
+ function identToken(s: string): string {
56
+ return s.replace(/[^a-zA-Z0-9]/g, "_");
57
+ }
58
+
59
+ function escShellSingleQuoted(s: string): string {
60
+ return s.replace(/'/g, "'\\''");
61
+ }
62
+
63
+ function mainName(schemaName: string): string {
64
+ return schemaName.replace(/[^a-zA-Z0-9]/g, "_");
65
+ }
66
+
67
+ const kHelpLong = "--help";
68
+ const kHelpShort = "-h";
69
+
70
+ // ── Bash Completion ────────────────────────────────────────────────────────────
71
+
72
+ function emitConsumeLong(ident: string, scopes: ScopeRec[]): string {
73
+ let o = "_${ident}_nac_consume_long() {\n".replace("${ident}", ident);
74
+ o += " local sid=\"$1\" w=\"$2\" nw=\"$3\"\n";
75
+ o += " case $sid in\n";
76
+ for (const [i, sc] of scopes.entries()) {
77
+ o += " " + i + ")\n";
78
+ o += " case $w in\n";
79
+ o += " " + kHelpLong + "|${kHelpLong}=*|${kHelpShort}) echo 1 ;;\n".replace(/\$\{kHelpLong\}/g, kHelpLong).replace(/\$\{kHelpShort\}/g, kHelpShort);
80
+ for (const op of sc.opts) {
81
+ if (op.positional) continue;
82
+ const base = "--" + op.name;
83
+ if (op.kind === "presence") {
84
+ o += " " + base + "|${base}=*) echo 1 ;;\n".replace(/\$\{base\}/g, base);
85
+ } else {
86
+ o += " " + base + "=*) echo 1 ;;\n";
87
+ o += " " + base + ") echo 2 ;;\n";
88
+ }
89
+ }
90
+ o += " *) echo 0 ;;\n";
91
+ o += " esac\n";
92
+ o += " ;;\n";
93
+ }
94
+ o += " *) echo 0 ;;\n";
95
+ o += " esac\n";
96
+ o += "}\n";
97
+ return o;
98
+ }
99
+
100
+ function emitConsumeShort(ident: string, scopes: ScopeRec[]): string {
101
+ let o = "_${ident}_nac_consume_short() {\n".replace("${ident}", ident);
102
+ o += " local sid=\"$1\" w=\"$2\"\n";
103
+ o += " case $sid in\n";
104
+ for (const [i, sc] of scopes.entries()) {
105
+ o += " " + i + ")\n";
106
+ o += " local rest=${w#-}\n";
107
+ o += " local ch\n";
108
+ o += " local saw=0\n";
109
+ o += " while [[ -n $rest ]]; do\n";
110
+ o += " ch=${rest:0:1}\n";
111
+ o += " rest=${rest:1}\n";
112
+ o += " case $ch in\n";
113
+ let boolChars = "";
114
+ for (const op of sc.opts) {
115
+ if (op.positional) continue;
116
+ if (!op.shortName) continue;
117
+ if (op.kind === "presence") {
118
+ boolChars += op.shortName + "|";
119
+ } else {
120
+ o += " " + op.shortName + ")\n";
121
+ o += " if [[ $saw -ne 0 || -n $rest ]]; then echo 0; return; fi\n";
122
+ o += " echo 2; return ;;\n";
123
+ }
124
+ }
125
+ if (boolChars.length > 0) {
126
+ boolChars = boolChars.slice(0, -1);
127
+ o += " " + boolChars + ") ;;\n";
128
+ }
129
+ o += " *) echo 0; return ;;\n";
130
+ o += " esac\n";
131
+ o += " saw=1\n";
132
+ o += " done\n";
133
+ o += " echo 1\n";
134
+ o += " ;;\n";
135
+ }
136
+ o += " *) echo 0 ;;\n";
137
+ o += " esac\n";
138
+ o += "}\n";
139
+ return o;
140
+ }
141
+
142
+ function emitMatchChild(ident: string, scopes: ScopeRec[], pathIndex: Record<string, number>): string {
143
+ let o = "_${ident}_nac_match_child() {\n".replace("${ident}", ident);
144
+ o += " local sid=\"$1\" w=\"$2\"\n";
145
+ o += " case $sid in\n";
146
+ for (const [sid, sc] of scopes.entries()) {
147
+ if (sc.kids.length === 0) continue;
148
+ o += " " + sid + ")\n";
149
+ o += " case $w in\n";
150
+ for (const ch of sc.kids) {
151
+ const childPath = sc.path === "" ? ch.key : sc.path + "/" + ch.key;
152
+ const cid = pathIndex[childPath] ?? 0;
153
+ o += " " + ch.key + ") echo " + cid + "; return 0 ;;\n";
154
+ }
155
+ o += " esac\n";
156
+ o += " ;;\n";
157
+ }
158
+ o += " esac\n";
159
+ o += " return 1\n";
160
+ o += "}\n";
161
+ return o;
162
+ }
163
+
164
+ function emitSimulate(ident: string): string {
165
+ let o = "_${ident}_nac_simulate() {\n".replace("${ident}", ident);
166
+ o += " local i=1 sid=0 w steps next\n";
167
+ o += " while (( i < COMP_CWORD )); do\n";
168
+ o += " w=\"${COMP_WORDS[i]}\"\n";
169
+ o += " if [[ $w == " + kHelpShort + " || $w == " + kHelpLong + " ]]; then\n";
170
+ o += " ((i++)); continue\n";
171
+ o += " fi\n";
172
+ o += " if [[ $w == --* ]]; then\n";
173
+ o += " steps=$(_${ident}_nac_consume_long \"$sid\" \"$w\" \"${COMP_WORDS[i+1]}\")\n".replace("${ident}", ident);
174
+ o += " case $steps in\n";
175
+ o += " 0) break ;;\n";
176
+ o += " 1) ((i++)) ;;\n";
177
+ o += " 2) ((i+=2)) ;;\n";
178
+ o += " *) break ;;\n";
179
+ o += " esac\n";
180
+ o += " continue\n";
181
+ o += " fi\n";
182
+ o += " if [[ $w == -* ]]; then\n";
183
+ o += " steps=$(_${ident}_nac_consume_short \"$sid\" \"$w\")\n".replace("${ident}", ident);
184
+ o += " case $steps in\n";
185
+ o += " 0) break ;;\n";
186
+ o += " 1) ((i++)) ;;\n";
187
+ o += " 2) ((i++)); break ;;\n";
188
+ o += " *) break ;;\n";
189
+ o += " esac\n";
190
+ o += " continue\n";
191
+ o += " fi\n";
192
+ o += " next=$(_${ident}_nac_match_child \"$sid\" \"$w\") || break\n".replace("${ident}", ident);
193
+ o += " sid=$next\n";
194
+ o += " ((i++))\n";
195
+ o += " done\n";
196
+ o += " REPLY_SID=$sid\n";
197
+ o += "}\n";
198
+ return o;
199
+ }
200
+
201
+ function emitMainBodyBash(schema: CliCommand, ident: string): string {
202
+ const main = mainName(schema.key);
203
+ let o = "_${main}() {\n".replace("${main}", main);
204
+ o += " local cur=\"${COMP_WORDS[COMP_CWORD]}\"\n";
205
+ o += " local prev=\"${COMP_WORDS[COMP_CWORD-1]:-}\"\n";
206
+ o += " _${ident}_nac_simulate\n".replace("${ident}", ident);
207
+ o += " local sid=$REPLY_SID\n";
208
+ o += " if [[ $cur == -* ]]; then\n";
209
+ o += " local oname=\"A_${ident}_${sid}_opts\"\n".replace("${ident}", ident);
210
+ o += " local -a optsarr\n";
211
+ o += " local -n optsref=\"$oname\"\n";
212
+ o += " COMPREPLY=( $(compgen -W \"${optsref[*]}\" -- \"$cur\") )\n";
213
+ o += " else\n";
214
+ o += " local lname=\"A_${ident}_${sid}_leaf\"\n".replace("${ident}", ident);
215
+ o += " local -n leafref=\"$lname\"\n";
216
+ o += " if [[ $leafref -eq 0 ]]; then\n";
217
+ o += " local cname=\"A_${ident}_${sid}_cmds\"\n".replace("${ident}", ident);
218
+ o += " local -a cmdsarr\n";
219
+ o += " local -n cmdsref=\"$cname\"\n";
220
+ o += " COMPREPLY=( $(compgen -W \"${cmdsref[*]}\" -- \"$cur\") )\n";
221
+ o += " else\n";
222
+ o += " local pname=\"A_${ident}_${sid}_pos\"\n".replace("${ident}", ident);
223
+ o += " local -n posref=\"$pname\"\n";
224
+ o += " if [[ $posref -eq 1 ]]; then\n";
225
+ o += " compopt -o filenames\n";
226
+ o += " fi\n";
227
+ o += " fi\n";
228
+ o += " fi\n";
229
+ o += "}\n\n";
230
+ o += "complete -F _${main} ${schema.key}\n".replace("${main}", main).replace("${schema.key}", schema.key);
231
+ return o;
232
+ }
233
+
234
+ export function completionBashScript(schema: CliCommand): string {
235
+ const ident = identToken(schema.key);
236
+ const scopes = collectScopes(schema);
237
+ let pathIndex: Record<string, number> = {};
238
+ for (const [i, s] of scopes.entries()) {
239
+ pathIndex[s.path] = i;
240
+ }
241
+
242
+ let out = "# Generated bash completion for " + schema.key + ".\n\n";
243
+
244
+ // Emit scope arrays
245
+ for (const [i, sc] of scopes.entries()) {
246
+ out += "A_" + ident + "_" + i + "_opts=()\n";
247
+ out += "A_" + ident + "_" + i + "_opts+=('" + kHelpLong + "' '" + kHelpShort + "')\n";
248
+ for (const o of sc.opts) {
249
+ if (o.positional) continue;
250
+ out += "A_" + ident + "_" + i + "_opts+=('--" + o.name + "')\n";
251
+ if (o.shortName) {
252
+ out += "A_" + ident + "_" + i + "_opts+=('-" + o.shortName + "')\n";
253
+ }
254
+ }
255
+ out += "A_" + ident + "_" + i + "_leaf=" + (sc.kids.length === 0 ? "1" : "0") + "\n";
256
+ out += "A_" + ident + "_" + i + "_pos=" + (sc.wantsFiles ? "1" : "0") + "\n";
257
+ if (sc.kids.length > 0) {
258
+ out += "A_" + ident + "_" + i + "_cmds=(";
259
+ for (const ch of sc.kids) {
260
+ out += " '" + ch.key + "'";
261
+ }
262
+ out += ")\n";
263
+ }
264
+ }
265
+
266
+ out += emitConsumeLong(ident, scopes);
267
+ out += emitConsumeShort(ident, scopes);
268
+ out += emitMatchChild(ident, scopes, pathIndex);
269
+ out += emitSimulate(ident);
270
+ out += emitMainBodyBash(schema, ident);
271
+
272
+ return out;
273
+ }
274
+
275
+ // ── Zsh Completion ─────────────────────────────────────────────────────────────
276
+
277
+ function emitScopeArraysZsh(ident: string, scopes: ScopeRec[]): string {
278
+ let out = "";
279
+ for (const [i, sc] of scopes.entries()) {
280
+ out += "typeset -g A_" + ident + "_" + i + "_opts\n";
281
+ out += "A_" + ident + "_" + i + "_opts=(";
282
+ out += "'" + escShellSingleQuoted(kHelpLong) + ":" + escShellSingleQuoted("Show help for this command.") + "' '" + escShellSingleQuoted(kHelpShort) + ":" + escShellSingleQuoted("Show help for this command.") + "'";
283
+ for (const o of sc.opts) {
284
+ out += " '" + escShellSingleQuoted("--" + o.name) + ":" + escShellSingleQuoted(o.description) + "'";
285
+ if (o.shortName) {
286
+ out += " '" + escShellSingleQuoted("-" + o.shortName) + ":" + escShellSingleQuoted(o.description) + "'";
287
+ }
288
+ }
289
+ out += ")\n";
290
+ out += "typeset -g A_" + ident + "_" + i + "_leaf=" + (sc.kids.length === 0 ? "1" : "0") + "\n";
291
+ out += "typeset -g A_" + ident + "_" + i + "_pos=" + (sc.wantsFiles ? "1" : "0") + "\n";
292
+ if (sc.kids.length > 0) {
293
+ out += "typeset -g A_" + ident + "_" + i + "_cmds=(";
294
+ for (const ch of sc.kids) {
295
+ out +=
296
+ " '" +
297
+ escShellSingleQuoted(ch.key) +
298
+ ":" +
299
+ escShellSingleQuoted(ch.description) +
300
+ "'";
301
+ }
302
+ out += ")\n";
303
+ }
304
+ }
305
+ return out;
306
+ }
307
+
308
+ function emitConsumeLongZsh(ident: string, scopes: ScopeRec[]): string {
309
+ let o = "_${ident}_nac_consume_long() {\n".replace("${ident}", ident);
310
+ o += " local sid=\"$1\" w=\"$2\" nw=\"$3\"\n";
311
+ o += " case $sid in\n";
312
+ for (const [i, sc] of scopes.entries()) {
313
+ o += " " + i + ")\n";
314
+ o += " case $w in\n";
315
+ o += " " + kHelpLong + "|${kHelpLong}=*|${kHelpShort}) echo 1 ;;\n".replace(/\$\{kHelpLong\}/g, kHelpLong).replace(/\$\{kHelpShort\}/g, kHelpShort);
316
+ for (const op of sc.opts) {
317
+ if (op.positional) continue;
318
+ const base = "--" + op.name;
319
+ if (op.kind === "presence") {
320
+ o += " " + base + "|${base}=*) echo 1 ;;\n".replace(/\$\{base\}/g, base);
321
+ } else {
322
+ o += " " + base + "=*) echo 1 ;;\n";
323
+ o += " " + base + ") echo 2 ;;\n";
324
+ }
325
+ }
326
+ o += " *) echo 0 ;;\n";
327
+ o += " esac\n";
328
+ o += " ;;\n";
329
+ }
330
+ o += " *) echo 0 ;;\n";
331
+ o += " esac\n";
332
+ o += "}\n";
333
+ return o;
334
+ }
335
+
336
+ function emitConsumeShortZsh(ident: string, scopes: ScopeRec[]): string {
337
+ let o = "_${ident}_nac_consume_short() {\n".replace("${ident}", ident);
338
+ o += " local sid=\"$1\" w=\"$2\"\n";
339
+ o += " case $sid in\n";
340
+ for (const [i, sc] of scopes.entries()) {
341
+ o += " " + i + ")\n";
342
+ o += " local rest=${w#-}\n";
343
+ o += " local ch\n";
344
+ o += " local saw=0\n";
345
+ o += " while [[ -n $rest ]]; do\n";
346
+ o += " ch=${rest[1,1]}\n";
347
+ o += " rest=${rest[2,-1]}\n";
348
+ o += " case $ch in\n";
349
+ let boolChars = "";
350
+ for (const op of sc.opts) {
351
+ if (op.positional) continue;
352
+ if (!op.shortName) continue;
353
+ if (op.kind === "presence") {
354
+ boolChars += op.shortName + "|";
355
+ } else {
356
+ o += " " + op.shortName + ")\n";
357
+ o += " if [[ $saw -ne 0 || -n $rest ]]; then echo 0; return; fi\n";
358
+ o += " echo 2; return ;;\n";
359
+ }
360
+ }
361
+ if (boolChars.length > 0) {
362
+ boolChars = boolChars.slice(0, -1);
363
+ o += " " + boolChars + ") ;;\n";
364
+ }
365
+ o += " *) echo 0; return ;;\n";
366
+ o += " esac\n";
367
+ o += " saw=1\n";
368
+ o += " done\n";
369
+ o += " echo 1\n";
370
+ o += " ;;\n";
371
+ }
372
+ o += " *) echo 0 ;;\n";
373
+ o += " esac\n";
374
+ o += "}\n";
375
+ return o;
376
+ }
377
+
378
+ function emitMatchChildZsh(ident: string, scopes: ScopeRec[], pathIndex: Record<string, number>): string {
379
+ let o = "_${ident}_nac_match_child() {\n".replace("${ident}", ident);
380
+ o += " local sid=\"$1\" w=\"$2\"\n";
381
+ o += " case $sid in\n";
382
+ for (const [sid, sc] of scopes.entries()) {
383
+ if (sc.kids.length === 0) continue;
384
+ o += " " + sid + ")\n";
385
+ o += " case $w in\n";
386
+ for (const ch of sc.kids) {
387
+ const childPath = sc.path === "" ? ch.key : sc.path + "/" + ch.key;
388
+ const cid = pathIndex[childPath] ?? 0;
389
+ o += " " + ch.key + ") echo " + cid + "; return 0 ;;\n";
390
+ }
391
+ o += " esac\n";
392
+ o += " ;;\n";
393
+ }
394
+ o += " esac\n";
395
+ o += " return 1\n";
396
+ o += "}\n";
397
+ return o;
398
+ }
399
+
400
+ function emitSimulateZsh(ident: string): string {
401
+ let o = "_${ident}_nac_simulate() {\n".replace("${ident}", ident);
402
+ o += " local i=2 sid=0 w steps next\n";
403
+ o += " while (( i < CURRENT )); do\n";
404
+ o += " w=$words[i]\n";
405
+ o += " if [[ $w == " + kHelpShort + " || $w == " + kHelpLong + " ]]; then\n";
406
+ o += " ((i++)); continue\n";
407
+ o += " fi\n";
408
+ o += " if [[ $w == --* ]]; then\n";
409
+ o += " steps=$(_${ident}_nac_consume_long \"$sid\" \"$w\" \"${words[i+1]}\")\n".replace("${ident}", ident);
410
+ o += " case $steps in\n";
411
+ o += " 0) break ;;\n";
412
+ o += " 1) ((i++)) ;;\n";
413
+ o += " 2) ((i+=2)) ;;\n";
414
+ o += " *) break ;;\n";
415
+ o += " esac\n";
416
+ o += " continue\n";
417
+ o += " fi\n";
418
+ o += " if [[ $w == -* ]]; then\n";
419
+ o += " steps=$(_${ident}_nac_consume_short \"$sid\" \"$w\")\n".replace("${ident}", ident);
420
+ o += " case $steps in\n";
421
+ o += " 0) break ;;\n";
422
+ o += " 1) ((i++)) ;;\n";
423
+ o += " 2) ((i++)); break ;;\n";
424
+ o += " *) break ;;\n";
425
+ o += " esac\n";
426
+ o += " continue\n";
427
+ o += " fi\n";
428
+ o += " next=$(_${ident}_nac_match_child \"$sid\" \"$w\") || break\n".replace("${ident}", ident);
429
+ o += " sid=$next\n";
430
+ o += " ((i++))\n";
431
+ o += " done\n";
432
+ o += " REPLY_SID=$sid\n";
433
+ o += "}\n";
434
+ return o;
435
+ }
436
+
437
+ function emitMainBodyZsh(schema: CliCommand, ident: string): string {
438
+ const main = mainName(schema.key);
439
+ let o = "_${main}() {\n".replace("${main}", main);
440
+ o += " local curcontext=\"$curcontext\" ret=1\n";
441
+ o += " _${ident}_nac_simulate\n".replace("${ident}", ident);
442
+ o += " local sid=$REPLY_SID\n";
443
+ o += " if [[ $PREFIX == -* ]]; then\n";
444
+ o += " local -a optsarr\n";
445
+ o += " local oname=\"A_${ident}_${sid}_opts\"\n".replace("${ident}", ident);
446
+ o += " optsarr=(${(@P)oname})\n";
447
+ o += " _describe -t options 'option' optsarr && ret=0\n";
448
+ o += " else\n";
449
+ o += " local lname=\"A_${ident}_${sid}_leaf\"\n".replace("${ident}", ident);
450
+ o += " if [[ ${(P)lname} -eq 0 ]]; then\n";
451
+ o += " local -a cmdsarr\n";
452
+ o += " local cname=\"A_${ident}_${sid}_cmds\"\n".replace("${ident}", ident);
453
+ o += " cmdsarr=(${(@P)cname})\n";
454
+ o += " _describe -t commands 'command' cmdsarr && ret=0\n";
455
+ o += " else\n";
456
+ o += " local pname=\"A_${ident}_${sid}_pos\"\n".replace("${ident}", ident);
457
+ o += " if [[ ${(P)pname} -eq 1 ]]; then\n";
458
+ o += " _files && ret=0\n";
459
+ o += " fi\n";
460
+ o += " fi\n";
461
+ o += " fi\n";
462
+ o += " return ret\n";
463
+ o += "}\n\n";
464
+ o += "compdef _${main} ${schema.key}\n".replace("${main}", main).replace("${schema.key}", schema.key);
465
+ return o;
466
+ }
467
+
468
+ export function completionZshScript(schema: CliCommand): string {
469
+ const ident = identToken(schema.key);
470
+ const scopes = collectScopes(schema);
471
+ let pathIndex: Record<string, number> = {};
472
+ for (const [i, s] of scopes.entries()) {
473
+ pathIndex[s.path] = i;
474
+ }
475
+
476
+ let out = "#compdef " + schema.key + "\n\n";
477
+ out += emitScopeArraysZsh(ident, scopes);
478
+ out += emitConsumeLongZsh(ident, scopes);
479
+ out += emitConsumeShortZsh(ident, scopes);
480
+ out += emitMatchChildZsh(ident, scopes, pathIndex);
481
+ out += emitSimulateZsh(ident);
482
+ out += emitMainBodyZsh(schema, ident);
483
+ return out;
484
+ }
485
+
486
+ /**
487
+ * Builds the static `completion` / `bash` / `zsh` subtree used for shell integration.
488
+ */
489
+ export function cliBuiltinCompletionGroup(appName: string): CliCommand {
490
+ return {
491
+ key: "completion",
492
+ description: "Generate the autocompletion script for shells.",
493
+ children: [
494
+ {
495
+ key: "bash",
496
+ description: "Print a bash tab-completion script.",
497
+ notes:
498
+ "Output is the whole script.\n" +
499
+ "Pipe it to a file, or feed it straight into your shell.\n\n" +
500
+ "To keep it across restarts, save it and source that file from ~/.bashrc.\n\n" +
501
+ "For example:\n\n" +
502
+ `echo 'eval \"$(${appName} completion bash)\"' >> ~/.bashrc\n` +
503
+ `\nor\n` +
504
+ ` ${appName} completion bash > ~/.bash_completion.d/${appName}\n` +
505
+ ` echo 'source ~/.bash_completion.d/${appName}' >> ~/.bashrc\n\n` +
506
+ "To try it only in this session (nothing written to disk):\n" +
507
+ ` source <(${appName} completion bash)`,
508
+ handler: () => {},
509
+ },
510
+ {
511
+ key: "zsh",
512
+ description: "Print a zsh tab-completion script.",
513
+ notes:
514
+ "Output is the whole script.\n\n" +
515
+ `fpath setup: ${appName} completion zsh > ~/.zsh/completions/_${appName}\n\n` +
516
+ `source setup: echo 'eval \"$(${appName} completion zsh)\"' >> ~/.zshrc\n\n` +
517
+ "To try it only in this session (nothing written to disk):\n" +
518
+ ` eval \"$(${appName} completion zsh)\"`,
519
+ handler: () => {},
520
+ },
521
+ ],
522
+ };
523
+ }
package/src/context.ts ADDED
@@ -0,0 +1,67 @@
1
+ /*
2
+ This class packages parsed state for leaf handlers.
3
+ It carries the app name, routed command path, positional args, and resolved options
4
+ so handlers can focus on business logic instead of parser plumbing.
5
+
6
+ It keeps handlers small with a typed read API for flags, strings, numbers, and custom
7
+ parsed values.
8
+ */
9
+
10
+ import type { CliCommand } from "./types.ts";
11
+ import { strictParseDouble } from "./utils.ts";
12
+
13
+ /**
14
+ * Values passed to a leaf command handler after parsing: app name, routed path, args, and merged options.
15
+ */
16
+ export class CliContext {
17
+ readonly appName: string;
18
+ readonly commandPath: string[];
19
+ readonly args: string[];
20
+ readonly schema: CliCommand;
21
+ readonly opts: Record<string, string>;
22
+
23
+ constructor(
24
+ appName: string,
25
+ commandPath: string[],
26
+ args: string[],
27
+ opts: Record<string, string>,
28
+ schema: CliCommand,
29
+ ) {
30
+ this.appName = appName;
31
+ this.commandPath = commandPath;
32
+ this.args = args;
33
+ this.opts = opts;
34
+ this.schema = schema;
35
+ }
36
+
37
+ /** Returns whether a presence flag was set (including implicit "1" for boolean options). */
38
+ flag(name: string): boolean {
39
+ return this.opts[name] !== undefined;
40
+ }
41
+
42
+ /** Returns the string value for a string-valued option, if present. */
43
+ stringOpt(name: string): string | undefined {
44
+ return this.opts[name];
45
+ }
46
+
47
+ /** Parses a stored string as a number; returns null if missing or not a strict double string. */
48
+ numberOpt(name: string): number | null {
49
+ const s = this.opts[name];
50
+ if (s === undefined) return null;
51
+ return strictParseDouble(s);
52
+ }
53
+
54
+ /**
55
+ * Generic typed accessor: parses a stored string using the provided parse function.
56
+ * This is the TypeScript-native advantage over the Swift version.
57
+ */
58
+ typedOpt<T>(name: string, parse: (s: string) => T): T | null {
59
+ const s = this.opts[name];
60
+ if (s === undefined) return null;
61
+ try {
62
+ return parse(s);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+ }