argsbarg 1.5.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.cursor/plans/cliprogram_capabilities_refactor_081e1737.plan.md +224 -0
  2. package/CHANGELOG.md +31 -1
  3. package/README.md +12 -8
  4. package/docs/install.md +2 -2
  5. package/docs/mcp.md +3 -3
  6. package/examples/mcp-test.ts +3 -3
  7. package/examples/minimal.ts +3 -3
  8. package/examples/nested.ts +3 -3
  9. package/examples/option-required.ts +3 -3
  10. package/index.d.ts +38 -37
  11. package/package.json +1 -1
  12. package/src/builtins/builtins.test.ts +3 -3
  13. package/src/builtins/completion-bash.ts +3 -3
  14. package/src/builtins/completion-fish.ts +2 -2
  15. package/src/builtins/completion-group.ts +2 -2
  16. package/src/builtins/completion-zsh.ts +3 -3
  17. package/src/builtins/dispatch.ts +41 -26
  18. package/src/builtins/export.ts +15 -8
  19. package/src/builtins/install.ts +3 -3
  20. package/src/builtins/mcp.ts +2 -2
  21. package/src/builtins/presentation.ts +34 -23
  22. package/src/builtins/scopes.ts +9 -8
  23. package/src/capabilities.ts +32 -0
  24. package/src/context.ts +17 -7
  25. package/src/help.ts +21 -9
  26. package/src/index.test.ts +128 -121
  27. package/src/index.ts +1 -1
  28. package/src/install/binary.ts +3 -3
  29. package/src/install/completions.ts +2 -2
  30. package/src/install/detect-installed.ts +1 -1
  31. package/src/install/index.ts +4 -4
  32. package/src/install/install.test.ts +2 -2
  33. package/src/install/mcp-config.ts +2 -2
  34. package/src/install/paths.ts +3 -3
  35. package/src/install/plan.ts +4 -4
  36. package/src/install/status.ts +2 -2
  37. package/src/install/uninstall.ts +2 -2
  38. package/src/invoke.ts +14 -5
  39. package/src/mcp/server.ts +3 -3
  40. package/src/mcp/tools.ts +16 -16
  41. package/src/mcp.ts +2 -2
  42. package/src/parse.ts +55 -27
  43. package/src/runtime.ts +34 -25
  44. package/src/schema.ts +6 -6
  45. package/src/skill/generate.ts +6 -6
  46. package/src/skill/install.ts +2 -2
  47. package/src/types.test.ts +40 -0
  48. package/src/types.ts +54 -44
  49. package/src/validate.ts +87 -72
package/src/index.test.ts CHANGED
@@ -7,9 +7,12 @@ It keeps the CLI contract stable by catching routing, option handling, and gener
7
7
  shell output regressions.
8
8
  */
9
9
 
10
+ import { cliPresentationRoot } from "./builtins/presentation.ts";
10
11
  import { completionBashScript, completionZshScript } from "./completion.ts";
11
12
  import { cliHelpRender } from "./help.ts";
12
- import { CliCommand, CliFallbackMode, CliOptionKind, cliInvoke } from "./index.ts";
13
+ import { CliProgram, CliFallbackMode, CliOptionKind, cliInvoke } from "./index.ts";
14
+ import type { CliLeaf } from "./types.ts";
15
+ import { isCliRouter } from "./types.ts";
13
16
  import {
14
17
  allMcpResources,
15
18
  collectMcpTools,
@@ -23,7 +26,7 @@ import { generateSkillBundle } from "./skill/generate.ts";
23
26
  import { cliSkillInstall } from "./skill/install.ts";
24
27
  import { ParseKind, parse, postParseValidate } from "./parse.ts";
25
28
  import { cliSchemaJson } from "./schema.ts";
26
- import { cliValidateRoot } from "./validate.ts";
29
+ import { cliValidateProgram } from "./validate.ts";
27
30
  import { expect, test } from "bun:test";
28
31
  import { $ } from "bun";
29
32
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -31,7 +34,7 @@ import { tmpdir } from "node:os";
31
34
  import { join } from "node:path";
32
35
 
33
36
  test("bundled short presence flags", () => {
34
- const root: CliCommand = {
37
+ const root: CliProgram = {
35
38
  key: "app",
36
39
  description: "",
37
40
  commands: [
@@ -56,7 +59,7 @@ test("bundled short presence flags", () => {
56
59
  },
57
60
  ],
58
61
  };
59
- cliValidateRoot(root);
62
+ cliValidateProgram(root);
60
63
  const pr = postParseValidate(root, parse(root, ["x", "-ab"]));
61
64
  expect(pr.kind).toBe(ParseKind.Ok);
62
65
  expect(pr.opts["a"]).toBe("1");
@@ -64,7 +67,7 @@ test("bundled short presence flags", () => {
64
67
  });
65
68
 
66
69
  test("long option equals", () => {
67
- const root: CliCommand = {
70
+ const root: CliProgram = {
68
71
  key: "app",
69
72
  description: "",
70
73
  commands: [
@@ -82,14 +85,14 @@ test("long option equals", () => {
82
85
  },
83
86
  ],
84
87
  };
85
- cliValidateRoot(root);
88
+ cliValidateProgram(root);
86
89
  const pr = postParseValidate(root, parse(root, ["x", "--name=pat"]));
87
90
  expect(pr.kind).toBe(ParseKind.Ok);
88
91
  expect(pr.opts["name"]).toBe("pat");
89
92
  });
90
93
 
91
94
  test("fallback missing or unknown root flags", () => {
92
- const root: CliCommand = {
95
+ const root: CliProgram = {
93
96
  key: "app",
94
97
  description: "",
95
98
  commands: [
@@ -109,7 +112,7 @@ test("fallback missing or unknown root flags", () => {
109
112
  fallbackCommand: "hello",
110
113
  fallbackMode: CliFallbackMode.MissingOrUnknown,
111
114
  };
112
- cliValidateRoot(root);
115
+ cliValidateProgram(root);
113
116
  const pr = postParseValidate(root, parse(root, ["--name", "bob"]));
114
117
  expect(pr.kind).toBe(ParseKind.Ok);
115
118
  expect(pr.path).toEqual(["hello"]);
@@ -117,31 +120,31 @@ test("fallback missing or unknown root flags", () => {
117
120
  });
118
121
 
119
122
  test("unknown command", () => {
120
- const root: CliCommand = {
123
+ const root: CliProgram = {
121
124
  key: "app",
122
125
  description: "",
123
126
  commands: [{ key: "hello", description: "", handler: () => {} }],
124
127
  };
125
- cliValidateRoot(root);
128
+ cliValidateProgram(root);
126
129
  const pr = parse(root, ["nope"]);
127
130
  expect(pr.kind).toBe(ParseKind.Error);
128
131
  expect(pr.errorMsg).toContain("Unknown command");
129
132
  });
130
133
 
131
134
  test("implicit help empty", () => {
132
- const root: CliCommand = {
135
+ const root: CliProgram = {
133
136
  key: "app",
134
137
  description: "",
135
138
  commands: [{ key: "x", description: "", handler: () => {} }],
136
139
  };
137
- cliValidateRoot(root);
140
+ cliValidateProgram(root);
138
141
  const pr = parse(root, []);
139
142
  expect(pr.kind).toBe(ParseKind.Help);
140
143
  expect(pr.helpExplicit).toBe(false);
141
144
  });
142
145
 
143
146
  test("invalid number post validate", () => {
144
- const root: CliCommand = {
147
+ const root: CliProgram = {
145
148
  key: "app",
146
149
  description: "",
147
150
  commands: [
@@ -159,7 +162,7 @@ test("invalid number post validate", () => {
159
162
  },
160
163
  ],
161
164
  };
162
- cliValidateRoot(root);
165
+ cliValidateProgram(root);
163
166
  let pr = parse(root, ["x", "--n", "notnum"]);
164
167
  pr = postParseValidate(root, pr);
165
168
  expect(pr.kind).toBe(ParseKind.Error);
@@ -167,7 +170,7 @@ test("invalid number post validate", () => {
167
170
  });
168
171
 
169
172
  test("supports scientific notation in numbers", () => {
170
- const root: CliCommand = {
173
+ const root: CliProgram = {
171
174
  key: "app",
172
175
  description: "",
173
176
  commands: [
@@ -185,7 +188,7 @@ test("supports scientific notation in numbers", () => {
185
188
  },
186
189
  ],
187
190
  };
188
- cliValidateRoot(root);
191
+ cliValidateProgram(root);
189
192
  let pr = parse(root, ["x", "--n", "1.23e4"]);
190
193
  pr = postParseValidate(root, pr);
191
194
  expect(pr.kind).toBe(ParseKind.Ok);
@@ -195,12 +198,12 @@ test("supports scientific notation in numbers", () => {
195
198
 
196
199
 
197
200
  test("completion scripts contain app name", () => {
198
- const root: CliCommand = {
201
+ const root: CliProgram = {
199
202
  key: "myapp",
200
203
  description: "Test",
201
204
  commands: [{ key: "hello", description: "Say hello.", handler: () => {} }],
202
205
  };
203
- cliValidateRoot(root);
206
+ cliValidateProgram(root);
204
207
  const bash = completionBashScript(root);
205
208
  expect(bash).toContain("bash completion for myapp");
206
209
  expect(bash).toContain("complete -F _myapp myapp");
@@ -212,18 +215,18 @@ test("completion scripts contain app name", () => {
212
215
  });
213
216
 
214
217
  test("completion scripts do not emit invalid bash substitutions", () => {
215
- const root: CliCommand = {
218
+ const root: CliProgram = {
216
219
  key: "app",
217
220
  description: "Test",
218
221
  commands: [{ key: "hello", description: "Say hello.", handler: () => {} }],
219
222
  };
220
- cliValidateRoot(root);
223
+ cliValidateProgram(root);
221
224
  const bash = completionBashScript(root);
222
225
  expect(bash).not.toContain("${${");
223
226
  });
224
227
 
225
228
  test("completion scripts escape shell-sensitive command text in zsh", () => {
226
- const root: CliCommand = {
229
+ const root: CliProgram = {
227
230
  key: "app",
228
231
  description: "Test",
229
232
  commands: [
@@ -234,18 +237,18 @@ test("completion scripts escape shell-sensitive command text in zsh", () => {
234
237
  },
235
238
  ],
236
239
  };
237
- cliValidateRoot(root);
240
+ cliValidateProgram(root);
238
241
  const zsh = completionZshScript(root);
239
242
  expect(zsh).toContain("quote'\\''cmd:Say '\\''hello'\\'' and keep going.");
240
243
  });
241
244
 
242
245
  test("completion scripts keep dotted app names in registration names", () => {
243
- const root: CliCommand = {
246
+ const root: CliProgram = {
244
247
  key: "minimal.ts",
245
248
  description: "Test",
246
249
  commands: [{ key: "hello", description: "Say hello.", handler: () => {} }],
247
250
  };
248
- cliValidateRoot(root);
251
+ cliValidateProgram(root);
249
252
 
250
253
  const bash = completionBashScript(root);
251
254
  expect(bash).toContain("complete -F _minimal_ts minimal.ts");
@@ -255,7 +258,7 @@ test("completion scripts keep dotted app names in registration names", () => {
255
258
  });
256
259
 
257
260
  test("trailing options after bounded positionals", () => {
258
- const root: CliCommand = {
261
+ const root: CliProgram = {
259
262
  key: "app",
260
263
  description: "",
261
264
  commands: [
@@ -280,7 +283,7 @@ test("trailing options after bounded positionals", () => {
280
283
  },
281
284
  ],
282
285
  };
283
- cliValidateRoot(root);
286
+ cliValidateProgram(root);
284
287
  const pr = postParseValidate(root, parse(root, ["x", "./file", "--verbose"]));
285
288
  expect(pr.kind).toBe(ParseKind.Ok);
286
289
  expect(pr.args).toEqual(["./file"]);
@@ -288,7 +291,7 @@ test("trailing options after bounded positionals", () => {
288
291
  });
289
292
 
290
293
  test("trailing options include parent-scoped flags", () => {
291
- const root: CliCommand = {
294
+ const root: CliProgram = {
292
295
  key: "app",
293
296
  description: "",
294
297
  commands: [
@@ -327,7 +330,7 @@ test("trailing options include parent-scoped flags", () => {
327
330
  },
328
331
  ],
329
332
  };
330
- cliValidateRoot(root);
333
+ cliValidateProgram(root);
331
334
  const pr = postParseValidate(root, parse(root, ["group", "leaf", "-u", "alice", "./file", "--json"]));
332
335
  expect(pr.kind).toBe(ParseKind.Ok);
333
336
  expect(pr.path).toEqual(["group", "leaf"]);
@@ -337,7 +340,7 @@ test("trailing options include parent-scoped flags", () => {
337
340
  });
338
341
 
339
342
  test("varargs tail parses trailing options", () => {
340
- const root: CliCommand = {
343
+ const root: CliProgram = {
341
344
  key: "app",
342
345
  description: "",
343
346
  commands: [
@@ -364,7 +367,7 @@ test("varargs tail parses trailing options", () => {
364
367
  },
365
368
  ],
366
369
  };
367
- cliValidateRoot(root);
370
+ cliValidateProgram(root);
368
371
  const pr = postParseValidate(root, parse(root, ["x", "./file", "--json"]));
369
372
  expect(pr.kind).toBe(ParseKind.Ok);
370
373
  expect(pr.args).toEqual(["./file"]);
@@ -372,7 +375,7 @@ test("varargs tail parses trailing options", () => {
372
375
  });
373
376
 
374
377
  test("stops parsing options at --", () => {
375
- const root: CliCommand = {
378
+ const root: CliProgram = {
376
379
  key: "app",
377
380
  description: "",
378
381
  commands: [
@@ -399,7 +402,7 @@ test("stops parsing options at --", () => {
399
402
  },
400
403
  ],
401
404
  };
402
- cliValidateRoot(root);
405
+ cliValidateProgram(root);
403
406
  const pr = postParseValidate(root, parse(root, ["x", "--name", "pat", "--", "--name", "bob", "-x"]));
404
407
  expect(pr.kind).toBe(ParseKind.Ok);
405
408
  expect(pr.opts["name"]).toBe("pat");
@@ -407,7 +410,7 @@ test("stops parsing options at --", () => {
407
410
  });
408
411
 
409
412
  test("missing required option returns error", () => {
410
- const root: CliCommand = {
413
+ const root: CliProgram = {
411
414
  key: "app",
412
415
  description: "",
413
416
  options: [
@@ -426,14 +429,14 @@ test("missing required option returns error", () => {
426
429
  },
427
430
  ],
428
431
  };
429
- cliValidateRoot(root);
432
+ cliValidateProgram(root);
430
433
  const pr = postParseValidate(root, parse(root, ["x"]));
431
434
  expect(pr.kind).toBe(ParseKind.Error);
432
435
  expect(pr.errorMsg).toContain("Missing required option: --req");
433
436
  });
434
437
 
435
438
  test("provided required option parses ok", () => {
436
- const root: CliCommand = {
439
+ const root: CliProgram = {
437
440
  key: "app",
438
441
  description: "",
439
442
  commands: [
@@ -452,14 +455,14 @@ test("provided required option parses ok", () => {
452
455
  },
453
456
  ],
454
457
  };
455
- cliValidateRoot(root);
458
+ cliValidateProgram(root);
456
459
  const pr = postParseValidate(root, parse(root, ["x", "--req", "val"]));
457
460
  expect(pr.kind).toBe(ParseKind.Ok);
458
461
  expect(pr.opts["req"]).toBe("val");
459
462
  });
460
463
 
461
464
  test("presence option cannot be required", () => {
462
- const root: CliCommand = {
465
+ const root: CliProgram = {
463
466
  key: "app",
464
467
  description: "",
465
468
  options: [
@@ -478,7 +481,7 @@ test("presence option cannot be required", () => {
478
481
  },
479
482
  ],
480
483
  };
481
- expect(() => cliValidateRoot(root)).toThrow(/Presence option cannot be required/);
484
+ expect(() => cliValidateProgram(root)).toThrow(/Presence option cannot be required/);
482
485
  });
483
486
 
484
487
  test("leaf completion help prints correctly", async () => {
@@ -527,7 +530,7 @@ test("leaf root help lists completion built-in", async () => {
527
530
  });
528
531
 
529
532
  test("parse recognizes --schema at the program root", () => {
530
- const root: CliCommand = {
533
+ const root: CliProgram = {
531
534
  key: "app",
532
535
  description: "demo",
533
536
  commands: [
@@ -538,13 +541,13 @@ test("parse recognizes --schema at the program root", () => {
538
541
  },
539
542
  ],
540
543
  };
541
- cliValidateRoot(root);
544
+ cliValidateProgram(root);
542
545
  const pr = parse(root, ["--schema"]);
543
546
  expect(pr.kind).toBe(ParseKind.Schema);
544
547
  });
545
548
 
546
549
  test("cliSchemaJson omits handlers and completion built-ins", () => {
547
- const root: CliCommand = {
550
+ const root: CliProgram = {
548
551
  key: "app",
549
552
  description: "demo",
550
553
  commands: [
@@ -574,7 +577,7 @@ test("cliSchemaJson omits handlers and completion built-ins", () => {
574
577
  });
575
578
 
576
579
  test("reserved option name schema is rejected", () => {
577
- const root: CliCommand = {
580
+ const root: CliProgram = {
578
581
  key: "app",
579
582
  description: "",
580
583
  commands: [
@@ -592,11 +595,11 @@ test("reserved option name schema is rejected", () => {
592
595
  },
593
596
  ],
594
597
  };
595
- expect(() => cliValidateRoot(root)).toThrow(/reserved for --schema/);
598
+ expect(() => cliValidateProgram(root)).toThrow(/reserved for --schema/);
596
599
  });
597
600
 
598
601
  test("root help lists --schema built-in", () => {
599
- const root: CliCommand = {
602
+ const root: CliProgram = {
600
603
  key: "app",
601
604
  description: "demo",
602
605
  commands: [
@@ -607,13 +610,13 @@ test("root help lists --schema built-in", () => {
607
610
  },
608
611
  ],
609
612
  };
610
- const help = cliHelpRender(root, [], false);
613
+ const help = cliHelpRender(cliPresentationRoot(root), [], false);
611
614
  expect(help).toContain("--schema");
612
615
  expect(help).toContain("Print the full command tree as JSON.");
613
616
  });
614
617
 
615
618
  test("nested help omits --schema built-in", () => {
616
- const root: CliCommand = {
619
+ const root: CliProgram = {
617
620
  key: "app",
618
621
  description: "demo",
619
622
  commands: [
@@ -624,12 +627,12 @@ test("nested help omits --schema built-in", () => {
624
627
  },
625
628
  ],
626
629
  };
627
- const help = cliHelpRender(root, ["x"], false);
630
+ const help = cliHelpRender(cliPresentationRoot(root), ["x"], false);
628
631
  expect(help).not.toContain("--schema");
629
632
  });
630
633
 
631
634
  test("completion scripts offer --schema at the program root only", () => {
632
- const root: CliCommand = {
635
+ const root: CliProgram = {
633
636
  key: "myapp",
634
637
  description: "",
635
638
  commands: [
@@ -655,7 +658,7 @@ test("completion scripts offer --schema at the program root only", () => {
655
658
  expect(zsh).toContain("'--schema:Print the full command tree as JSON.'");
656
659
  });
657
660
 
658
- const nestedMcpFixture: CliCommand = {
661
+ const nestedMcpFixture: CliProgram = {
659
662
  key: "nested.ts",
660
663
  description: "Nested groups demo.",
661
664
  mcpServer: { name: "nested-demo", version: "1.0.0" },
@@ -811,7 +814,7 @@ test("mcpToolCallToArgv expands varargs positionals", () => {
811
814
  });
812
815
 
813
816
  test("reserved command name install is rejected", () => {
814
- const root: CliCommand = {
817
+ const root: CliProgram = {
815
818
  key: "app",
816
819
  description: "",
817
820
  commands: [
@@ -822,11 +825,11 @@ test("reserved command name install is rejected", () => {
822
825
  },
823
826
  ],
824
827
  };
825
- expect(() => cliValidateRoot(root)).toThrow(/Reserved command name: install/);
828
+ expect(() => cliValidateProgram(root)).toThrow(/Reserved command name: install/);
826
829
  });
827
830
 
828
831
  test("top-level command name mcp is allowed without mcpServer", () => {
829
- const root: CliCommand = {
832
+ const root: CliProgram = {
830
833
  key: "app",
831
834
  description: "",
832
835
  commands: [
@@ -837,11 +840,11 @@ test("top-level command name mcp is allowed without mcpServer", () => {
837
840
  },
838
841
  ],
839
842
  };
840
- expect(() => cliValidateRoot(root)).not.toThrow();
843
+ expect(() => cliValidateProgram(root)).not.toThrow();
841
844
  });
842
845
 
843
846
  test("top-level command name mcp is rejected when mcpServer is set", () => {
844
- const root: CliCommand = {
847
+ const root: CliProgram = {
845
848
  key: "app",
846
849
  description: "",
847
850
  mcpServer: {},
@@ -853,11 +856,11 @@ test("top-level command name mcp is rejected when mcpServer is set", () => {
853
856
  },
854
857
  ],
855
858
  };
856
- expect(() => cliValidateRoot(root)).toThrow(/Reserved command name: mcp/);
859
+ expect(() => cliValidateProgram(root)).toThrow(/Reserved command name: mcp/);
857
860
  });
858
861
 
859
862
  test("mcpServer on non-root node is rejected", () => {
860
- const root: CliCommand = {
863
+ const root = {
861
864
  key: "app",
862
865
  description: "",
863
866
  commands: [
@@ -868,22 +871,22 @@ test("mcpServer on non-root node is rejected", () => {
868
871
  handler: () => {},
869
872
  },
870
873
  ],
871
- };
872
- expect(() => cliValidateRoot(root)).toThrow(/mcpServer is only supported on the program root/);
874
+ } as unknown as CliProgram;
875
+ expect(() => cliValidateProgram(root)).toThrow(/mcpServer is only supported on the program root/);
873
876
  });
874
877
 
875
878
  test("mcpTool on root is rejected", () => {
876
- const root: CliCommand = {
879
+ const root: CliProgram = {
877
880
  key: "app",
878
881
  description: "",
879
882
  mcpTool: { enabled: false },
880
883
  handler: () => {},
881
884
  };
882
- expect(() => cliValidateRoot(root)).toThrow(/mcpTool is only supported on leaf commands/);
885
+ expect(() => cliValidateProgram(root)).toThrow(/mcpTool is only supported on leaf commands/);
883
886
  });
884
887
 
885
888
  test("mcpTool on routing node is rejected", () => {
886
- const root: CliCommand = {
889
+ const root: CliProgram = {
887
890
  key: "app",
888
891
  description: "",
889
892
  commands: [
@@ -901,7 +904,7 @@ test("mcpTool on routing node is rejected", () => {
901
904
  },
902
905
  ],
903
906
  };
904
- expect(() => cliValidateRoot(root)).toThrow(/mcpTool is only supported on leaf commands/);
907
+ expect(() => cliValidateProgram(root)).toThrow(/mcpTool is only supported on leaf commands/);
905
908
  });
906
909
 
907
910
  test("buildToolCallSuccess returns stdout only", () => {
@@ -1040,7 +1043,7 @@ test("minimal.ts mcp without opt-in fails", async () => {
1040
1043
  test("ctx.invocation is cli via cliRun", async () => {
1041
1044
  const indexPath = join(import.meta.dir, "index.ts");
1042
1045
  const { stdout } = await $`bun -e ${`
1043
- import { cliRun, CliCommand } from ${JSON.stringify(indexPath)};
1046
+ import { cliRun, CliProgram } from ${JSON.stringify(indexPath)};
1044
1047
  const cli = { key: "t", description: "d", handler: (ctx) => console.log(ctx.invocation) };
1045
1048
  await cliRun(cli, []);
1046
1049
  `}`.quiet();
@@ -1049,20 +1052,20 @@ await cliRun(cli, []);
1049
1052
 
1050
1053
  test("ctx.invocation is mcp via cliInvoke", async () => {
1051
1054
  let seen = "";
1052
- const root: CliCommand = {
1055
+ const root: CliProgram = {
1053
1056
  key: "app",
1054
1057
  description: "",
1055
1058
  handler: (ctx) => {
1056
1059
  seen = ctx.invocation;
1057
1060
  },
1058
1061
  };
1059
- cliValidateRoot(root);
1062
+ cliValidateProgram(root);
1060
1063
  const result = await cliInvoke(root, []);
1061
1064
  expect(result.kind).toBe("ok");
1062
1065
  expect(seen).toBe("mcp");
1063
1066
  });
1064
1067
 
1065
- const enumMcpFixture: CliCommand = {
1068
+ const enumMcpFixture: CliProgram = {
1066
1069
  key: "app",
1067
1070
  description: "",
1068
1071
  mcpServer: {},
@@ -1092,7 +1095,7 @@ test("Enum option inputSchema includes enum array", () => {
1092
1095
  });
1093
1096
 
1094
1097
  test("cliInvoke rejects invalid Enum value", async () => {
1095
- const root: CliCommand = {
1098
+ const root: CliProgram = {
1096
1099
  key: "app",
1097
1100
  description: "",
1098
1101
  handler: () => {},
@@ -1106,14 +1109,14 @@ test("cliInvoke rejects invalid Enum value", async () => {
1106
1109
  },
1107
1110
  ],
1108
1111
  };
1109
- cliValidateRoot(root);
1112
+ cliValidateProgram(root);
1110
1113
  const result = await cliInvoke(root, ["--mode", "staging"]);
1111
1114
  expect(result.kind).toBe("error");
1112
1115
  expect(result.errorMsg).toContain("not one of");
1113
1116
  });
1114
1117
 
1115
1118
  test("cliInvoke accepts valid Enum value", async () => {
1116
- const root: CliCommand = {
1119
+ const root: CliProgram = {
1117
1120
  key: "app",
1118
1121
  description: "",
1119
1122
  handler: (ctx) => {
@@ -1129,34 +1132,34 @@ test("cliInvoke accepts valid Enum value", async () => {
1129
1132
  },
1130
1133
  ],
1131
1134
  };
1132
- cliValidateRoot(root);
1135
+ cliValidateProgram(root);
1133
1136
  const result = await cliInvoke(root, ["--mode", "dev"]);
1134
1137
  expect(result.kind).toBe("ok");
1135
1138
  expect(result.stdout.trim()).toBe("dev");
1136
1139
  });
1137
1140
 
1138
- test("cliValidateRoot rejects Enum with no choices", () => {
1139
- const root: CliCommand = {
1141
+ test("cliValidateProgram rejects Enum with no choices", () => {
1142
+ const root: CliProgram = {
1140
1143
  key: "app",
1141
1144
  description: "",
1142
1145
  handler: () => {},
1143
1146
  options: [{ name: "mode", description: "", kind: CliOptionKind.Enum, choices: [] }],
1144
1147
  };
1145
- expect(() => cliValidateRoot(root)).toThrow(/requires non-empty choices/);
1148
+ expect(() => cliValidateProgram(root)).toThrow(/requires non-empty choices/);
1146
1149
  });
1147
1150
 
1148
- test("cliValidateRoot rejects Enum with duplicate choices", () => {
1149
- const root: CliCommand = {
1151
+ test("cliValidateProgram rejects Enum with duplicate choices", () => {
1152
+ const root: CliProgram = {
1150
1153
  key: "app",
1151
1154
  description: "",
1152
1155
  handler: () => {},
1153
1156
  options: [{ name: "mode", description: "", kind: CliOptionKind.Enum, choices: ["a", "a"] }],
1154
1157
  };
1155
- expect(() => cliValidateRoot(root)).toThrow(/choices must be distinct/);
1158
+ expect(() => cliValidateProgram(root)).toThrow(/choices must be distinct/);
1156
1159
  });
1157
1160
 
1158
1161
  test("mcpTool.description override wins without requiresEnv suffix", () => {
1159
- const root: CliCommand = {
1162
+ const root: CliProgram = {
1160
1163
  key: "app",
1161
1164
  description: "",
1162
1165
  mcpServer: {},
@@ -1174,7 +1177,7 @@ test("mcpTool.description override wins without requiresEnv suffix", () => {
1174
1177
  });
1175
1178
 
1176
1179
  test("mcpTool.requiresEnv appended to auto description", () => {
1177
- const root: CliCommand = {
1180
+ const root: CliProgram = {
1178
1181
  key: "app",
1179
1182
  description: "",
1180
1183
  mcpServer: {},
@@ -1191,8 +1194,8 @@ test("mcpTool.requiresEnv appended to auto description", () => {
1191
1194
  expect(tools[0]!.description).toContain("[requires env: TOKEN]");
1192
1195
  });
1193
1196
 
1194
- test("cliValidateRoot rejects duplicate mcpResources URIs", () => {
1195
- const root: CliCommand = {
1197
+ test("cliValidateProgram rejects duplicate mcpResources URIs", () => {
1198
+ const root: CliProgram = {
1196
1199
  key: "app",
1197
1200
  description: "",
1198
1201
  mcpServer: {
@@ -1203,11 +1206,11 @@ test("cliValidateRoot rejects duplicate mcpResources URIs", () => {
1203
1206
  },
1204
1207
  commands: [{ key: "x", description: "", handler: () => {} }],
1205
1208
  };
1206
- expect(() => cliValidateRoot(root)).toThrow(/URIs must be unique/);
1209
+ expect(() => cliValidateProgram(root)).toThrow(/URIs must be unique/);
1207
1210
  });
1208
1211
 
1209
- test("cliValidateRoot rejects resource URI matching schemaResourceUri", () => {
1210
- const root: CliCommand = {
1212
+ test("cliValidateProgram rejects resource URI matching schemaResourceUri", () => {
1213
+ const root: CliProgram = {
1211
1214
  key: "app",
1212
1215
  description: "",
1213
1216
  mcpServer: {
@@ -1216,11 +1219,11 @@ test("cliValidateRoot rejects resource URI matching schemaResourceUri", () => {
1216
1219
  },
1217
1220
  commands: [{ key: "x", description: "", handler: () => {} }],
1218
1221
  };
1219
- expect(() => cliValidateRoot(root)).toThrow(/conflicts with the built-in schema resource/);
1222
+ expect(() => cliValidateProgram(root)).toThrow(/conflicts with the built-in schema resource/);
1220
1223
  });
1221
1224
 
1222
1225
  test("allMcpResources includes custom resources", () => {
1223
- const root: CliCommand = {
1226
+ const root: CliProgram = {
1224
1227
  key: "app",
1225
1228
  description: "",
1226
1229
  mcpServer: {
@@ -1263,7 +1266,7 @@ test("loadEnvFile overwrites existing keys", () => {
1263
1266
  });
1264
1267
 
1265
1268
  test("Enum completions list choices in bash script", () => {
1266
- const root: CliCommand = {
1269
+ const root: CliProgram = {
1267
1270
  key: "app",
1268
1271
  description: "",
1269
1272
  commands: [
@@ -1368,7 +1371,7 @@ test("MCP envFile loads vars for tool handlers", async () => {
1368
1371
 
1369
1372
  // ── v1.3 parser ergonomics ────────────────────────────────────────────────────
1370
1373
 
1371
- function varargsReadFixture(): CliCommand {
1374
+ function varargsReadFixture(): CliProgram {
1372
1375
  return {
1373
1376
  key: "app",
1374
1377
  description: "",
@@ -1398,7 +1401,7 @@ function varargsReadFixture(): CliCommand {
1398
1401
  };
1399
1402
  }
1400
1403
 
1401
- function nestedDocsFallbackFixture(): CliCommand {
1404
+ function nestedDocsFallbackFixture(): CliProgram {
1402
1405
  return {
1403
1406
  key: "app",
1404
1407
  description: "",
@@ -1427,14 +1430,14 @@ function nestedDocsFallbackFixture(): CliCommand {
1427
1430
 
1428
1431
  test("nested fallback routes to default when argv exhausted at router", () => {
1429
1432
  const root = nestedDocsFallbackFixture();
1430
- cliValidateRoot(root);
1433
+ cliValidateProgram(root);
1431
1434
  const pr = postParseValidate(root, parse(root, ["docs"]));
1432
1435
  expect(pr.kind).toBe(ParseKind.Ok);
1433
1436
  expect(pr.path).toEqual(["docs", "guide"]);
1434
1437
  });
1435
1438
 
1436
1439
  test("nested fallback MissingOrUnknown routes unknown token to default", () => {
1437
- const root: CliCommand = {
1440
+ const root: CliProgram = {
1438
1441
  key: "app",
1439
1442
  description: "",
1440
1443
  commands: [
@@ -1467,7 +1470,7 @@ test("nested fallback MissingOrUnknown routes unknown token to default", () => {
1467
1470
  },
1468
1471
  ],
1469
1472
  };
1470
- cliValidateRoot(root);
1473
+ cliValidateProgram(root);
1471
1474
  const pr = postParseValidate(root, parse(root, ["docs", "extra-topic"]));
1472
1475
  expect(pr.kind).toBe(ParseKind.Ok);
1473
1476
  expect(pr.path).toEqual(["docs", "guide"]);
@@ -1476,14 +1479,14 @@ test("nested fallback MissingOrUnknown routes unknown token to default", () => {
1476
1479
 
1477
1480
  test("nested fallback MissingOnly errors on unknown subcommand", () => {
1478
1481
  const root = nestedDocsFallbackFixture();
1479
- cliValidateRoot(root);
1482
+ cliValidateProgram(root);
1480
1483
  const pr = parse(root, ["docs", "nope"]);
1481
1484
  expect(pr.kind).toBe(ParseKind.Error);
1482
1485
  expect(pr.errorMsg).toContain("Unknown subcommand");
1483
1486
  });
1484
1487
 
1485
- test("cliValidateRoot rejects invalid nested fallbackCommand", () => {
1486
- const root: CliCommand = {
1488
+ test("cliValidateProgram rejects invalid nested fallbackCommand", () => {
1489
+ const root: CliProgram = {
1487
1490
  key: "app",
1488
1491
  description: "",
1489
1492
  commands: [
@@ -1501,29 +1504,29 @@ test("cliValidateRoot rejects invalid nested fallbackCommand", () => {
1501
1504
  },
1502
1505
  ],
1503
1506
  };
1504
- expect(() => cliValidateRoot(root)).toThrow(/fallbackCommand 'missing' is not a child of 'docs'/);
1507
+ expect(() => cliValidateProgram(root)).toThrow(/fallbackCommand 'missing' is not a child of 'docs'/);
1505
1508
  });
1506
1509
 
1507
- test("cliValidateRoot accepts nested fallbackCommand when child exists", () => {
1510
+ test("cliValidateProgram accepts nested fallbackCommand when child exists", () => {
1508
1511
  const root = nestedDocsFallbackFixture();
1509
- expect(() => cliValidateRoot(root)).not.toThrow();
1512
+ expect(() => cliValidateProgram(root)).not.toThrow();
1510
1513
  });
1511
1514
 
1512
1515
  test("nested router scoped help does not route to fallback", () => {
1513
1516
  const root = nestedDocsFallbackFixture();
1514
- cliValidateRoot(root);
1517
+ cliValidateProgram(root);
1515
1518
  const pr = parse(root, ["docs", "--help"]);
1516
1519
  expect(pr.kind).toBe(ParseKind.Help);
1517
1520
  expect(pr.helpPath).toEqual(["docs"]);
1518
1521
  expect(pr.helpExplicit).toBe(true);
1519
- const help = cliHelpRender(root, pr.helpPath, false);
1522
+ const help = cliHelpRender(cliPresentationRoot(root), pr.helpPath, false);
1520
1523
  expect(help).toContain("api");
1521
1524
  expect(help).toContain("guide");
1522
1525
  });
1523
1526
 
1524
1527
  test("varargs trailing option after positionals via cliInvoke", async () => {
1525
1528
  const root = varargsReadFixture();
1526
- cliValidateRoot(root);
1529
+ cliValidateProgram(root);
1527
1530
  const pr = postParseValidate(root, parse(root, ["read", "file.txt", "--json"]));
1528
1531
  expect(pr.kind).toBe(ParseKind.Ok);
1529
1532
  expect(pr.args).toEqual(["file.txt"]);
@@ -1532,7 +1535,7 @@ test("varargs trailing option after positionals via cliInvoke", async () => {
1532
1535
 
1533
1536
  test("varargs option before positionals", () => {
1534
1537
  const root = varargsReadFixture();
1535
- cliValidateRoot(root);
1538
+ cliValidateProgram(root);
1536
1539
  const pr = postParseValidate(root, parse(root, ["read", "--json", "file.txt"]));
1537
1540
  expect(pr.kind).toBe(ParseKind.Ok);
1538
1541
  expect(pr.args).toEqual(["file.txt"]);
@@ -1541,7 +1544,7 @@ test("varargs option before positionals", () => {
1541
1544
 
1542
1545
  test("varargs multiple files then trailing option", () => {
1543
1546
  const root = varargsReadFixture();
1544
- cliValidateRoot(root);
1547
+ cliValidateProgram(root);
1545
1548
  const pr = postParseValidate(root, parse(root, ["read", "a.txt", "b.txt", "--json"]));
1546
1549
  expect(pr.kind).toBe(ParseKind.Ok);
1547
1550
  expect(pr.args).toEqual(["a.txt", "b.txt"]);
@@ -1550,7 +1553,7 @@ test("varargs multiple files then trailing option", () => {
1550
1553
 
1551
1554
  test("varargs double dash forces positional", () => {
1552
1555
  const root = varargsReadFixture();
1553
- cliValidateRoot(root);
1556
+ cliValidateProgram(root);
1554
1557
  const pr = postParseValidate(root, parse(root, ["read", "file.txt", "--", "--json"]));
1555
1558
  expect(pr.kind).toBe(ParseKind.Ok);
1556
1559
  expect(pr.args).toEqual(["file.txt", "--json"]);
@@ -1559,7 +1562,7 @@ test("varargs double dash forces positional", () => {
1559
1562
 
1560
1563
  test("varargs unknown flag errors", async () => {
1561
1564
  const root = varargsReadFixture();
1562
- cliValidateRoot(root);
1565
+ cliValidateProgram(root);
1563
1566
  const result = await cliInvoke(root, ["read", "--unknown"]);
1564
1567
  expect(result.kind).toBe("error");
1565
1568
  expect(result.stderr).toContain("--unknown");
@@ -1567,7 +1570,7 @@ test("varargs unknown flag errors", async () => {
1567
1570
 
1568
1571
  test("varargs scoped help in tail", () => {
1569
1572
  const root = varargsReadFixture();
1570
- cliValidateRoot(root);
1573
+ cliValidateProgram(root);
1571
1574
  const pr = parse(root, ["read", "file.txt", "--help"]);
1572
1575
  expect(pr.kind).toBe(ParseKind.Help);
1573
1576
  expect(pr.helpPath).toContain("read");
@@ -1575,7 +1578,7 @@ test("varargs scoped help in tail", () => {
1575
1578
  });
1576
1579
 
1577
1580
  test("ctx.positional returns single slot value", async () => {
1578
- const root: CliCommand = {
1581
+ const root: CliProgram = {
1579
1582
  key: "app",
1580
1583
  description: "",
1581
1584
  commands: [
@@ -1590,7 +1593,7 @@ test("ctx.positional returns single slot value", async () => {
1590
1593
  ],
1591
1594
  };
1592
1595
  let captured: string | string[] | undefined;
1593
- cliValidateRoot(root);
1596
+ cliValidateProgram(root);
1594
1597
  await cliInvoke(root, ["x", "./file"]);
1595
1598
  expect(captured).toBe("./file");
1596
1599
  });
@@ -1598,16 +1601,18 @@ test("ctx.positional returns single slot value", async () => {
1598
1601
  test("ctx.positional returns varargs array", async () => {
1599
1602
  const root = varargsReadFixture();
1600
1603
  let captured: string | string[] | undefined;
1601
- root.commands![0]!.handler = (ctx) => {
1602
- captured = ctx.positional("files");
1603
- };
1604
- cliValidateRoot(root);
1604
+ if (isCliRouter(root)) {
1605
+ (root.commands[0] as CliLeaf).handler = (ctx) => {
1606
+ captured = ctx.positional("files");
1607
+ };
1608
+ }
1609
+ cliValidateProgram(root);
1605
1610
  await cliInvoke(root, ["read", "a.txt", "b.txt"]);
1606
1611
  expect(captured).toEqual(["a.txt", "b.txt"]);
1607
1612
  });
1608
1613
 
1609
1614
  test("ctx.positional returns undefined for absent optional slot", async () => {
1610
- const root: CliCommand = {
1615
+ const root: CliProgram = {
1611
1616
  key: "app",
1612
1617
  description: "",
1613
1618
  commands: [
@@ -1624,7 +1629,7 @@ test("ctx.positional returns undefined for absent optional slot", async () => {
1624
1629
  ],
1625
1630
  };
1626
1631
  let captured: string | string[] | undefined;
1627
- cliValidateRoot(root);
1632
+ cliValidateProgram(root);
1628
1633
  await cliInvoke(root, ["x"]);
1629
1634
  expect(captured).toBeUndefined();
1630
1635
  });
@@ -1633,11 +1638,13 @@ test("ctx.positional varargs matches ctx.args", async () => {
1633
1638
  const root = varargsReadFixture();
1634
1639
  let positional: string | string[] | undefined;
1635
1640
  let args: string[] = [];
1636
- root.commands![0]!.handler = (ctx) => {
1637
- positional = ctx.positional("files");
1638
- args = ctx.args;
1639
- };
1640
- cliValidateRoot(root);
1641
+ if (isCliRouter(root)) {
1642
+ (root.commands[0] as CliLeaf).handler = (ctx) => {
1643
+ positional = ctx.positional("files");
1644
+ args = ctx.args;
1645
+ };
1646
+ }
1647
+ cliValidateProgram(root);
1641
1648
  await cliInvoke(root, ["read", "a.txt", "b.txt"]);
1642
1649
  expect(positional).toEqual(args);
1643
1650
  });
@@ -1673,7 +1680,7 @@ test("mcpToolCallToArgv empty string varargs appends nothing", () => {
1673
1680
  // ── Skills ────────────────────────────────────────────────────────────────────
1674
1681
 
1675
1682
  test("install config on non-root node is rejected", () => {
1676
- const root: CliCommand = {
1683
+ const root = {
1677
1684
  key: "app",
1678
1685
  description: "",
1679
1686
  commands: [
@@ -1684,8 +1691,8 @@ test("install config on non-root node is rejected", () => {
1684
1691
  handler: () => {},
1685
1692
  },
1686
1693
  ],
1687
- };
1688
- expect(() => cliValidateRoot(root)).toThrow(/install is only supported on the program root/);
1694
+ } as unknown as CliProgram;
1695
+ expect(() => cliValidateProgram(root)).toThrow(/install is only supported on the program root/);
1689
1696
  });
1690
1697
 
1691
1698
  test("generateSkillBundle includes frontmatter and command catalog", () => {