argsbarg 1.4.3 → 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 (57) hide show
  1. package/.cursor/plans/cliprogram_capabilities_refactor_081e1737.plan.md +224 -0
  2. package/.private/scratch.md +1 -1
  3. package/CHANGELOG.md +39 -1
  4. package/README.md +29 -21
  5. package/docs/ai-skills.md +24 -52
  6. package/docs/install.md +84 -0
  7. package/docs/mcp.md +8 -8
  8. package/examples/mcp-test.ts +3 -3
  9. package/examples/minimal.ts +3 -3
  10. package/examples/nested.ts +3 -3
  11. package/examples/option-required.ts +3 -3
  12. package/index.d.ts +44 -50
  13. package/package.json +1 -1
  14. package/src/builtins/builtins.test.ts +101 -0
  15. package/src/builtins/completion-bash.ts +240 -0
  16. package/src/builtins/completion-fish.ts +73 -0
  17. package/src/builtins/completion-group.ts +50 -0
  18. package/src/builtins/completion-zsh.ts +244 -0
  19. package/src/builtins/dispatch.ts +138 -0
  20. package/src/builtins/export.ts +53 -0
  21. package/src/builtins/index.ts +10 -0
  22. package/src/builtins/install.ts +99 -0
  23. package/src/builtins/mcp.ts +13 -0
  24. package/src/builtins/presentation.ts +50 -0
  25. package/src/builtins/scopes.ts +46 -0
  26. package/src/builtins/shell-helpers.ts +24 -0
  27. package/src/capabilities.ts +32 -0
  28. package/src/completion.ts +10 -693
  29. package/src/context.ts +21 -6
  30. package/src/help.ts +21 -9
  31. package/src/index.test.ts +114 -118
  32. package/src/index.ts +2 -1
  33. package/src/install/binary.ts +82 -0
  34. package/src/install/compiled.ts +15 -0
  35. package/src/install/completions.ts +52 -0
  36. package/src/install/detect-installed.ts +67 -0
  37. package/src/install/index.ts +196 -0
  38. package/src/install/install.test.ts +124 -0
  39. package/src/install/mcp-config.ts +70 -0
  40. package/src/install/paths.ts +69 -0
  41. package/src/install/plan.ts +183 -0
  42. package/src/install/shell.ts +56 -0
  43. package/src/install/status.ts +63 -0
  44. package/src/install/uninstall.ts +111 -0
  45. package/src/invoke.ts +14 -5
  46. package/src/mcp/server.ts +3 -3
  47. package/src/mcp/tools.ts +17 -17
  48. package/src/mcp.ts +2 -2
  49. package/src/parse.ts +55 -27
  50. package/src/runtime.ts +47 -100
  51. package/src/schema.ts +10 -52
  52. package/src/skill/generate.ts +10 -10
  53. package/src/skill/install.ts +21 -19
  54. package/src/types.test.ts +40 -0
  55. package/src/types.ts +59 -49
  56. package/src/validate.ts +89 -83
  57. package/src/ai.ts +0 -7
package/docs/mcp.md CHANGED
@@ -9,12 +9,12 @@ MCP is **opt-in**. Apps that do not set `mcpServer` on the program root behave e
9
9
  1. Add `mcpServer` to your program root:
10
10
 
11
11
  ```typescript
12
- const cli: CliCommand = {
12
+ const cli = {
13
13
  key: "myapp",
14
14
  description: "My app.",
15
15
  mcpServer: { name: "myapp", version: "1.0.0" },
16
16
  commands: [/* ... */],
17
- };
17
+ } satisfies CliProgram;
18
18
  ```
19
19
 
20
20
  `mcpServer: {}` is enough to enable the server. Optional fields override defaults (see [Configuration](#configuration)).
@@ -22,7 +22,7 @@ const cli: CliCommand = {
22
22
  2. Run the MCP server:
23
23
 
24
24
  ```bash
25
- myapp ai mcp
25
+ myapp mcp
26
26
  ```
27
27
 
28
28
  The process reads NDJSON requests from stdin and writes NDJSON responses to stdout. It stays alive until stdin closes.
@@ -34,7 +34,7 @@ Optionally install an agent skill for discovery without MCP: see [docs/ai-skills
34
34
  The `examples/nested.ts` demo enables MCP — try:
35
35
 
36
36
  ```bash
37
- bun run examples/nested.ts ai mcp
37
+ bun run examples/nested.ts mcp
38
38
  ```
39
39
 
40
40
  ## Client setup
@@ -58,11 +58,11 @@ Use your real binary or script path. For a compiled CLI, `command` can be the in
58
58
 
59
59
  ### Other MCP hosts
60
60
 
61
- Any host that spawns a subprocess and wires stdin/stdout works the same way: the **command** is your app, and **`ai mcp`** starts the server.
61
+ Any host that spawns a subprocess and wires stdin/stdout works the same way: the **command** is your app, and **`mcp`** starts the server.
62
62
 
63
63
  ## Configuration
64
64
 
65
- Set `mcpServer` on the **program root only** (the `CliCommand` passed to `cliRun`). Validation rejects `mcpServer` on nested nodes.
65
+ Set `mcpServer` on the **program root only** (the `CliProgram` passed to `cliRun`). Validation rejects `mcpServer` on nested nodes.
66
66
 
67
67
  | Field | Default | Purpose |
68
68
  | --- | --- | --- |
@@ -277,7 +277,7 @@ Requests without an `id` are treated as notifications and do not receive a respo
277
277
  ### Manual smoke test
278
278
 
279
279
  ```bash
280
- printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | bun run examples/nested.ts ai mcp
280
+ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | bun run examples/nested.ts mcp
281
281
  ```
282
282
 
283
283
  You should get one JSON line on stdout with `result.capabilities` and `result.serverInfo`.
@@ -290,7 +290,7 @@ When MCP is enabled:
290
290
  - Do not declare a top-level command named **`completion`** — reserved for shell completions.
291
291
  - Do not declare an option named **`schema`** — reserved for `--schema`.
292
292
 
293
- Running `myapp ai mcp` without `mcpServer` on the root fails with an error (exit 1).
293
+ Running `myapp mcp` without `mcpServer` on the root fails with an error (exit 1).
294
294
 
295
295
  ## Design notes
296
296
 
@@ -3,11 +3,11 @@
3
3
  MCP test fixture for subprocess integration tests only.
4
4
  */
5
5
 
6
- import { cliRun, CliCommand, CliOptionKind } from "../src/index.ts";
6
+ import { cliRun, CliProgram, CliOptionKind } from "../src/index.ts";
7
7
 
8
8
  const envFilePath = process.env.ARGS_TEST_ENV_FILE;
9
9
 
10
- const cli: CliCommand = {
10
+ const cli = {
11
11
  key: "mcp-test",
12
12
  description: "MCP integration test fixture.",
13
13
  mcpServer: {
@@ -61,6 +61,6 @@ const cli: CliCommand = {
61
61
  },
62
62
  },
63
63
  ],
64
- };
64
+ } satisfies CliProgram;
65
65
 
66
66
  await cliRun(cli);
@@ -7,9 +7,9 @@ readers can copy the pattern into their own scripts quickly.
7
7
  It demonstrates the minimal Bun integration path.
8
8
  */
9
9
 
10
- import { cliRun, CliCommand, CliOptionKind } from "../src/index.ts";
10
+ import { cliRun, CliProgram, CliOptionKind } from "../src/index.ts";
11
11
 
12
- const cli: CliCommand = {
12
+ const cli = {
13
13
  key: "minimal.ts",
14
14
  description: "Tiny demo.",
15
15
  positionals: [
@@ -36,6 +36,6 @@ const cli: CliCommand = {
36
36
  }
37
37
  console.log(`hello ${name}`);
38
38
  },
39
- };
39
+ } satisfies CliProgram;
40
40
 
41
41
  await cliRun(cli);
@@ -7,9 +7,9 @@ and fallback commands fit together in one schema.
7
7
  It demonstrates how the schema scales beyond one command.
8
8
  */
9
9
 
10
- import { cliRun, CliCommand, CliOptionKind, CliFallbackMode } from "../src/index.ts";
10
+ import { cliRun, CliProgram, CliOptionKind, CliFallbackMode } from "../src/index.ts";
11
11
 
12
- const cli: CliCommand = {
12
+ const cli = {
13
13
  key: "nested.ts",
14
14
  description: "Nested groups demo.",
15
15
  mcpServer: { name: "nested-demo", version: "1.0.0" },
@@ -97,6 +97,6 @@ const cli: CliCommand = {
97
97
  ],
98
98
  fallbackCommand: "read",
99
99
  fallbackMode: CliFallbackMode.MissingOrUnknown,
100
- };
100
+ } satisfies CliProgram;
101
101
 
102
102
  await cliRun(cli);
@@ -7,9 +7,9 @@ readers can copy the pattern into their own scripts quickly.
7
7
  It demonstrates the minimal Bun integration path.
8
8
  */
9
9
 
10
- import { cliRun, CliCommand, CliOptionKind, CliFallbackMode, isInteractiveTty } from "../src/index.ts";
10
+ import { cliRun, CliProgram, CliOptionKind, CliFallbackMode, isInteractiveTty } from "../src/index.ts";
11
11
 
12
- const cli: CliCommand = {
12
+ const cli = {
13
13
  key: "option-required.ts",
14
14
  description: "Demo of a required option.",
15
15
  options: [
@@ -42,6 +42,6 @@ const cli: CliCommand = {
42
42
  console.log(`requiredNonTty: ${requiredNonTty}`);
43
43
  console.log(`optional: ${optional}`);
44
44
  },
45
- };
45
+ } satisfies CliProgram;
46
46
 
47
47
  await cliRun(cli);
package/index.d.ts CHANGED
@@ -7,11 +7,13 @@ export declare class CliContext {
7
7
  readonly appName: string;
8
8
  readonly commandPath: string[];
9
9
  readonly args: string[];
10
- readonly schema: CliCommand;
10
+ readonly schema: CliProgram;
11
11
  readonly opts: Record<string, string>;
12
12
  readonly invocation: CliInvocation;
13
- /** Captures the merged program root, routed path, positional words, and option map for a leaf handler. */
14
- constructor(appName: string, commandPath: string[], args: string[], opts: Record<string, string>, schema: CliCommand, invocation?: CliInvocation);
13
+ /** Program root schema (same as {@link schema}). */
14
+ get program(): CliProgram;
15
+ /** Captures the program root, routed path, positional words, and option map for a leaf handler. */
16
+ constructor(appName: string, commandPath: string[], args: string[], opts: Record<string, string>, schema: CliProgram, invocation?: CliInvocation);
15
17
  /** Returns whether a presence flag was set (including implicit "1" for boolean options). */
16
18
  hasFlag(name: string): boolean;
17
19
  /** Returns the string value for a string-valued option, if present. */
@@ -64,7 +66,7 @@ export declare enum CliFallbackMode {
64
66
  UnknownOnly = "unknownOnly"
65
67
  }
66
68
  /**
67
- * A named flag or value option (`--long`, `-short`), listed on `CliCommand.options`.
69
+ * A named flag or value option (`--long`, `-short`), listed on command `options`.
68
70
  */
69
71
  export interface CliOption {
70
72
  /** Option name (e.g., "name", "verbose"). */
@@ -84,7 +86,7 @@ export interface CliOption {
84
86
  choices?: string[];
85
87
  }
86
88
  /**
87
- * An ordered positional argument slot, listed on `CliCommand.positionals`.
89
+ * An ordered positional argument slot, listed on leaf `positionals`.
88
90
  */
89
91
  export interface CliPositional {
90
92
  /** Positional name (used in help and error messages). */
@@ -105,7 +107,7 @@ export interface CliPositional {
105
107
  argMax?: number;
106
108
  }
107
109
  /**
108
- * Root-only. Enables `myapp ai mcp` and MCP stdio server metadata.
110
+ * Enables `myapp mcp` and MCP stdio server metadata (program root only).
109
111
  */
110
112
  export interface CliMcpServerConfig {
111
113
  /** `initialize` serverInfo.name (default: root `key`). */
@@ -166,18 +168,18 @@ export interface CliMcpToolConfig {
166
168
  requiresEnv?: string[];
167
169
  }
168
170
  /**
169
- * Root-only. Opt out of `ai skill` install commands with `{ enabled: false }`.
171
+ * Opt-out and defaults for the `install` built-in (compiled binaries only; program root only).
170
172
  */
171
- export interface CliAiSkillConfig {
172
- /** When `false`, disable `ai skill *` install commands (default: enabled). */
173
+ export interface CliInstallConfig {
174
+ /** When `false`, hide/disable `install` (default: enabled). */
173
175
  enabled?: boolean;
174
- /** Skill directory name (default: sanitized root `key`). */
175
- name?: string;
176
+ /** Default bin directory (default: `~/.local/bin`). Overridden by `INSTALL_PREFIX` env and `--prefix`. */
177
+ prefix?: string;
176
178
  }
177
179
  /**
178
- * Base properties shared by all command nodes.
180
+ * Base properties shared by all nodes in the user command tree.
179
181
  */
180
- export interface CliCommandBase {
182
+ export interface CliNodeBase {
181
183
  /** Program or command key (e.g., "myapp", "stat", "owner"). */
182
184
  key: string;
183
185
  /** Short description shown in help. */
@@ -186,49 +188,50 @@ export interface CliCommandBase {
186
188
  notes?: string;
187
189
  /** Global or command-level flags/options. */
188
190
  options?: CliOption[];
189
- /** Root-only. When set, enables the `ai mcp` built-in subcommand. */
190
- mcpServer?: CliMcpServerConfig;
191
- /** Root-only. Opt out of `ai skill` install with `{ enabled: false }`. */
192
- aiSkill?: CliAiSkillConfig;
193
- /** Leaf-only. Per-tool MCP exposure and metadata. */
194
- mcpTool?: CliMcpToolConfig;
195
191
  }
196
192
  /**
197
- * A command node: either a routing group (has commands) or a leaf (has handler).
198
- *
199
- * The value passed to cliRun is the program root: name is the app/binary name.
200
- * The root may be a routing group or a leaf command.
193
+ * A leaf command node with a handler and optional positionals.
201
194
  */
202
- export type CliCommand = (CliCommandBase & {
195
+ export type CliLeaf = CliNodeBase & {
203
196
  /** Handler function for leaf commands. */
204
197
  handler: CliHandler;
205
198
  /** Positional argument definitions. */
206
199
  positionals?: CliPositional[];
207
- /** Nested subcommands (empty for leaf commands). */
208
- commands?: never;
209
- /** Default subcommand (routing commands only). */
210
- fallbackCommand?: never;
211
- /** How fallbackCommand is applied at this routing node (routing commands only). */
212
- fallbackMode?: never;
213
- }) | (CliCommandBase & {
200
+ /** Per-tool MCP exposure and metadata. */
201
+ mcpTool?: CliMcpToolConfig;
202
+ };
203
+ /**
204
+ * A routing command node with nested subcommands.
205
+ */
206
+ export type CliRouter = CliNodeBase & {
214
207
  /** Nested subcommands. */
215
- commands: CliCommand[];
208
+ commands: CliNode[];
216
209
  /** Default subcommand when argv omits a command or uses an unknown token at this routing node. */
217
210
  fallbackCommand?: string;
218
- /** How fallbackCommand is applied at this routing node (not root-only). */
211
+ /** How fallbackCommand is applied at this routing node. */
219
212
  fallbackMode?: CliFallbackMode;
220
- /** Handler function (leaf commands only). */
221
- handler?: never;
222
- /** Positional argument definitions (leaf commands only). */
223
- positionals?: never;
224
- });
213
+ };
214
+ /**
215
+ * A node in the user-defined command tree (router or leaf).
216
+ */
217
+ export type CliNode = CliLeaf | CliRouter;
218
+ /**
219
+ * Program root passed to `cliRun` / `cliInvoke`.
220
+ * May be a leaf or router, plus optional program-level MCP and install config.
221
+ */
222
+ export type CliProgram = CliNode & {
223
+ /** When set, enables the `mcp` built-in subcommand. */
224
+ mcpServer?: CliMcpServerConfig;
225
+ /** Opt-out and defaults for `install` (compiled binaries only). */
226
+ install?: CliInstallConfig;
227
+ };
225
228
  /**
226
229
  * Handler closure type for leaf commands.
227
230
  * Supports both sync and async handlers.
228
231
  */
229
232
  export type CliHandler = (ctx: CliContext) => void | Promise<void>;
230
233
  /**
231
- * Error thrown when the static CliCommand tree violates ArgsBarg rules.
234
+ * Error thrown when the static CLI tree violates ArgsBarg rules.
232
235
  */
233
236
  export declare class CliSchemaValidationError extends Error {
234
237
  /** Creates a schema validation error with a human-readable rule violation. */
@@ -253,17 +256,8 @@ export interface CliInvokeResult {
253
256
  * Parses argv against the user root, runs the leaf handler, and returns captured output.
254
257
  * Never calls process.exit.
255
258
  */
256
- export declare function cliInvoke(root: CliCommand, argv: string[]): Promise<CliInvokeResult>;
257
- /**
258
- * Validates the schema, parses argv, prints help or errors, runs completion or the leaf handler, then exits.
259
- *
260
- * @param root The root CliCommand.
261
- * @param argv Override the default argv (process.argv.slice(2)).
262
- */
263
- export declare function cliRun(root: CliCommand, argv?: string[]): Promise<never>;
264
- /**
265
- * Prints a red error line and contextual help on stderr, then exits with status 1.
266
- */
259
+ export declare function cliInvoke(root: CliProgram, argv: string[]): Promise<CliInvokeResult>;
260
+ export declare function cliRun(program: CliProgram, argv?: string[]): Promise<never>;
267
261
  export declare function cliErrWithHelp(ctx: CliContext, msg: string): never;
268
262
  /** True when stdin is a TTY. */
269
263
  export declare const isInteractiveTty: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "1.4.3",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "//just": "echo this app uses justfile for development tasks"
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test, afterEach } from "bun:test";
2
+ import { cliBuiltinInstallCommand, installBuiltinOptions } from "./install.ts";
3
+ import { cliBuiltinMcpCommand } from "./mcp.ts";
4
+ import { cliPresentationRoot } from "./presentation.ts";
5
+ import { completionBashScript, completionFishScript, completionZshScript } from "./index.ts";
6
+ import { exportPresentationBuiltins } from "./export.ts";
7
+ import { CliProgram } from "../types.ts";
8
+ import { setCompiledExecutableOverride } from "../install/compiled.ts";
9
+
10
+ const fixture: CliProgram = {
11
+ key: "myapp",
12
+ description: "Demo app.",
13
+ mcpServer: { name: "myapp" },
14
+ commands: [
15
+ {
16
+ key: "hello",
17
+ description: "Say hello.",
18
+ handler: () => {},
19
+ },
20
+ ],
21
+ };
22
+
23
+ afterEach(() => {
24
+ setCompiledExecutableOverride(null);
25
+ });
26
+
27
+ describe("builtins help copy", () => {
28
+ test("install command includes description and option text when compiled", () => {
29
+ setCompiledExecutableOverride(true);
30
+ const install = cliBuiltinInstallCommand(fixture);
31
+ expect(install.description).toContain("Install the binary");
32
+ expect(install.notes).toContain("bun build --compile");
33
+ const names = installBuiltinOptions(fixture).map((o) => o.name);
34
+ expect(names).toContain("all");
35
+ expect(names).toContain("mcp");
36
+ expect(names).toContain("prefix");
37
+ });
38
+
39
+ test("install omits --mcp option when mcpServer unset", () => {
40
+ setCompiledExecutableOverride(true);
41
+ const noMcp: CliProgram = { key: "x", description: "x", handler: () => {} };
42
+ const names = installBuiltinOptions(noMcp).map((o) => o.name);
43
+ expect(names).not.toContain("mcp");
44
+ });
45
+
46
+ test("mcp builtin description is user-facing", () => {
47
+ const mcp = cliBuiltinMcpCommand();
48
+ expect(mcp.description).toContain("MCP server");
49
+ expect(mcp.notes).toContain('["mcp"]');
50
+ });
51
+ });
52
+
53
+ describe("presentation root", () => {
54
+ test("includes mcp when mcpServer set", () => {
55
+ setCompiledExecutableOverride(false);
56
+ const root = cliPresentationRoot(fixture);
57
+ expect(root.commands?.map((c) => c.key)).toContain("mcp");
58
+ expect(root.commands?.map((c) => c.key)).not.toContain("install");
59
+ });
60
+
61
+ test("includes install when compiled", () => {
62
+ setCompiledExecutableOverride(true);
63
+ const root = cliPresentationRoot(fixture);
64
+ expect(root.commands?.map((c) => c.key)).toContain("install");
65
+ });
66
+ });
67
+
68
+ describe("completion emitters", () => {
69
+ test("fish script references app key and subcommands", () => {
70
+ setCompiledExecutableOverride(true);
71
+ const schema = cliPresentationRoot(fixture);
72
+ const fish = completionFishScript(schema);
73
+ expect(fish).toContain("complete -c myapp");
74
+ expect(fish).toContain("hello");
75
+ expect(fish).toContain("install");
76
+ });
77
+
78
+ test("bash script includes install flags when compiled", () => {
79
+ setCompiledExecutableOverride(true);
80
+ const schema = cliPresentationRoot(fixture);
81
+ const bash = completionBashScript(schema);
82
+ expect(bash).toContain("--all");
83
+ expect(bash).toContain("install");
84
+ });
85
+
86
+ test("zsh script registers compdef", () => {
87
+ const schema = cliPresentationRoot({ key: "zapp", description: "z", handler: () => {} });
88
+ const zsh = completionZshScript(schema);
89
+ expect(zsh).toContain("#compdef zapp");
90
+ expect(zsh).toContain("compdef _zapp zapp");
91
+ });
92
+ });
93
+
94
+ describe("schema export builtins", () => {
95
+ test("exportPresentationBuiltins includes install options when compiled", () => {
96
+ setCompiledExecutableOverride(true);
97
+ const builtins = exportPresentationBuiltins(fixture);
98
+ const install = builtins.find((b) => b.key === "install");
99
+ expect(install?.options?.find((o) => o.name === "all")?.description).toContain("binary");
100
+ });
101
+ });
@@ -0,0 +1,240 @@
1
+ import { CliNode, CliRouter, CliOptionKind } from "../types.ts";
2
+ import { collectScopes, type ScopeRec } from "./scopes.ts";
3
+ import {
4
+ escShellSingleQuoted,
5
+ identToken,
6
+ kHelpLong,
7
+ kHelpShort,
8
+ kSchemaLong,
9
+ mainName,
10
+ } from "./shell-helpers.ts";
11
+
12
+ function emitConsumeLong(ident: string, scopes: ScopeRec[]): string {
13
+ let o = "_${ident}_nac_consume_long() {\n".replace("${ident}", ident);
14
+ o += " local sid=\"$1\" w=\"$2\" nw=\"$3\"\n";
15
+ o += " case $sid in\n";
16
+ for (const [i, sc] of scopes.entries()) {
17
+ o += " " + i + ")\n";
18
+ o += " case $w in\n";
19
+ o += " " + kHelpLong + "|${kHelpLong}=*|${kHelpShort}) echo 1 ;;\n".replace(/\$\{kHelpLong\}/g, kHelpLong).replace(/\$\{kHelpShort\}/g, kHelpShort);
20
+ if (sc.path === "") {
21
+ o += " " + kSchemaLong + ") echo 1 ;;\n";
22
+ }
23
+ for (const op of sc.opts) {
24
+ const base = "--" + op.name;
25
+ if (op.kind === "presence") {
26
+ o += " " + base + "|${base}=*) echo 1 ;;\n".replace(/\$\{base\}/g, base);
27
+ } else {
28
+ o += " " + base + "=*) echo 1 ;;\n";
29
+ o += " " + base + ") echo 2 ;;\n";
30
+ }
31
+ }
32
+ o += " *) echo 0 ;;\n";
33
+ o += " esac\n";
34
+ o += " ;;\n";
35
+ }
36
+ o += " *) echo 0 ;;\n";
37
+ o += " esac\n";
38
+ o += "}\n";
39
+ return o;
40
+ }
41
+
42
+ function emitConsumeShort(ident: string, scopes: ScopeRec[]): string {
43
+ let o = "_${ident}_nac_consume_short() {\n".replace("${ident}", ident);
44
+ o += " local sid=\"$1\" w=\"$2\"\n";
45
+ o += " case $sid in\n";
46
+ for (const [i, sc] of scopes.entries()) {
47
+ o += " " + i + ")\n";
48
+ o += " local rest=${w#-}\n";
49
+ o += " local ch\n";
50
+ o += " local saw=0\n";
51
+ o += " while [[ -n $rest ]]; do\n";
52
+ o += " ch=${rest:0:1}\n";
53
+ o += " rest=${rest:1}\n";
54
+ o += " case $ch in\n";
55
+ let boolChars = "";
56
+ for (const op of sc.opts) {
57
+ if (!op.shortName) continue;
58
+ if (op.kind === "presence") {
59
+ boolChars += op.shortName + "|";
60
+ } else {
61
+ o += " " + op.shortName + ")\n";
62
+ o += " if [[ $saw -ne 0 || -n $rest ]]; then echo 0; return; fi\n";
63
+ o += " echo 2; return ;;\n";
64
+ }
65
+ }
66
+ if (boolChars.length > 0) {
67
+ boolChars = boolChars.slice(0, -1);
68
+ o += " " + boolChars + ") ;;\n";
69
+ }
70
+ o += " *) echo 0; return ;;\n";
71
+ o += " esac\n";
72
+ o += " saw=1\n";
73
+ o += " done\n";
74
+ o += " echo 1\n";
75
+ o += " ;;\n";
76
+ }
77
+ o += " *) echo 0 ;;\n";
78
+ o += " esac\n";
79
+ o += "}\n";
80
+ return o;
81
+ }
82
+
83
+ function emitMatchChild(ident: string, scopes: ScopeRec[], pathIndex: Record<string, number>): string {
84
+ let o = "_${ident}_nac_match_child() {\n".replace("${ident}", ident);
85
+ o += " local sid=\"$1\" w=\"$2\"\n";
86
+ o += " case $sid in\n";
87
+ for (const [sid, sc] of scopes.entries()) {
88
+ if (sc.kids.length === 0) continue;
89
+ o += " " + sid + ")\n";
90
+ o += " case $w in\n";
91
+ for (const ch of sc.kids) {
92
+ const childPath = sc.path === "" ? ch.key : sc.path + "/" + ch.key;
93
+ const cid = pathIndex[childPath] ?? 0;
94
+ o += " " + ch.key + ") echo " + cid + "; return 0 ;;\n";
95
+ }
96
+ o += " esac\n";
97
+ o += " ;;\n";
98
+ }
99
+ o += " esac\n";
100
+ o += " return 1\n";
101
+ o += "}\n";
102
+ return o;
103
+ }
104
+
105
+ function emitSimulate(ident: string): string {
106
+ let o = "_${ident}_nac_simulate() {\n".replace("${ident}", ident);
107
+ o += " local i=1 sid=0 w steps next\n";
108
+ o += " while (( i < COMP_CWORD )); do\n";
109
+ o += " w=\"${COMP_WORDS[i]}\"\n";
110
+ o += " if [[ $w == " + kHelpShort + " || $w == " + kHelpLong + " || $w == " + kSchemaLong + " ]]; then\n";
111
+ o += " ((i++)); continue\n";
112
+ o += " fi\n";
113
+ o += " if [[ $w == --* ]]; then\n";
114
+ o += " steps=$(_${ident}_nac_consume_long \"$sid\" \"$w\" \"${COMP_WORDS[i+1]}\")\n".replace("${ident}", ident);
115
+ o += " case $steps in\n";
116
+ o += " 0) break ;;\n";
117
+ o += " 1) ((i++)) ;;\n";
118
+ o += " 2) ((i+=2)) ;;\n";
119
+ o += " *) break ;;\n";
120
+ o += " esac\n";
121
+ o += " continue\n";
122
+ o += " fi\n";
123
+ o += " if [[ $w == -* ]]; then\n";
124
+ o += " steps=$(_${ident}_nac_consume_short \"$sid\" \"$w\")\n".replace("${ident}", ident);
125
+ o += " case $steps in\n";
126
+ o += " 0) break ;;\n";
127
+ o += " 1) ((i++)) ;;\n";
128
+ o += " 2) ((i++)); break ;;\n";
129
+ o += " *) break ;;\n";
130
+ o += " esac\n";
131
+ o += " continue\n";
132
+ o += " fi\n";
133
+ o += " next=$(_${ident}_nac_match_child \"$sid\" \"$w\") || break\n".replace("${ident}", ident);
134
+ o += " sid=$next\n";
135
+ o += " ((i++))\n";
136
+ o += " done\n";
137
+ o += " REPLY_SID=$sid\n";
138
+ o += "}\n";
139
+ return o;
140
+ }
141
+
142
+ function emitEnumReplyBash(ident: string, scopes: ScopeRec[]): string {
143
+ let o = "_${ident}_nac_enum_reply() {\n".replace("${ident}", ident);
144
+ o += " local sid=\"$1\" prev=\"$2\" cur=\"$3\"\n";
145
+ o += " case $sid in\n";
146
+ for (const [i, sc] of scopes.entries()) {
147
+ const enumOpts = sc.opts.filter((op) => op.kind === CliOptionKind.Enum && (op.choices?.length ?? 0) > 0);
148
+ if (enumOpts.length === 0) continue;
149
+ o += " " + i + ")\n";
150
+ o += " case $prev in\n";
151
+ for (const op of enumOpts) {
152
+ const words = (op.choices ?? []).map((c) => escShellSingleQuoted(c)).join(" ");
153
+ o += " --" + op.name + ") COMPREPLY=( $(compgen -W '" + words + "' -- \"$cur\") ); return 0 ;;\n";
154
+ }
155
+ o += " esac\n";
156
+ o += " ;;\n";
157
+ }
158
+ o += " esac\n";
159
+ o += " return 1\n";
160
+ o += "}\n";
161
+ return o;
162
+ }
163
+
164
+ function emitMainBodyBash(schema: CliRouter, ident: string): string {
165
+ const main = mainName(schema.key);
166
+ let o = "_${main}() {\n".replace("${main}", main);
167
+ o += " local cur=\"${COMP_WORDS[COMP_CWORD]}\"\n";
168
+ o += " local prev=\"${COMP_WORDS[COMP_CWORD-1]:-}\"\n";
169
+ o += " _${ident}_nac_simulate\n".replace("${ident}", ident);
170
+ o += " local sid=$REPLY_SID\n";
171
+ o += " if _${ident}_nac_enum_reply \"$sid\" \"$prev\" \"$cur\"; then return; fi\n".replace("${ident}", ident);
172
+ o += " if [[ $cur == -* ]]; then\n";
173
+ o += " local oname=\"A_${ident}_${sid}_opts\"\n".replace("${ident}", ident);
174
+ o += " local -a optsarr\n";
175
+ o += " local -n optsref=\"$oname\"\n";
176
+ o += " COMPREPLY=( $(compgen -W \"${optsref[*]}\" -- \"$cur\") )\n";
177
+ o += " else\n";
178
+ o += " local lname=\"A_${ident}_${sid}_leaf\"\n".replace("${ident}", ident);
179
+ o += " local -n leafref=\"$lname\"\n";
180
+ o += " if [[ $leafref -eq 0 ]]; then\n";
181
+ o += " local cname=\"A_${ident}_${sid}_cmds\"\n".replace("${ident}", ident);
182
+ o += " local -a cmdsarr\n";
183
+ o += " local -n cmdsref=\"$cname\"\n";
184
+ o += " COMPREPLY=( $(compgen -W \"${cmdsref[*]}\" -- \"$cur\") )\n";
185
+ o += " else\n";
186
+ o += " local pname=\"A_${ident}_${sid}_pos\"\n".replace("${ident}", ident);
187
+ o += " local -n posref=\"$pname\"\n";
188
+ o += " if [[ $posref -eq 1 ]]; then\n";
189
+ o += " compopt -o filenames\n";
190
+ o += " fi\n";
191
+ o += " fi\n";
192
+ o += " fi\n";
193
+ o += "}\n\n";
194
+ o += "complete -F _${main} ${schema.key}\n".replace("${main}", main).replace("${schema.key}", schema.key);
195
+ return o;
196
+ }
197
+
198
+ /** Returns a self-contained bash `complete` script for the given program schema. */
199
+ export function completionBashScript(schema: CliRouter): string {
200
+ const ident = identToken(schema.key);
201
+ const scopes = collectScopes(schema);
202
+ const pathIndex: Record<string, number> = {};
203
+ for (const [i, s] of scopes.entries()) {
204
+ pathIndex[s.path] = i;
205
+ }
206
+
207
+ let out = "# Generated bash completion for " + schema.key + ".\n\n";
208
+
209
+ for (const [i, sc] of scopes.entries()) {
210
+ out += "A_" + ident + "_" + i + "_opts=()\n";
211
+ out += "A_" + ident + "_" + i + "_opts+=('" + kHelpLong + "' '" + kHelpShort + "')\n";
212
+ if (sc.path === "") {
213
+ out += "A_" + ident + "_" + i + "_opts+=('" + kSchemaLong + "')\n";
214
+ }
215
+ for (const o of sc.opts) {
216
+ out += "A_" + ident + "_" + i + "_opts+=('--" + o.name + "')\n";
217
+ if (o.shortName) {
218
+ out += "A_" + ident + "_" + i + "_opts+=('-" + o.shortName + "')\n";
219
+ }
220
+ }
221
+ out += "A_" + ident + "_" + i + "_leaf=" + (sc.kids.length === 0 ? "1" : "0") + "\n";
222
+ out += "A_" + ident + "_" + i + "_pos=" + (sc.wantsFiles ? "1" : "0") + "\n";
223
+ if (sc.kids.length > 0) {
224
+ out += "A_" + ident + "_" + i + "_cmds=(";
225
+ for (const ch of sc.kids) {
226
+ out += " '" + ch.key + "'";
227
+ }
228
+ out += ")\n";
229
+ }
230
+ }
231
+
232
+ out += emitConsumeLong(ident, scopes);
233
+ out += emitConsumeShort(ident, scopes);
234
+ out += emitMatchChild(ident, scopes, pathIndex);
235
+ out += emitSimulate(ident);
236
+ out += emitEnumReplyBash(ident, scopes);
237
+ out += emitMainBodyBash(schema, ident);
238
+
239
+ return out;
240
+ }