argsbarg 1.4.2 → 1.5.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 (43) hide show
  1. package/.private/scratch.md +2 -1
  2. package/CHANGELOG.md +29 -1
  3. package/README.md +22 -7
  4. package/docs/ai-skills.md +47 -0
  5. package/docs/install.md +84 -0
  6. package/docs/mcp.md +7 -5
  7. package/index.d.ts +11 -9
  8. package/package.json +1 -1
  9. package/src/builtins/builtins.test.ts +101 -0
  10. package/src/builtins/completion-bash.ts +240 -0
  11. package/src/builtins/completion-fish.ts +73 -0
  12. package/src/builtins/completion-group.ts +50 -0
  13. package/src/builtins/completion-zsh.ts +244 -0
  14. package/src/builtins/dispatch.ts +123 -0
  15. package/src/builtins/export.ts +46 -0
  16. package/src/builtins/index.ts +10 -0
  17. package/src/builtins/install.ts +99 -0
  18. package/src/builtins/mcp.ts +13 -0
  19. package/src/builtins/presentation.ts +39 -0
  20. package/src/builtins/scopes.ts +45 -0
  21. package/src/builtins/shell-helpers.ts +24 -0
  22. package/src/completion.ts +10 -652
  23. package/src/index.test.ts +135 -4
  24. package/src/index.ts +1 -0
  25. package/src/install/binary.ts +82 -0
  26. package/src/install/compiled.ts +15 -0
  27. package/src/install/completions.ts +52 -0
  28. package/src/install/detect-installed.ts +67 -0
  29. package/src/install/index.ts +196 -0
  30. package/src/install/install.test.ts +124 -0
  31. package/src/install/mcp-config.ts +70 -0
  32. package/src/install/paths.ts +69 -0
  33. package/src/install/plan.ts +183 -0
  34. package/src/install/shell.ts +56 -0
  35. package/src/install/status.ts +63 -0
  36. package/src/install/uninstall.ts +111 -0
  37. package/src/mcp/tools.ts +1 -1
  38. package/src/runtime.ts +23 -66
  39. package/src/schema.ts +7 -49
  40. package/src/skill/generate.ts +183 -0
  41. package/src/skill/install.ts +47 -0
  42. package/src/types.ts +12 -0
  43. package/src/validate.ts +14 -20
package/src/runtime.ts CHANGED
@@ -1,24 +1,17 @@
1
1
  /*
2
2
  This module runs parsed commands, help, errors, completion, and leaf handlers.
3
- It owns the top-level control flow after parsing, including validation failures,
4
- shell completion dispatch, and leaf handler invocation.
5
-
6
- It keeps execution flow out of the public barrel so the exported API stays small and
7
- the runtime responsibilities remain easy to reason about.
8
3
  */
9
4
 
10
- import { cliBuiltinCompletionGroup, cliBuiltinMcpCommand, cliPresentationRoot, completionBashScript, completionZshScript } from "./completion.ts";
5
+ import { builtinInterceptRoot, dispatchBuiltin } from "./builtins/dispatch.ts";
6
+ import { cliPresentationRoot } from "./builtins/presentation.ts";
7
+ import { isCompiledExecutable } from "./install/compiled.ts";
11
8
  import { CliContext } from "./context.ts";
12
9
  import { cliHelpRender } from "./help.ts";
13
- import { cliMcpServeStdio } from "./mcp.ts";
14
10
  import { parse, postParseValidate, ParseKind } from "./parse.ts";
15
11
  import { cliSchemaJson } from "./schema.ts";
16
12
  import { CliCommand } from "./types.ts";
17
13
  import { cliValidateRoot } from "./validate.ts";
18
14
 
19
- /**
20
- * Merges the caller's program root with the reserved `completion` subtree.
21
- */
22
15
  function cliRootMergedWithBuiltins(root: CliCommand): CliCommand {
23
16
  if (root.handler) {
24
17
  return root;
@@ -26,12 +19,6 @@ function cliRootMergedWithBuiltins(root: CliCommand): CliCommand {
26
19
  return cliPresentationRoot(root);
27
20
  }
28
21
 
29
- /**
30
- * Validates the schema, parses argv, prints help or errors, runs completion or the leaf handler, then exits.
31
- *
32
- * @param root The root CliCommand.
33
- * @param argv Override the default argv (process.argv.slice(2)).
34
- */
35
22
  export async function cliRun(root: CliCommand, argv: string[] = process.argv.slice(2)): Promise<never> {
36
23
  try {
37
24
  cliValidateRoot(root);
@@ -44,29 +31,27 @@ export async function cliRun(root: CliCommand, argv: string[] = process.argv.sli
44
31
  process.exit(1);
45
32
  }
46
33
 
47
- let parseRoot = root;
48
- let isLeafCompletionIntercept = false;
34
+ if (argv.length >= 1 && argv[0] === "mcp" && !root.mcpServer) {
35
+ process.stderr.write("MCP is not enabled. Set mcpServer on the program root.\n");
36
+ process.exit(1);
37
+ }
49
38
 
50
- if (root.handler && argv.length >= 1 && argv[0] === "mcp" && !root.mcpServer) {
51
- process.stderr.write("Unknown command: mcp\n");
39
+ if (argv.length >= 1 && argv[0] === "install" && !isCompiledExecutable()) {
40
+ process.stderr.write("install is only available in compiled binaries (bun build --compile).\n");
52
41
  process.exit(1);
53
42
  }
54
43
 
55
- // Intercept completion for Leaf roots (since they can't natively have a completion subcommand)
56
- // but wrap them in a dummy router so that the parser handles `-h` and errors correctly.
57
- if (root.handler && argv.length >= 1 && argv[0] === "completion") {
58
- isLeafCompletionIntercept = true;
59
- parseRoot = {
60
- key: root.key,
61
- description: root.description,
62
- commands: [cliBuiltinCompletionGroup(root.key)],
63
- } as any;
64
- } else if (root.handler && argv.length >= 1 && argv[0] === "mcp" && root.mcpServer) {
65
- parseRoot = {
66
- key: root.key,
67
- description: root.description,
68
- commands: [cliBuiltinMcpCommand()],
69
- } as CliCommand;
44
+ let parseRoot: CliCommand;
45
+ let isLeafCompletionIntercept = false;
46
+
47
+ if (root.handler) {
48
+ const intercept = builtinInterceptRoot(root, argv);
49
+ if (intercept.isLeafCompletionIntercept || intercept.parseRoot !== root) {
50
+ parseRoot = intercept.parseRoot;
51
+ isLeafCompletionIntercept = intercept.isLeafCompletionIntercept;
52
+ } else {
53
+ parseRoot = root;
54
+ }
70
55
  } else {
71
56
  parseRoot = cliRootMergedWithBuiltins(root);
72
57
  }
@@ -92,33 +77,8 @@ export async function cliRun(root: CliCommand, argv: string[] = process.argv.sli
92
77
  process.exit(1);
93
78
  }
94
79
 
95
- // Leaf roots have an empty path; that's normal.
96
-
97
- if (pr.path[0] === "completion") {
98
- // If we intercepted a leaf, we MUST pass the original `root` to generate completions
99
- // because `parseRoot` is just a dummy router!
100
- const schemaForCompletion = isLeafCompletionIntercept ? root : parseRoot;
101
-
102
- if (pr.path[1] === "bash") {
103
- process.stdout.write(completionBashScript(schemaForCompletion));
104
- process.exit(0);
105
- }
106
- if (pr.path[1] === "zsh") {
107
- process.stdout.write(completionZshScript(schemaForCompletion));
108
- process.exit(0);
109
- }
110
- }
111
-
112
- if (pr.path[0] === "mcp") {
113
- if (!root.mcpServer) {
114
- process.stderr.write("Internal error: mcp not enabled.\n");
115
- process.exit(1);
116
- }
117
- if (pr.path.length !== 1) {
118
- process.stderr.write("Unknown subcommand: mcp " + pr.path.slice(1).join(" ") + "\n");
119
- process.exit(1);
120
- }
121
- await cliMcpServeStdio(root);
80
+ if (pr.kind === ParseKind.Ok) {
81
+ await dispatchBuiltin(root, pr, { isLeafCompletionIntercept, parseRoot });
122
82
  }
123
83
 
124
84
  let current = parseRoot;
@@ -148,13 +108,10 @@ export async function cliRun(root: CliCommand, argv: string[] = process.argv.sli
148
108
  }
149
109
  }
150
110
 
151
- /**
152
- * Prints a red error line and contextual help on stderr, then exits with status 1.
153
- */
154
111
  export function cliErrWithHelp(ctx: CliContext, msg: string): never {
155
112
  const color = process.stderr.isTTY;
156
113
  const line = color ? `\u001B[31m${msg}\u001B[0m` : msg;
157
114
  process.stderr.write(line + "\n");
158
115
  process.stderr.write(cliHelpRender(cliPresentationRoot(ctx.schema), ctx.commandPath, true));
159
116
  process.exit(1);
160
- }
117
+ }
package/src/schema.ts CHANGED
@@ -1,55 +1,12 @@
1
1
  /*
2
2
  This module serializes the CLI schema tree to JSON for machine-readable introspection.
3
- It strips handlers and runtime-only nodes so agents can discover commands, options,
4
- and positionals in one shot.
5
-
6
- It keeps schema export aligned with the declarative CliCommand model that drives help
7
- and completion.
8
3
  */
9
4
 
10
- import {
11
- CliCommand,
12
- CliFallbackMode,
13
- CliOption,
14
- CliPositional,
15
- } from "./types.ts";
16
- import { cliBuiltinCompletionGroup } from "./completion.ts";
17
-
18
- /** JSON-safe command node (no handlers). */
19
- export interface CliSchemaExport {
20
- /** Program or command key. */
21
- key: string;
22
- /** Short description shown in help. */
23
- description: string;
24
- /** Additional notes shown in help (supports {app} placeholder). */
25
- notes?: string;
26
- /** Global or command-level flags/options. */
27
- options?: CliOption[];
28
- /** Default top-level subcommand (program root only). */
29
- fallbackCommand?: string;
30
- /** How fallbackCommand is applied (program root only). */
31
- fallbackMode?: CliFallbackMode;
32
- /** Nested subcommands (routing nodes only). */
33
- commands?: CliSchemaExport[];
34
- /** Positional argument definitions (leaf nodes only). */
35
- positionals?: CliPositional[];
36
- }
5
+ import { CliCommand } from "./types.ts";
6
+ import { exportPresentationBuiltins, type CliSchemaExport } from "./builtins/export.ts";
37
7
 
38
- /** JSON-safe export of the reserved `completion` subtree (no handler recursion). */
39
- function exportBuiltinCompletionGroup(appName: string): CliSchemaExport {
40
- const group = cliBuiltinCompletionGroup(appName);
41
- return {
42
- key: group.key,
43
- description: group.description,
44
- commands: (group.commands ?? []).map((ch) => ({
45
- key: ch.key,
46
- description: ch.description,
47
- ...((ch.notes ?? "").length > 0 ? { notes: ch.notes } : {}),
48
- })),
49
- };
50
- }
8
+ const RESERVED = new Set(["completion", "install", "mcp"]);
51
9
 
52
- /** Converts one `CliCommand` node into a JSON-safe export (handlers omitted). */
53
10
  function exportCommand(cmd: CliCommand): CliSchemaExport {
54
11
  const out: CliSchemaExport = {
55
12
  key: cmd.key,
@@ -68,7 +25,7 @@ function exportCommand(cmd: CliCommand): CliSchemaExport {
68
25
  if ((cmd.positionals ?? []).length > 0) {
69
26
  out.positionals = cmd.positionals;
70
27
  }
71
- out.commands = [exportBuiltinCompletionGroup(cmd.key)];
28
+ out.commands = exportPresentationBuiltins(cmd);
72
29
  return out;
73
30
  }
74
31
 
@@ -79,7 +36,7 @@ function exportCommand(cmd: CliCommand): CliSchemaExport {
79
36
  out.fallbackMode = cmd.fallbackMode;
80
37
  }
81
38
 
82
- const children = (cmd.commands ?? []).filter((ch) => ch.key !== "completion");
39
+ const children = (cmd.commands ?? []).filter((ch) => !RESERVED.has(ch.key));
83
40
  if (children.length > 0) {
84
41
  out.commands = children.map(exportCommand);
85
42
  }
@@ -87,7 +44,8 @@ function exportCommand(cmd: CliCommand): CliSchemaExport {
87
44
  return out;
88
45
  }
89
46
 
90
- /** Returns pretty-printed JSON for the full program schema (trailing newline). */
91
47
  export function cliSchemaJson(root: CliCommand): string {
92
48
  return JSON.stringify(exportCommand(root), null, 2) + "\n";
93
49
  }
50
+
51
+ export type { CliSchemaExport };
@@ -0,0 +1,183 @@
1
+ /*
2
+ This module generates Agent Skills content (SKILL.md + reference.md) from a CLI schema.
3
+ */
4
+
5
+ import { collectOptionDefs } from "../parse.ts";
6
+ import { cliSchemaJson } from "../schema.ts";
7
+ import { collectMcpTools, sanitizeToolSegment } from "../mcp/tools.ts";
8
+ import { CliCommand, CliOptionKind } from "../types.ts";
9
+
10
+ export type SkillTarget = "cursor" | "claude";
11
+
12
+ export interface SkillBundle {
13
+ dirName: string;
14
+ skillMd: string;
15
+ referenceMd: string;
16
+ }
17
+
18
+ /** Truncates text to maxLen with ellipsis. */
19
+ function truncate(text: string, maxLen: number): string {
20
+ if (text.length <= maxLen) return text;
21
+ return text.slice(0, maxLen - 1) + "…";
22
+ }
23
+
24
+ /** Builds third-person skill description for YAML frontmatter. */
25
+ function skillDescription(root: CliCommand): string {
26
+ const tools = collectMcpTools(root);
27
+ const paths = tools.map((t) => (t.path.length > 0 ? t.path.join(" ") : root.key));
28
+ const sample = paths.slice(0, 5).join(", ");
29
+ const more = paths.length > 5 ? `, and ${paths.length - 5} more` : "";
30
+ const desc = `Operates the ${root.key} CLI (${sample}${more}). Use when the user mentions ${root.key}${paths.length > 0 ? `, ${paths.slice(0, 3).join(", ")}` : ""}, or related tasks.`;
31
+ return truncate(desc, 1024);
32
+ }
33
+
34
+ /** Formats one command line for the catalog section. */
35
+ function formatCommandEntry(root: CliCommand, tool: ReturnType<typeof collectMcpTools>[number]): string {
36
+ const cliPath = tool.path.length > 0 ? `${root.key} ${tool.path.join(" ")}` : root.key;
37
+ let line = `- **\`${cliPath}\`** — ${tool.description}`;
38
+ const opts = collectOptionDefs(root, tool.path);
39
+ const flags = opts.filter((o) => o.kind === CliOptionKind.Presence).map((o) => `--${o.name}`);
40
+ if (flags.length > 0) {
41
+ line += ` (flags: ${flags.join(", ")})`;
42
+ }
43
+ const enums = opts.filter((o) => o.kind === CliOptionKind.Enum && o.choices?.length);
44
+ for (const e of enums) {
45
+ line += ` (\`--${e.name}\`: ${e.choices!.join(" | ")})`;
46
+ }
47
+ const varargs = (tool.leaf.positionals ?? []).filter((p) => (p.argMax ?? 1) === 0);
48
+ if (varargs.length > 0) {
49
+ line += ` (varargs: ${varargs.map((p) => p.name).join(", ")})`;
50
+ }
51
+ return line;
52
+ }
53
+
54
+ /** Builds SKILL.md body for the given target. */
55
+ function buildSkillMd(root: CliCommand, target: SkillTarget, dirName: string): string {
56
+ const name = sanitizeToolSegment(root.key);
57
+ const description = skillDescription(root);
58
+ const tools = collectMcpTools(root);
59
+
60
+ const lines: string[] = [
61
+ "---",
62
+ `name: ${name}`,
63
+ `description: ${description}`,
64
+ "---",
65
+ "",
66
+ `# ${root.key}`,
67
+ "",
68
+ root.description,
69
+ "",
70
+ "## When to use",
71
+ "",
72
+ `Use this skill when working with **${root.key}** — shell commands, automation, or agent tool calls for this application.`,
73
+ "",
74
+ "## Execution",
75
+ "",
76
+ ];
77
+
78
+ if (root.mcpServer !== undefined) {
79
+ lines.push(
80
+ "**Prefer MCP** when a host has the server connected:",
81
+ "",
82
+ "```bash",
83
+ `${root.key} mcp`,
84
+ "```",
85
+ "",
86
+ "Example Cursor `mcp.json` entry:",
87
+ "",
88
+ "```json",
89
+ JSON.stringify(
90
+ {
91
+ mcpServers: {
92
+ [root.mcpServer.name ?? root.key]: {
93
+ command: root.key,
94
+ args: ["mcp"],
95
+ },
96
+ },
97
+ },
98
+ null,
99
+ 2,
100
+ ),
101
+ "```",
102
+ "",
103
+ "When MCP tools are available, use `tools/call` with flat JSON arguments. Read the schema resource for full shapes.",
104
+ "",
105
+ "Otherwise invoke via shell:",
106
+ "",
107
+ );
108
+ } else {
109
+ lines.push("Invoke via shell:", "");
110
+ }
111
+
112
+ lines.push("```bash", `${root.key} <subcommand> [options] [args]`, "```", "", "## Commands", "");
113
+
114
+ if (tools.length === 0) {
115
+ lines.push("(No leaf commands in schema.)", "");
116
+ } else {
117
+ for (const tool of tools) {
118
+ lines.push(formatCommandEntry(root, tool));
119
+ }
120
+ lines.push("");
121
+ }
122
+
123
+ lines.push(
124
+ "## Pitfalls",
125
+ "",
126
+ "- Use `--` before tokens that look like flags when they are positional arguments.",
127
+ "- Under MCP (`ctx.invocation === \"mcp\"`), child processes must not inherit stdout — use piped stdout.",
128
+ "- Required environment variables are listed per command in descriptions (`requires env`).",
129
+ "",
130
+ "## Reference",
131
+ "",
132
+ "See `reference.md` in this skill directory for the full `--schema` JSON export.",
133
+ "",
134
+ );
135
+
136
+ if (target === "cursor") {
137
+ lines.push(
138
+ "## Cursor install location",
139
+ "",
140
+ `- Project: \`.cursor/skills/${dirName}/\``,
141
+ `- Global: \`~/.cursor/skills/${dirName}/\``,
142
+ "",
143
+ "Do not install under `~/.cursor/skills-cursor/` (reserved for Cursor built-ins).",
144
+ "",
145
+ );
146
+ } else {
147
+ lines.push(
148
+ "## Claude Code",
149
+ "",
150
+ `- Invoke with \`/${dirName}\` or let Claude auto-match from the description.`,
151
+ `- Project skills: \`.claude/skills/${dirName}/\``,
152
+ `- Global skills: \`~/.claude/skills/${dirName}/\``,
153
+ `- Bundled files in this directory are available via \`\${CLAUDE_SKILL_DIR}\` when the skill runs.`,
154
+ "",
155
+ );
156
+ }
157
+
158
+ return lines.join("\n");
159
+ }
160
+
161
+ /** Builds reference.md with pretty-printed schema JSON. */
162
+ function buildReferenceMd(root: CliCommand): string {
163
+ return [
164
+ `# ${root.key} — CLI reference`,
165
+ "",
166
+ "Generated from the program `--schema` export. Handlers and runtime-only nodes are omitted.",
167
+ "",
168
+ "```json",
169
+ cliSchemaJson(root).trimEnd(),
170
+ "```",
171
+ "",
172
+ ].join("\n");
173
+ }
174
+
175
+ /** Generates SKILL.md and reference.md for Cursor or Claude Code. */
176
+ export function generateSkillBundle(root: CliCommand, target: SkillTarget): SkillBundle {
177
+ const dirName = sanitizeToolSegment(root.key);
178
+ return {
179
+ dirName,
180
+ skillMd: buildSkillMd(root, target, dirName),
181
+ referenceMd: buildReferenceMd(root),
182
+ };
183
+ }
@@ -0,0 +1,47 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { CliCommand } from "../types.ts";
5
+ import { generateSkillBundle, type SkillTarget } from "./generate.ts";
6
+
7
+ export interface SkillInstallOpts {
8
+ global?: boolean;
9
+ /** When true, remove an existing skill directory before writing. */
10
+ rimraf?: boolean;
11
+ /** When true, skip writes but return paths that would change. */
12
+ dry?: boolean;
13
+ }
14
+
15
+ function userHome(): string {
16
+ return process.env.HOME ?? homedir();
17
+ }
18
+
19
+ function resolveSkillDir(target: SkillTarget, dirName: string, global: boolean): string {
20
+ const base = global
21
+ ? join(userHome(), target === "cursor" ? ".cursor" : ".claude", "skills")
22
+ : join(process.cwd(), target === "cursor" ? ".cursor" : ".claude", "skills");
23
+ return join(base, dirName);
24
+ }
25
+
26
+ /** Writes SKILL.md and reference.md; returns changed file paths. */
27
+ export function cliSkillInstall(root: CliCommand, target: SkillTarget, opts: SkillInstallOpts): string[] {
28
+ const bundle = generateSkillBundle(root, target);
29
+ const dir = resolveSkillDir(target, bundle.dirName, opts.global ?? false);
30
+ const changed: string[] = [];
31
+
32
+ if (opts.rimraf && existsSync(dir) && !opts.dry) {
33
+ rmSync(dir, { recursive: true, force: true });
34
+ }
35
+
36
+ const skillPath = join(dir, "SKILL.md");
37
+ const refPath = join(dir, "reference.md");
38
+
39
+ if (!opts.dry) {
40
+ mkdirSync(dir, { recursive: true });
41
+ writeFileSync(skillPath, bundle.skillMd, "utf8");
42
+ writeFileSync(refPath, bundle.referenceMd, "utf8");
43
+ }
44
+
45
+ changed.push(skillPath, refPath);
46
+ return changed;
47
+ }
package/src/types.ts CHANGED
@@ -153,6 +153,16 @@ export interface CliMcpToolConfig {
153
153
  requiresEnv?: string[];
154
154
  }
155
155
 
156
+ /**
157
+ * Root-only. Opt-out and defaults for the `install` built-in (compiled binaries only).
158
+ */
159
+ export interface CliInstallConfig {
160
+ /** When `false`, hide/disable `install` (default: enabled). */
161
+ enabled?: boolean;
162
+ /** Default bin directory (default: `~/.local/bin`). Overridden by `INSTALL_PREFIX` env and `--prefix`. */
163
+ prefix?: string;
164
+ }
165
+
156
166
  /**
157
167
  * Base properties shared by all command nodes.
158
168
  */
@@ -167,6 +177,8 @@ export interface CliCommandBase {
167
177
  options?: CliOption[];
168
178
  /** Root-only. When set, enables the `mcp` built-in subcommand. */
169
179
  mcpServer?: CliMcpServerConfig;
180
+ /** Root-only. Opt-out and defaults for `install` (compiled binaries only). */
181
+ install?: CliInstallConfig;
170
182
  /** Leaf-only. Per-tool MCP exposure and metadata. */
171
183
  mcpTool?: CliMcpToolConfig;
172
184
  }
package/src/validate.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  /*
2
2
  This module validates CLI schemas before execution.
3
- It checks reserved command names, handler placement, fallback rules, duplicate names,
4
- and positional ordering before the runtime starts.
5
-
6
- It fails early on structural problems so invalid trees never reach parsing or dispatch.
7
3
  */
8
4
 
9
5
  import {
@@ -14,26 +10,24 @@ import {
14
10
  } from "./types.ts";
15
11
  import { MCP_SCHEMA_URI_DEFAULT } from "./mcp/tools.ts";
16
12
 
17
- const reservedCommandNames = ["completion", "mcp"];
13
+ function reservedCommandNames(root: CliCommand): string[] {
14
+ const names = ["completion", "install"];
15
+ if (root.mcpServer !== undefined) {
16
+ names.push("mcp");
17
+ }
18
+ return names;
19
+ }
18
20
 
19
- /**
20
- * Validates the static CliCommand tree against ArgBarg rules.
21
- * Throws CliSchemaValidationError if rules are violated.
22
- */
23
21
  export function cliValidateRoot(root: CliCommand): void {
24
-
25
- // Check for reserved command names at root
26
22
  for (const child of root.commands ?? []) {
27
- if (reservedCommandNames.includes(child.key)) {
23
+ if (reservedCommandNames(root).includes(child.key)) {
28
24
  throw new CliSchemaValidationError(`Reserved command name: ${child.key}`);
29
25
  }
30
26
  }
31
27
 
32
- // Recursively validate
33
28
  walkCommand(root, true);
34
29
  }
35
30
 
36
- /** Recursively validates a command node: handlers vs children, options, and positionals. */
37
31
  function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
38
32
  if (!isRoot && cmd.mcpServer !== undefined) {
39
33
  throw new CliSchemaValidationError(
@@ -41,6 +35,12 @@ function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
41
35
  );
42
36
  }
43
37
 
38
+ if (!isRoot && cmd.install !== undefined) {
39
+ throw new CliSchemaValidationError(
40
+ "install is only supported on the program root (not on " + cmd.key + ")",
41
+ );
42
+ }
43
+
44
44
  const isLeaf = "handler" in cmd && !!cmd.handler;
45
45
  if (!isLeaf && cmd.mcpTool !== undefined) {
46
46
  throw new CliSchemaValidationError(
@@ -64,7 +64,6 @@ function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
64
64
  }
65
65
  }
66
66
 
67
- // Check for duplicate child names
68
67
  const seenNames = new Set<string>();
69
68
  for (const child of cmd.commands ?? []) {
70
69
  if (seenNames.has(child.key)) {
@@ -89,7 +88,6 @@ function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
89
88
  }
90
89
  }
91
90
 
92
- // Validate options (short name uniqueness, reserved -h, required presence)
93
91
  const seenShorts = new Set<string>();
94
92
  for (const opt of cmd.options ?? []) {
95
93
  if (opt.required && opt.kind === CliOptionKind.Presence) {
@@ -143,7 +141,6 @@ function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
143
141
  }
144
142
  }
145
143
 
146
- // Validate positionals
147
144
  const positionals = cmd.positionals ?? [];
148
145
  for (const p of positionals) {
149
146
  if (p.argMin !== undefined && p.argMin < 0) {
@@ -162,7 +159,6 @@ function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
162
159
  }
163
160
  }
164
161
 
165
- // Check positional ordering: required before optional
166
162
  let sawOptional = false;
167
163
  for (const p of positionals) {
168
164
  const { argMin = 1 } = p;
@@ -173,7 +169,6 @@ function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
173
169
  }
174
170
  }
175
171
 
176
- // Check unlimited positional must be last
177
172
  for (let idx = 0; idx < positionals.length; idx++) {
178
173
  const { argMax = 1 } = positionals[idx]!;
179
174
  if (argMax === 0 && idx + 1 < positionals.length) {
@@ -183,7 +178,6 @@ function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
183
178
  }
184
179
  }
185
180
 
186
- // Recurse into nested commands
187
181
  for (const child of cmd.commands ?? []) {
188
182
  walkCommand(child, false);
189
183
  }