argsbarg 1.5.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.cursor/plans/cliprogram_capabilities_refactor_081e1737.plan.md +224 -0
  2. package/CHANGELOG.md +24 -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 +40 -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 +21 -6
  25. package/src/help.ts +21 -9
  26. package/src/index.test.ts +71 -64
  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 +33 -24
  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 +89 -71
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { CliCommand } from "../types.ts";
3
+ import { CliProgram } from "../types.ts";
4
4
  import { installBinary } from "./binary.ts";
5
5
  import { installCompletions } from "./completions.ts";
6
6
  import { detectInstalledArtifacts } from "./detect-installed.ts";
@@ -45,12 +45,12 @@ function wantsSkill(opts: InstallOpts): boolean {
45
45
  return !!(opts.all || opts.skill);
46
46
  }
47
47
 
48
- function wantsMcp(opts: InstallOpts, root: CliCommand): boolean {
48
+ function wantsMcp(opts: InstallOpts, root: CliProgram): boolean {
49
49
  return !!(opts.all || opts.mcp) && root.mcpServer !== undefined;
50
50
  }
51
51
 
52
52
  /** Builds install actions for normal mode (--all / scoped targets). */
53
- export function buildInstallPlan(root: CliCommand, paths: InstallPaths, opts: InstallOpts): InstallAction[] {
53
+ export function buildInstallPlan(root: CliProgram, paths: InstallPaths, opts: InstallOpts): InstallAction[] {
54
54
  const actions: InstallAction[] = [];
55
55
  const dry = !!opts.dry;
56
56
 
@@ -148,7 +148,7 @@ export function buildInstallPlan(root: CliCommand, paths: InstallPaths, opts: In
148
148
  }
149
149
 
150
150
  /** Builds update actions for artifacts already installed. */
151
- export function buildUpdatePlan(root: CliCommand, paths: InstallPaths, opts: InstallOpts): InstallAction[] {
151
+ export function buildUpdatePlan(root: CliProgram, paths: InstallPaths, opts: InstallOpts): InstallAction[] {
152
152
  const detected = detectInstalledArtifacts(paths);
153
153
  const scoped: InstallOpts = {
154
154
  bin: true,
@@ -1,4 +1,4 @@
1
- import { CliCommand } from "../types.ts";
1
+ import { CliProgram } from "../types.ts";
2
2
  import { buildInstallStatus, detectInstalledArtifacts } from "./detect-installed.ts";
3
3
  import type { InstallOpts } from "./plan.ts";
4
4
  import { resolveInstallPaths } from "./paths.ts";
@@ -20,7 +20,7 @@ export function installErr(msg: string): void {
20
20
  }
21
21
 
22
22
  /** Prints install status to stdout (human or JSON). */
23
- export function printInstallStatus(root: CliCommand, opts: InstallOpts): void {
23
+ export function printInstallStatus(root: CliProgram, opts: InstallOpts): void {
24
24
  const paths = resolveInstallPaths(root, opts);
25
25
  const detected = detectInstalledArtifacts(paths);
26
26
  const status = buildInstallStatus(paths, detected);
@@ -1,6 +1,6 @@
1
1
  import { existsSync, rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { CliCommand } from "../types.ts";
3
+ import { CliProgram } from "../types.ts";
4
4
  import { uninstallBinary } from "./binary.ts";
5
5
  import { uninstallCompletions } from "./completions.ts";
6
6
  import { detectInstalledArtifacts } from "./detect-installed.ts";
@@ -20,7 +20,7 @@ function scopeAll(opts: InstallOpts): boolean {
20
20
 
21
21
  /** Builds uninstall actions from detected artifacts. */
22
22
  export function buildUninstallPlan(
23
- root: CliCommand,
23
+ root: CliProgram,
24
24
  paths: InstallPaths,
25
25
  opts: InstallOpts,
26
26
  ): UninstallAction[] {
package/src/invoke.ts CHANGED
@@ -6,7 +6,7 @@ process.exit so MCP tool calls can run handlers repeatedly.
6
6
 
7
7
  import { CliContext } from "./context.ts";
8
8
  import { parse, postParseValidate, ParseKind } from "./parse.ts";
9
- import { CliCommand } from "./types.ts";
9
+ import { type CliNode, type CliProgram, isCliRouter } from "./types.ts";
10
10
  import { format } from "node:util";
11
11
 
12
12
  /** Outcome of a non-exiting CLI invocation. */
@@ -40,7 +40,7 @@ class CliInvokeExit extends Error {
40
40
  }
41
41
 
42
42
  /** Looks up a subcommand or routing node by `key`. */
43
- function findChild(cmds: CliCommand[], name: string): CliCommand | undefined {
43
+ function findChild(cmds: CliNode[], name: string): CliNode | undefined {
44
44
  return cmds.find((c) => c.key === name);
45
45
  }
46
46
 
@@ -48,7 +48,7 @@ function findChild(cmds: CliCommand[], name: string): CliCommand | undefined {
48
48
  * Parses argv against the user root, runs the leaf handler, and returns captured output.
49
49
  * Never calls process.exit.
50
50
  */
51
- export async function cliInvoke(root: CliCommand, argv: string[]): Promise<CliInvokeResult> {
51
+ export async function cliInvoke(root: CliProgram, argv: string[]): Promise<CliInvokeResult> {
52
52
  let pr = parse(root, argv);
53
53
  pr = postParseValidate(root, pr);
54
54
 
@@ -82,9 +82,18 @@ export async function cliInvoke(root: CliCommand, argv: string[]): Promise<CliIn
82
82
  };
83
83
  }
84
84
 
85
- let current: CliCommand = root;
85
+ let current: CliProgram = root;
86
86
  for (const seg of pr.path) {
87
- const ch = findChild(current.commands ?? [], seg);
87
+ if (!isCliRouter(current)) {
88
+ return {
89
+ kind: "error",
90
+ exitCode: 1,
91
+ stdout: "",
92
+ stderr: "Internal error: missing handler for path.",
93
+ errorMsg: "Internal error: missing handler for path.",
94
+ };
95
+ }
96
+ const ch = findChild(current.commands, seg);
88
97
  if (!ch) {
89
98
  return {
90
99
  kind: "error",
package/src/mcp/server.ts CHANGED
@@ -4,7 +4,7 @@ resources, and ping. Responses are newline-delimited JSON on stdout only.
4
4
  */
5
5
 
6
6
  import { cliInvoke } from "../invoke.ts";
7
- import { CliCommand } from "../types.ts";
7
+ import { CliProgram } from "../types.ts";
8
8
  import { buildToolCallSuccess } from "./result.ts";
9
9
  import {
10
10
  allMcpResources,
@@ -41,7 +41,7 @@ function writeError(id: string | number | null | undefined, code: number, messag
41
41
  }
42
42
 
43
43
  /** Handles one NDJSON request line. */
44
- async function handleRequestLine(root: CliCommand, line: string): Promise<void> {
44
+ async function handleRequestLine(root: CliProgram, line: string): Promise<void> {
45
45
  let req: JsonRpcRequest;
46
46
  try {
47
47
  req = JSON.parse(line) as JsonRpcRequest;
@@ -217,7 +217,7 @@ async function handleRequestLine(root: CliCommand, line: string): Promise<void>
217
217
  }
218
218
 
219
219
  /** Runs the MCP NDJSON read loop on stdin until EOF. */
220
- export async function mcpServeStdioLoop(root: CliCommand): Promise<void> {
220
+ export async function mcpServeStdioLoop(root: CliProgram): Promise<void> {
221
221
  let buffer = "";
222
222
  for await (const chunk of Bun.stdin.stream()) {
223
223
  buffer += new TextDecoder().decode(chunk);
package/src/mcp/tools.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /*
2
- This module maps CliCommand leaf nodes to MCP tool definitions and converts
2
+ This module maps CliProgram leaf nodes to MCP tool definitions and converts
3
3
  flat JSON tool arguments into argv for cliInvoke.
4
4
  */
5
5
 
@@ -7,7 +7,7 @@ import { readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { collectOptionDefs } from "../parse.ts";
9
9
  import { cliSchemaJson } from "../schema.ts";
10
- import { CliCommand, CliOption, CliOptionKind, CliPositional } from "../types.ts";
10
+ import { CliProgram, CliLeaf, CliNode, CliOption, CliOptionKind, CliPositional, isCliLeaf, isCliRouter } from "../types.ts";
11
11
 
12
12
  /** Default URI for the CLI schema MCP resource. */
13
13
  export const MCP_SCHEMA_URI_DEFAULT = "argsbarg://schema";
@@ -21,7 +21,7 @@ export interface McpToolDef {
21
21
  /** Command path segments from the program root. */
22
22
  path: string[];
23
23
  /** Leaf command node. */
24
- leaf: CliCommand;
24
+ leaf: CliLeaf;
25
25
  /** JSON Schema for tools/call arguments. */
26
26
  inputSchema: Record<string, unknown>;
27
27
  }
@@ -38,7 +38,7 @@ export function sanitizeToolSegment(key: string): string {
38
38
  }
39
39
 
40
40
  /** Builds the MCP tool name for a leaf at the given path. */
41
- export function mcpToolName(root: CliCommand, path: string[]): string {
41
+ export function mcpToolName(root: CliProgram, path: string[]): string {
42
42
  if (path.length === 0) {
43
43
  return sanitizeToolSegment(root.key);
44
44
  }
@@ -71,7 +71,7 @@ function positionalProperty(p: CliPositional): Record<string, unknown> {
71
71
  }
72
72
 
73
73
  /** Builds inputSchema for a leaf command. */
74
- function buildInputSchema(root: CliCommand, path: string[], leaf: CliCommand): Record<string, unknown> {
74
+ function buildInputSchema(root: CliProgram, path: string[], leaf: CliLeaf): Record<string, unknown> {
75
75
  const properties: Record<string, unknown> = {};
76
76
  const required: string[] = [];
77
77
 
@@ -102,7 +102,7 @@ function buildInputSchema(root: CliCommand, path: string[], leaf: CliCommand): R
102
102
  }
103
103
 
104
104
  /** Resolves MCP tool description with optional override and requiresEnv suffix. */
105
- function resolveToolDescription(root: CliCommand, path: string[], leaf: CliCommand): string {
105
+ function resolveToolDescription(root: CliProgram, path: string[], leaf: CliLeaf): string {
106
106
  if (leaf.mcpTool?.description) {
107
107
  return leaf.mcpTool.description;
108
108
  }
@@ -124,7 +124,7 @@ export interface McpResourceEntry {
124
124
  }
125
125
 
126
126
  /** Returns built-in schema resource plus user mcpServer.resources. */
127
- export function allMcpResources(root: CliCommand): McpResourceEntry[] {
127
+ export function allMcpResources(root: CliProgram): McpResourceEntry[] {
128
128
  const schemaUri = resolveMcpSchemaUri(root);
129
129
  const builtIn: McpResourceEntry = {
130
130
  uri: schemaUri,
@@ -144,12 +144,12 @@ export function allMcpResources(root: CliCommand): McpResourceEntry[] {
144
144
  }
145
145
 
146
146
  /** Recursively collects MCP tool definitions from user leaf commands. */
147
- export function collectMcpTools(root: CliCommand): McpToolDef[] {
147
+ export function collectMcpTools(root: CliProgram): McpToolDef[] {
148
148
  const out: McpToolDef[] = [];
149
149
 
150
150
  /** Walks the command tree and appends leaf tools. */
151
- function walk(cmd: CliCommand, path: string[]): void {
152
- if ("handler" in cmd && cmd.handler) {
151
+ function walk(cmd: CliNode, path: string[]): void {
152
+ if (isCliLeaf(cmd)) {
153
153
  if (cmd.key === "completion" || cmd.key === "install" || cmd.key === "mcp") {
154
154
  return;
155
155
  }
@@ -165,15 +165,15 @@ export function collectMcpTools(root: CliCommand): McpToolDef[] {
165
165
  });
166
166
  return;
167
167
  }
168
- for (const ch of cmd.commands ?? []) {
168
+ for (const ch of cmd.commands) {
169
169
  walk(ch, [...path, ch.key]);
170
170
  }
171
171
  }
172
172
 
173
- if ("handler" in root && root.handler) {
173
+ if (isCliLeaf(root)) {
174
174
  walk(root, []);
175
175
  } else {
176
- for (const ch of root.commands ?? []) {
176
+ for (const ch of root.commands) {
177
177
  walk(ch, [ch.key]);
178
178
  }
179
179
  }
@@ -193,7 +193,7 @@ function resolveMcpVersionFromPackageJson(): string | undefined {
193
193
  }
194
194
 
195
195
  /** Resolves MCP server name and version for initialize. */
196
- export function resolveMcpServerInfo(root: CliCommand): { name: string; version: string } {
196
+ export function resolveMcpServerInfo(root: CliProgram): { name: string; version: string } {
197
197
  return {
198
198
  name: root.mcpServer?.name ?? root.key,
199
199
  version: root.mcpServer?.version ?? resolveMcpVersionFromPackageJson() ?? "0.0.0",
@@ -201,13 +201,13 @@ export function resolveMcpServerInfo(root: CliCommand): { name: string; version:
201
201
  }
202
202
 
203
203
  /** Resolves the schema resource URI for this app. */
204
- export function resolveMcpSchemaUri(root: CliCommand): string {
204
+ export function resolveMcpSchemaUri(root: CliProgram): string {
205
205
  return root.mcpServer?.schemaResourceUri ?? MCP_SCHEMA_URI_DEFAULT;
206
206
  }
207
207
 
208
208
  /** Converts flat MCP tool arguments to argv for cliInvoke. */
209
209
  export function mcpToolCallToArgv(
210
- root: CliCommand,
210
+ root: CliProgram,
211
211
  tool: McpToolDef,
212
212
  args: Record<string, unknown>,
213
213
  ): string[] | { error: string } {
package/src/mcp.ts CHANGED
@@ -4,13 +4,13 @@ This module starts the ArgsBarg MCP stdio server for opt-in program roots.
4
4
 
5
5
  import { mcpServeStdioLoop } from "./mcp/server.ts";
6
6
  import { bootstrapMcpEnv } from "./mcp/env.ts";
7
- import { CliCommand } from "./types.ts";
7
+ import { CliProgram } from "./types.ts";
8
8
 
9
9
  /**
10
10
  * Runs the MCP JSON-RPC server on stdin/stdout until stdin closes, then exits.
11
11
  * Caller must ensure `root.mcpServer` is set.
12
12
  */
13
- export async function cliMcpServeStdio(root: CliCommand): Promise<never> {
13
+ export async function cliMcpServeStdio(root: CliProgram): Promise<never> {
14
14
  try {
15
15
  if (root.mcpServer) {
16
16
  bootstrapMcpEnv(root.mcpServer);
package/src/parse.ts CHANGED
@@ -9,10 +9,13 @@ across every entry path.
9
9
 
10
10
  import { CliContext } from "./context.ts";
11
11
  import {
12
- CliCommand,
12
+ type CliLeaf,
13
+ CliNode,
13
14
  CliFallbackMode,
14
15
  CliOption,
15
16
  CliOptionKind,
17
+ isCliLeaf,
18
+ isCliRouter,
16
19
  } from "./types.ts";
17
20
  import { fullStringIsDouble } from "./utils.ts";
18
21
 
@@ -69,7 +72,7 @@ function isSchemaTok(tok: string): boolean {
69
72
  }
70
73
 
71
74
  /** Looks up a subcommand or routing node by `key`. */
72
- function findChild(cmds: CliCommand[], name: string): CliCommand | undefined {
75
+ function findChild(cmds: CliNode[], name: string): CliNode | undefined {
73
76
  return cmds.find((c) => c.key === name);
74
77
  }
75
78
 
@@ -217,15 +220,16 @@ function consumeOptions(
217
220
  // ── Positional Collection ─────────────────────────────────────────────────────
218
221
 
219
222
  /** Merges option defs from the program root along the routed command path. */
220
- export function collectOptionDefs(root: CliCommand, path: string[]): CliOption[] {
223
+ export function collectOptionDefs(root: CliNode, path: string[]): CliOption[] {
221
224
  let defs = [...(root.options ?? [])];
222
- let cmds = root.commands ?? [];
225
+ let node: CliNode = root;
223
226
 
224
227
  for (const seg of path) {
225
- const ch = findChild(cmds, seg);
228
+ if (!isCliRouter(node)) break;
229
+ const ch = findChild(node.commands, seg);
226
230
  if (!ch) break;
227
231
  defs.push(...(ch.options ?? []));
228
- cmds = ch.commands ?? [];
232
+ node = ch;
229
233
  }
230
234
 
231
235
  return defs;
@@ -233,7 +237,7 @@ export function collectOptionDefs(root: CliCommand, path: string[]): CliOption[]
233
237
 
234
238
  /** Fills `args` for a leaf from `startIdx` according to `node.positionals`. */
235
239
  function finishLeaf(
236
- node: CliCommand,
240
+ node: CliLeaf,
237
241
  startIdx: number,
238
242
  argv: string[],
239
243
  path: string[],
@@ -384,14 +388,16 @@ function schemaResult(): ParseResult {
384
388
  /**
385
389
  * Parses `argv` against the program root, routing into subcommands and filling `opts` / `args`.
386
390
  */
387
- export function parse(root: CliCommand, argv: string[]): ParseResult {
391
+ export function parse(root: CliNode, argv: string[]): ParseResult {
388
392
  let i = 0;
389
393
  const path: string[] = [];
390
394
  const opts: Record<string, string> = {};
391
395
 
392
396
  const rootLenient =
397
+ isCliRouter(root) &&
393
398
  root.fallbackCommand !== undefined &&
394
- ((root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOrUnknown || (root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.UnknownOnly);
399
+ ((root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOrUnknown ||
400
+ (root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.UnknownOnly);
395
401
 
396
402
  // Consume root-level options first
397
403
  const rootRep = consumeOptions(root.options ?? [], rootLenient, argv, i, opts);
@@ -420,16 +426,20 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
420
426
 
421
427
  // Determine which subcommand to route to
422
428
  let cmdName: string;
423
- let node: CliCommand | undefined;
429
+ let node: CliNode | undefined;
424
430
 
425
- if (root.handler) {
426
- return finishLeaf(root as CliCommand, i, argv, path, opts, root.options ?? [], forcePositionals);
431
+ if (isCliLeaf(root)) {
432
+ return finishLeaf(root, i, argv, path, opts, root.options ?? [], forcePositionals);
427
433
  }
428
434
 
429
435
  if (i >= argv.length) {
430
- if (root.fallbackCommand !== undefined && ((root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOnly || (root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOrUnknown)) {
436
+ if (
437
+ root.fallbackCommand !== undefined &&
438
+ ((root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOnly ||
439
+ (root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOrUnknown)
440
+ ) {
431
441
  cmdName = root.fallbackCommand;
432
- node = findChild(root.commands ?? [], cmdName);
442
+ node = findChild(root.commands, cmdName);
433
443
  if (!node) {
434
444
  return { kind: ParseKind.Error, path: [], opts: {}, args: [], helpExplicit: false, helpPath: [], errorMsg: `Unknown command: ${cmdName}`, errorHelpPath: path };
435
445
  }
@@ -438,7 +448,7 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
438
448
  }
439
449
  } else {
440
450
  const peek = argv[i];
441
- const childPick = !forcePositionals ? findChild(root.commands ?? [], peek) : undefined;
451
+ const childPick = !forcePositionals ? findChild(root.commands, peek) : undefined;
442
452
 
443
453
  if (childPick !== undefined) {
444
454
  cmdName = peek;
@@ -452,14 +462,14 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
452
462
 
453
463
  if (canRouteUnknown) {
454
464
  cmdName = root.fallbackCommand!;
455
- node = findChild(root.commands ?? [], cmdName);
465
+ node = findChild(root.commands, cmdName);
456
466
  if (!node) {
457
467
  return { kind: ParseKind.Error, path: [], opts: {}, args: [], helpExplicit: false, helpPath: [], errorMsg: `Unknown command: ${cmdName}`, errorHelpPath: path };
458
468
  }
459
469
  } else {
460
470
  cmdName = peek;
461
471
  if (!forcePositionals) i += 1;
462
- node = findChild(root.commands ?? [], cmdName);
472
+ node = findChild(root.commands, cmdName);
463
473
  if (!node) {
464
474
  return {
465
475
  kind: ParseKind.Error,
@@ -506,14 +516,14 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
506
516
  }
507
517
 
508
518
  if (i >= argv.length) {
509
- if ((current.commands ?? []).length > 0) {
519
+ if (isCliRouter(current) && current.commands.length > 0) {
510
520
  const fb = current.fallbackCommand;
511
521
  const fm = current.fallbackMode ?? CliFallbackMode.MissingOnly;
512
522
  if (
513
523
  fb !== undefined &&
514
524
  (fm === CliFallbackMode.MissingOnly || fm === CliFallbackMode.MissingOrUnknown)
515
525
  ) {
516
- const fbNode = findChild(current.commands ?? [], fb);
526
+ const fbNode = findChild(current.commands, fb);
517
527
  if (fbNode) {
518
528
  path.push(fb);
519
529
  current = fbNode;
@@ -522,6 +532,9 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
522
532
  }
523
533
  return helpResult(path, false);
524
534
  }
535
+ if (!isCliLeaf(current)) {
536
+ return helpResult(path, false);
537
+ }
525
538
  return finishLeaf(current, i, argv, path, opts, collectOptionDefs(root, path), forcePositionals);
526
539
  }
527
540
 
@@ -539,8 +552,8 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
539
552
  };
540
553
  }
541
554
 
542
- if (!forcePositionals) {
543
- const childOpt = findChild(current.commands ?? [], tok);
555
+ if (!forcePositionals && isCliRouter(current)) {
556
+ const childOpt = findChild(current.commands, tok);
544
557
  if (childOpt !== undefined) {
545
558
  i += 1;
546
559
  path.push(tok);
@@ -549,7 +562,7 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
549
562
  }
550
563
  }
551
564
 
552
- if ((current.commands ?? []).length > 0) {
565
+ if (isCliRouter(current) && current.commands.length > 0) {
553
566
  const fb = current.fallbackCommand;
554
567
  const fm = current.fallbackMode ?? CliFallbackMode.MissingOnly;
555
568
  const canRouteUnknown =
@@ -557,7 +570,7 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
557
570
  (fm === CliFallbackMode.MissingOrUnknown || fm === CliFallbackMode.UnknownOnly);
558
571
 
559
572
  if (canRouteUnknown) {
560
- const fbNode = findChild(current.commands ?? [], fb!);
573
+ const fbNode = findChild(current.commands, fb!);
561
574
  if (fbNode) {
562
575
  path.push(fb!);
563
576
  current = fbNode;
@@ -577,6 +590,9 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
577
590
  };
578
591
  }
579
592
 
593
+ if (!isCliLeaf(current)) {
594
+ return helpResult(path, false);
595
+ }
580
596
  return finishLeaf(current, i, argv, path, opts, collectOptionDefs(root, path), forcePositionals);
581
597
  }
582
598
  }
@@ -586,14 +602,26 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
586
602
  /**
587
603
  * Validates option keys and numeric values for an Ok parse, merging in-scope options along `pr.path`.
588
604
  */
589
- export function postParseValidate(root: CliCommand, pr: ParseResult): ParseResult {
605
+ export function postParseValidate(root: CliNode, pr: ParseResult): ParseResult {
590
606
  if (pr.kind !== ParseKind.Ok) return pr;
591
607
 
592
608
  let defs = [...(root.options ?? [])];
593
- let cmds = root.commands ?? [];
609
+ let node: CliNode = root;
594
610
 
595
611
  for (const seg of pr.path) {
596
- const ch = findChild(cmds, seg);
612
+ if (!isCliRouter(node)) {
613
+ return {
614
+ kind: ParseKind.Error,
615
+ path: pr.path,
616
+ opts: {},
617
+ args: [],
618
+ helpExplicit: false,
619
+ helpPath: [],
620
+ errorMsg: "Internal path error",
621
+ errorHelpPath: pr.path,
622
+ };
623
+ }
624
+ const ch = findChild(node.commands, seg);
597
625
  if (!ch) {
598
626
  return {
599
627
  kind: ParseKind.Error,
@@ -607,7 +635,7 @@ export function postParseValidate(root: CliCommand, pr: ParseResult): ParseResul
607
635
  };
608
636
  }
609
637
  defs.push(...(ch.options ?? []));
610
- cmds = ch.commands ?? [];
638
+ node = ch;
611
639
  }
612
640
 
613
641
  for (const d of defs) {
package/src/runtime.ts CHANGED
@@ -2,26 +2,25 @@
2
2
  This module runs parsed commands, help, errors, completion, and leaf handlers.
3
3
  */
4
4
 
5
+ import { resolveCapabilities } from "./capabilities.ts";
5
6
  import { builtinInterceptRoot, dispatchBuiltin } from "./builtins/dispatch.ts";
6
7
  import { cliPresentationRoot } from "./builtins/presentation.ts";
8
+ import type { CliRouter } from "./types.ts";
9
+ import { type CliNode, type CliProgram, isCliLeaf, isCliRouter } from "./types.ts";
7
10
  import { isCompiledExecutable } from "./install/compiled.ts";
8
11
  import { CliContext } from "./context.ts";
9
12
  import { cliHelpRender } from "./help.ts";
10
13
  import { parse, postParseValidate, ParseKind } from "./parse.ts";
11
14
  import { cliSchemaJson } from "./schema.ts";
12
- import { CliCommand } from "./types.ts";
13
- import { cliValidateRoot } from "./validate.ts";
15
+ import { cliValidateProgram } from "./validate.ts";
14
16
 
15
- function cliRootMergedWithBuiltins(root: CliCommand): CliCommand {
16
- if (root.handler) {
17
- return root;
18
- }
19
- return cliPresentationRoot(root);
17
+ function cliRootMergedWithBuiltins(program: CliProgram): CliRouter {
18
+ return cliPresentationRoot(program);
20
19
  }
21
20
 
22
- export async function cliRun(root: CliCommand, argv: string[] = process.argv.slice(2)): Promise<never> {
21
+ export async function cliRun(program: CliProgram, argv: string[] = process.argv.slice(2)): Promise<never> {
23
22
  try {
24
- cliValidateRoot(root);
23
+ cliValidateProgram(program);
25
24
  } catch (err) {
26
25
  if (err instanceof Error) {
27
26
  process.stderr.write(err.message + "\n");
@@ -31,7 +30,9 @@ export async function cliRun(root: CliCommand, argv: string[] = process.argv.sli
31
30
  process.exit(1);
32
31
  }
33
32
 
34
- if (argv.length >= 1 && argv[0] === "mcp" && !root.mcpServer) {
33
+ const caps = resolveCapabilities(program);
34
+
35
+ if (argv.length >= 1 && argv[0] === "mcp" && !caps.mcp) {
35
36
  process.stderr.write("MCP is not enabled. Set mcpServer on the program root.\n");
36
37
  process.exit(1);
37
38
  }
@@ -41,31 +42,35 @@ export async function cliRun(root: CliCommand, argv: string[] = process.argv.sli
41
42
  process.exit(1);
42
43
  }
43
44
 
44
- let parseRoot: CliCommand;
45
+ let parseRoot: CliNode;
46
+ let completionParseRoot: CliRouter = cliRootMergedWithBuiltins(program);
45
47
  let isLeafCompletionIntercept = false;
46
48
 
47
- if (root.handler) {
48
- const intercept = builtinInterceptRoot(root, argv);
49
- if (intercept.isLeafCompletionIntercept || intercept.parseRoot !== root) {
49
+ if (isCliLeaf(program)) {
50
+ const intercept = builtinInterceptRoot(program, argv);
51
+ if (intercept.isLeafCompletionIntercept || intercept.parseRoot !== program) {
50
52
  parseRoot = intercept.parseRoot;
53
+ completionParseRoot = isCliRouter(intercept.parseRoot)
54
+ ? intercept.parseRoot
55
+ : cliRootMergedWithBuiltins(program);
51
56
  isLeafCompletionIntercept = intercept.isLeafCompletionIntercept;
52
57
  } else {
53
- parseRoot = root;
58
+ parseRoot = program;
54
59
  }
55
60
  } else {
56
- parseRoot = cliRootMergedWithBuiltins(root);
61
+ parseRoot = cliRootMergedWithBuiltins(program);
57
62
  }
58
63
 
59
64
  let pr = parse(parseRoot, argv);
60
65
  pr = postParseValidate(parseRoot, pr);
61
66
 
62
67
  if (pr.kind === ParseKind.Help) {
63
- process.stdout.write(cliHelpRender(cliPresentationRoot(root), pr.helpPath, false));
68
+ process.stdout.write(cliHelpRender(cliPresentationRoot(program), pr.helpPath, false));
64
69
  process.exit(pr.helpExplicit ? 0 : 1);
65
70
  }
66
71
 
67
72
  if (pr.kind === ParseKind.Schema) {
68
- process.stdout.write(cliSchemaJson(root));
73
+ process.stdout.write(cliSchemaJson(program));
69
74
  process.exit(0);
70
75
  }
71
76
 
@@ -73,17 +78,21 @@ export async function cliRun(root: CliCommand, argv: string[] = process.argv.sli
73
78
  const color = process.stderr.isTTY;
74
79
  const msg = color ? `\u001B[31m${pr.errorMsg}\u001B[0m` : pr.errorMsg;
75
80
  process.stderr.write(msg + "\n");
76
- process.stderr.write(cliHelpRender(cliPresentationRoot(root), pr.errorHelpPath, true));
81
+ process.stderr.write(cliHelpRender(cliPresentationRoot(program), pr.errorHelpPath, true));
77
82
  process.exit(1);
78
83
  }
79
84
 
80
85
  if (pr.kind === ParseKind.Ok) {
81
- await dispatchBuiltin(root, pr, { isLeafCompletionIntercept, parseRoot });
86
+ await dispatchBuiltin(program, pr, { isLeafCompletionIntercept, parseRoot: completionParseRoot });
82
87
  }
83
88
 
84
- let current = parseRoot;
89
+ let current: CliNode = parseRoot;
85
90
  for (const seg of pr.path) {
86
- const ch = (current.commands ?? []).find((candidate: CliCommand) => candidate.key === seg);
91
+ if (!isCliRouter(current)) {
92
+ process.stderr.write("Internal error: missing handler for path.\n");
93
+ process.exit(1);
94
+ }
95
+ const ch = current.commands.find((candidate) => candidate.key === seg);
87
96
  if (!ch) {
88
97
  process.stderr.write("Internal error: missing handler for path.\n");
89
98
  process.exit(1);
@@ -91,12 +100,12 @@ export async function cliRun(root: CliCommand, argv: string[] = process.argv.sli
91
100
  current = ch;
92
101
  }
93
102
 
94
- if (!current.handler) {
103
+ if (!isCliLeaf(current) || !current.handler) {
95
104
  process.stderr.write("Internal error: missing handler for path.\n");
96
105
  process.exit(1);
97
106
  }
98
107
 
99
- const ctx = new CliContext(parseRoot.key, pr.path, pr.args, pr.opts, parseRoot, "cli");
108
+ const ctx = new CliContext(program.key, pr.path, pr.args, pr.opts, program, "cli");
100
109
  try {
101
110
  await Promise.resolve(current.handler(ctx));
102
111
  process.exit(0);