argsbarg 1.4.0 → 1.4.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.
package/README.md CHANGED
@@ -78,7 +78,7 @@ await cliRun(cli);
78
78
  Everything you need for a first-class CLI:
79
79
 
80
80
  - **Nested subcommands** (`CliCommand` with `commands` for groups, `handler` for leaves)
81
- - **POSIX-style options** (`-x`, `--long`, `--long=value`)
81
+ - **POSIX-style options** (`-x`, `--long`, `--long=value`) — kinds: presence, string, number, **enum** (`choices` array)
82
82
  - **Bundled presence flags** (`-abc`)
83
83
  - **Positional arguments and varargs tails** (`CliPositional` objects on `positionals`)
84
84
  - **Scoped help** at any routing depth (`-h` / `--help`)
@@ -105,9 +105,9 @@ Do not declare an option named **`schema`** — it is reserved for `--schema`.
105
105
 
106
106
  ### MCP (AI agents)
107
107
 
108
- Opt in on the program root with `mcpServer: {}` (or `{ name, version, … }`), then run `myapp mcp` for a stdio MCP server. Each leaf command becomes a tool; the CLI tree is available as resource `argsbarg://schema`.
108
+ Opt in on the program root with `mcpServer: {}` (or `{ name, version, … }`), then run `myapp mcp` for a stdio MCP server. Each leaf command becomes a tool; the CLI tree is available as resource `argsbarg://schema`. Handlers can read `ctx.invocation` and use `cliInvoke` for headless testing.
109
109
 
110
- See **[docs/mcp.md](docs/mcp.md)** for configuration, Cursor setup, tool naming, argument mapping, and protocol details.
110
+ See **[docs/mcp.md](docs/mcp.md)** for configuration, env bootstrapping, custom resources, Cursor setup, and protocol details.
111
111
 
112
112
 
113
113
  ### Shell completions
@@ -197,10 +197,11 @@ The package root (`argsbarg` / `src/index.ts`) exports the types and runtime you
197
197
  | Symbol | Role |
198
198
  | --- | --- |
199
199
  | `CliCommand`, `CliOption`, `CliPositional`, `CliHandler` | Schema and handler types. |
200
- | `CliOptionKind`, `CliFallbackMode` | Option kinds and root fallback behavior. |
200
+ | `CliOptionKind`, `CliFallbackMode` | Option kinds (`Presence`, `String`, `Number`, `Enum`) and root fallback behavior. |
201
201
  | `CliSchemaValidationError` | Thrown when the static command tree violates schema rules. |
202
- | `CliContext` | Handler context (`ctx.flag`, `ctx.stringOpt`, `ctx.args`, …). |
202
+ | `CliContext` | Handler context (`ctx.flag`, `ctx.stringOpt`, `ctx.args`, `ctx.invocation`, …). |
203
203
  | `cliRun(root, [argv])` | Validate, parse argv, dispatch, exit. |
204
+ | `cliInvoke(root, argv)` | Parse and dispatch without exiting; returns captured stdout/stderr. |
204
205
  | `cliErrWithHelp(ctx, msg)` | Print error + scoped help on stderr, exit 1. |
205
206
 
206
207
  Reserved identifier (validated at startup): root command **`completion`**.
package/docs/mcp.md CHANGED
@@ -67,6 +67,9 @@ Set `mcpServer` on the **program root only** (the `CliCommand` passed to `cliRun
67
67
  | `name` | root `key` | `serverInfo.name` in the `initialize` response |
68
68
  | `version` | `package.json` `version` in cwd, else `"0.0.0"` | `serverInfo.version` |
69
69
  | `schemaResourceUri` | `"argsbarg://schema"` | URI for the schema resource |
70
+ | `shellEnv` | off | Capture login-shell `env` at startup (`true` uses `$SHELL`, or pass a shell path) |
71
+ | `envFile` | off | Load a `.env` file after `shellEnv` (`~` supported); warns on stderr if missing |
72
+ | `resources` | `[]` | Custom `CliMcpResource` entries for `resources/list` and `resources/read` |
70
73
 
71
74
  Example with all fields:
72
75
 
@@ -117,11 +120,24 @@ Set `mcpTool: { enabled: false }` on a **leaf command** to hide it from `tools/l
117
120
 
118
121
  Omitted or `enabled: true` exposes the command (default). `mcpTool` is only valid on leaves — not on the program root or routing groups.
119
122
 
123
+ ### Per-leaf tool metadata
124
+
125
+ ```typescript
126
+ mcpTool: {
127
+ enabled: true,
128
+ description: "Custom tools/list text (overrides auto-generated path + help).",
129
+ requiresEnv: ["API_TOKEN", "DATABASE_URL"],
130
+ }
131
+ ```
132
+
133
+ - **`description`** — when set, replaces the auto-generated `path — help` description entirely (no automatic `requiresEnv` suffix; mention vars in your text if needed).
134
+ - **`requiresEnv`** — on auto-generated descriptions, appended as `[requires env: …]`. Enforced at `tools/call` time before the handler runs. Empty or unset env values count as missing.
135
+
120
136
  ### Tool arguments
121
137
 
122
138
  Each tool’s `inputSchema` is a JSON Schema object built from your CLI definition:
123
139
 
124
- - **Options** — parent-scoped flags are included (e.g. `stat`’s `--json` appears on `stat_owner_lookup`). Presence options are `boolean`; string and number options match their `CliOptionKind`. Required options are listed in `required`.
140
+ - **Options** — parent-scoped flags are included (e.g. `stat`’s `--json` appears on `stat_owner_lookup`). Presence options are `boolean`; string, number, and **enum** options match their `CliOptionKind` (`Enum` uses JSON Schema `enum`). Required options are listed in `required`.
125
141
  - **Positionals** — one property per `CliPositional` on the leaf. Single-slot positionals are `string`; varargs tails (`argMax: 0`) are `string[]`. Required positionals are listed in `required`.
126
142
 
127
143
  Arguments are a **flat JSON object** keyed by option and positional names (same names as in your schema, including hyphenated option names like `"user-name"`).
@@ -152,9 +168,9 @@ On failure (parse error, validation error, non-zero exit, thrown error), the mes
152
168
 
153
169
  Help and `--schema` are not available through tool calls; use the schema resource or run the CLI directly for those.
154
170
 
155
- ## Schema resource
171
+ ## Schema and custom resources
156
172
 
157
- The server advertises one MCP resource: your full CLI tree as JSON — the same output as `myapp --schema`.
173
+ The built-in resource `argsbarg://schema` (or `schemaResourceUri`) exposes your full CLI tree as JSON — the same output as `myapp --schema`.
158
174
 
159
175
  | Property | Value |
160
176
  | --- | --- |
@@ -162,7 +178,79 @@ The server advertises one MCP resource: your full CLI tree as JSON — the same
162
178
  | MIME type | `application/json` |
163
179
  | Contents | `cliSchemaJson(root)` — handlers omitted, built-ins excluded |
164
180
 
165
- Agents can read this resource to discover commands, options, and positionals without guessing tool shapes.
181
+ Add custom resources on the program root:
182
+
183
+ ```typescript
184
+ mcpServer: {
185
+ resources: [
186
+ {
187
+ uri: "myapp://config",
188
+ name: "config",
189
+ description: "Resolved app configuration.",
190
+ mimeType: "application/json",
191
+ load: () => JSON.stringify({ /* … */ }),
192
+ },
193
+ ],
194
+ },
195
+ ```
196
+
197
+ URIs must be unique and must not equal `schemaResourceUri`. `load()` runs synchronously at `resources/read` time.
198
+
199
+ ## Invocation context
200
+
201
+ Handlers receive `ctx.invocation`: `"cli"` for normal `cliRun` dispatch, `"mcp"` for MCP `tools/call`.
202
+
203
+ Use this to branch subprocess behavior — MCP stdout is the JSON-RPC wire, so child processes must not inherit it:
204
+
205
+ ```typescript
206
+ handler: async (ctx) => {
207
+ const proc = Bun.spawn(["my-tool", ...ctx.args], {
208
+ stdout: ctx.invocation === "mcp" ? "pipe" : "inherit",
209
+ stderr: "inherit",
210
+ });
211
+ // capture proc.stdout when piping…
212
+ };
213
+ ```
214
+
215
+ `Bun.spawn({ stdout: "inherit" })` under MCP corrupts the wire. Prefer `"pipe"` and let argsbarg return captured handler stdout in the tool result.
216
+
217
+ ### `cliInvoke` (public API)
218
+
219
+ `cliInvoke(root, argv)` runs a leaf handler without exiting the process — useful for tests and headless integrations. Returns `{ kind, exitCode, stdout, stderr }`. MCP tool dispatch uses this internally.
220
+
221
+ **Note:** Tool output is buffered until the handler completes. Live streaming (e.g. `tail -f`) is not supported yet; see [Design notes](#design-notes).
222
+
223
+ ## Environment bootstrapping
224
+
225
+ MCP hosts (e.g. Cursor) often spawn your server with a minimal environment — missing `PATH` entries for Homebrew, nvm, rbenv, etc.
226
+
227
+ At server start (`cliMcpServeStdio`), before the NDJSON loop:
228
+
229
+ | Order | Source | Behavior |
230
+ | --- | --- | --- |
231
+ | 1 | `shellEnv` | Spawns `$SHELL -l -c env`; merges into `process.env` |
232
+ | 2 | `envFile` | Loads `.env`; **overwrites** keys from step 1 |
233
+
234
+ **`shellEnv` merge rules:**
235
+
236
+ - **`PATH`** — shell-only segments are **prepended** to the host `PATH` (always merged).
237
+ - **Other vars** — set only when absent from the host environment (host wins).
238
+ - On failure — one-line warning on **stderr**; server continues.
239
+
240
+ **`envFile`:**
241
+
242
+ - Supports `~` expansion.
243
+ - Missing file — warning on stderr, server continues.
244
+ - Keys from the file **always overwrite** `process.env`.
245
+
246
+ Example:
247
+
248
+ ```typescript
249
+ mcpServer: {
250
+ shellEnv: true,
251
+ envFile: "~/.config/myapp/mcp.env",
252
+ },
253
+ ```
166
254
 
167
255
  ## Protocol
168
256
 
@@ -179,8 +267,8 @@ Agents can read this resource to discover commands, options, and positionals wit
179
267
  | `ping` | Returns `{}`. |
180
268
  | `tools/list` | Lists all tools with `name`, `description`, `inputSchema`. |
181
269
  | `tools/call` | Runs a leaf handler; params: `name`, `arguments` (object). |
182
- | `resources/list` | Lists the schema resource. |
183
- | `resources/read` | Returns schema JSON; params: `uri`. |
270
+ | `resources/list` | Lists schema + custom resources. |
271
+ | `resources/read` | Returns resource body; params: `uri`. |
184
272
 
185
273
  Requests without an `id` are treated as notifications and do not receive a response (except `notifications/initialized`, which is ignored after parsing).
186
274
 
@@ -207,5 +295,6 @@ Running `myapp mcp` without `mcpServer` on the root fails with an error (exit 1)
207
295
  - **Zero extra dependencies** — hand-rolled NDJSON JSON-RPC on top of ArgsBarg’s existing parser and schema.
208
296
  - **Same handlers** — tool calls run your real leaf handlers via an internal invoke path that captures stdout/stderr and does not exit the process, so the MCP server can handle many requests in one process.
209
297
  - **User schema only** — tool dispatch uses your program root, not merged presentation builtins.
298
+ - **Buffered output** — MCP tool results are sent after the handler finishes. Incremental stdout (log tail, progress) is not streamed; a future release may add MCP progress notifications.
210
299
 
211
300
  For the `--schema` export used by the resource, see the main README built-ins section.
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bun
2
+ /*
3
+ MCP test fixture for subprocess integration tests only.
4
+ */
5
+
6
+ import { cliRun, CliCommand, CliOptionKind } from "../src/index.ts";
7
+
8
+ const envFilePath = process.env.ARGS_TEST_ENV_FILE;
9
+
10
+ const cli: CliCommand = {
11
+ key: "mcp-test",
12
+ description: "MCP integration test fixture.",
13
+ mcpServer: {
14
+ name: "mcp-test",
15
+ version: "0.0.0-test",
16
+ ...(envFilePath ? { envFile: envFilePath } : {}),
17
+ resources: [
18
+ {
19
+ uri: "test://hello",
20
+ name: "hello",
21
+ description: "Test resource.",
22
+ mimeType: "text/plain",
23
+ load: () => "hello resource",
24
+ },
25
+ ],
26
+ },
27
+ commands: [
28
+ {
29
+ key: "echo-env",
30
+ description: "Echo an env var.",
31
+ mcpTool: {
32
+ requiresEnv: ["ARGS_TEST_SECRET"],
33
+ },
34
+ options: [
35
+ {
36
+ name: "name",
37
+ description: "Env var name to read.",
38
+ kind: CliOptionKind.String,
39
+ required: true,
40
+ },
41
+ ],
42
+ handler: (ctx) => {
43
+ const name = ctx.stringOpt("name") ?? "";
44
+ console.log(process.env[name] ?? "");
45
+ },
46
+ },
47
+ {
48
+ key: "set-mode",
49
+ description: "Set mode enum.",
50
+ options: [
51
+ {
52
+ name: "mode",
53
+ description: "Operating mode.",
54
+ kind: CliOptionKind.Enum,
55
+ choices: ["dev", "prod"],
56
+ required: true,
57
+ },
58
+ ],
59
+ handler: (ctx) => {
60
+ console.log(`mode=${ctx.stringOpt("mode")}`);
61
+ },
62
+ },
63
+ ],
64
+ };
65
+
66
+ await cliRun(cli);
package/index.d.ts CHANGED
@@ -1,7 +1,35 @@
1
1
  // Generated by dts-bundle-generator v9.5.1
2
2
 
3
3
  /**
4
- * Option kinds: presence (boolean flag), string (free-form text), or number (strict double).
4
+ * Values passed to a leaf command handler after parsing: app name, routed path, args, and merged options.
5
+ */
6
+ export declare class CliContext {
7
+ readonly appName: string;
8
+ readonly commandPath: string[];
9
+ readonly args: string[];
10
+ readonly schema: CliCommand;
11
+ readonly opts: Record<string, string>;
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);
15
+ /** Returns whether a presence flag was set (including implicit "1" for boolean options). */
16
+ hasFlag(name: string): boolean;
17
+ /** Returns the string value for a string-valued option, if present. */
18
+ stringOpt(name: string): string | undefined;
19
+ /** Parses a stored string as a number; returns null if missing or not a strict double string. */
20
+ numberOpt(name: string): number | null;
21
+ /**
22
+ * Generic typed accessor: parses a stored string using the provided parse function.
23
+ * This is the TypeScript-native advantage over the Swift version.
24
+ */
25
+ typedOpt<T>(name: string, parse: (s: string) => T): T | null;
26
+ }
27
+ /**
28
+ * How a leaf handler was dispatched.
29
+ */
30
+ export type CliInvocation = "cli" | "mcp";
31
+ /**
32
+ * Option kinds: presence (boolean flag), string (free-form text), number (strict double), or enum (fixed choices).
5
33
  */
6
34
  export declare enum CliOptionKind {
7
35
  /** Boolean flag: no value token (may be implicit `"1"` when set). */
@@ -9,7 +37,9 @@ export declare enum CliOptionKind {
9
37
  /** Free-form string value. */
10
38
  String = "string",
11
39
  /** Strict floating-point value (parsed at validation time). */
12
- Number = "number"
40
+ Number = "number",
41
+ /** Fixed set of allowed string values. Requires non-empty `choices` on the option. */
42
+ Enum = "enum"
13
43
  }
14
44
  /**
15
45
  * When fallbackCommand is used for missing or unknown top-level tokens.
@@ -44,6 +74,11 @@ export interface CliOption {
44
74
  shortName?: string;
45
75
  /** Whether this option must be provided. Cannot be used with Presence kind. */
46
76
  required?: boolean;
77
+ /**
78
+ * Allowed values. Required when kind === Enum; ignored otherwise.
79
+ * Must be a non-empty array of distinct non-empty strings.
80
+ */
81
+ choices?: string[];
47
82
  }
48
83
  /**
49
84
  * An ordered positional argument slot, listed on `CliCommand.positionals`.
@@ -76,6 +111,38 @@ export interface CliMcpServerConfig {
76
111
  version?: string;
77
112
  /** Resource URI for schema export (default: `"argsbarg://schema"`). */
78
113
  schemaResourceUri?: string;
114
+ /**
115
+ * Capture the user's login shell environment at MCP server start and merge it
116
+ * into process.env. Solves missing PATH, nvm/rbenv shims, Homebrew binaries,
117
+ * and shell exports that MCP hosts (e.g. Cursor) don't inherit.
118
+ */
119
+ shellEnv?: boolean | string;
120
+ /**
121
+ * Path to a .env file loaded into process.env at MCP server start, after shellEnv.
122
+ * Supports `~` expansion. Warns on stderr if the file does not exist.
123
+ * Always overwrites — envFile is authoritative for its keys.
124
+ */
125
+ envFile?: string;
126
+ /**
127
+ * Custom MCP resources exposed alongside the built-in argsbarg://schema resource.
128
+ * URIs must be unique and must not equal schemaResourceUri.
129
+ */
130
+ resources?: CliMcpResource[];
131
+ }
132
+ /**
133
+ * A custom MCP resource exposed under resources/list and resources/read.
134
+ */
135
+ export interface CliMcpResource {
136
+ /** Resource URI (must be unique; must not equal schemaResourceUri). */
137
+ uri: string;
138
+ /** Short display name for resources/list. */
139
+ name: string;
140
+ /** Optional human description for resources/list. */
141
+ description?: string;
142
+ /** MIME type (default: "text/plain"). */
143
+ mimeType?: string;
144
+ /** Called at resources/read time; must return the resource body. */
145
+ load: () => string;
79
146
  }
80
147
  /**
81
148
  * Leaf-only. Controls how this command appears as an MCP tool.
@@ -83,6 +150,17 @@ export interface CliMcpServerConfig {
83
150
  export interface CliMcpToolConfig {
84
151
  /** When `false`, omit from `tools/list` (default: exposed). */
85
152
  enabled?: boolean;
153
+ /**
154
+ * Override the generated MCP tool description.
155
+ * Default: auto-generated from command path and description.
156
+ */
157
+ description?: string;
158
+ /**
159
+ * Environment variable names required at runtime.
160
+ * Appended to auto-generated MCP tool descriptions; enforced at tools/call time.
161
+ * Empty string counts as absent.
162
+ */
163
+ requiresEnv?: string[];
86
164
  }
87
165
  /**
88
166
  * Base properties shared by all command nodes.
@@ -142,29 +220,26 @@ export declare class CliSchemaValidationError extends Error {
142
220
  /** Creates a schema validation error with a human-readable rule violation. */
143
221
  constructor(message: string);
144
222
  }
223
+ /** Outcome of a non-exiting CLI invocation. */
224
+ export type CliInvokeKind = "ok" | "help" | "schema" | "error";
225
+ /** Result of cliInvoke: captured output and exit metadata without process.exit. */
226
+ export interface CliInvokeResult {
227
+ /** Invocation outcome. */
228
+ kind: CliInvokeKind;
229
+ /** Simulated exit code. */
230
+ exitCode: number;
231
+ /** Captured stdout during handler execution. */
232
+ stdout: string;
233
+ /** Captured stderr during handler execution. */
234
+ stderr: string;
235
+ /** Set when kind === "error" (parse/validation message). */
236
+ errorMsg?: string;
237
+ }
145
238
  /**
146
- * Values passed to a leaf command handler after parsing: app name, routed path, args, and merged options.
239
+ * Parses argv against the user root, runs the leaf handler, and returns captured output.
240
+ * Never calls process.exit.
147
241
  */
148
- export declare class CliContext {
149
- readonly appName: string;
150
- readonly commandPath: string[];
151
- readonly args: string[];
152
- readonly schema: CliCommand;
153
- readonly opts: Record<string, string>;
154
- /** Captures the merged program root, routed path, positional words, and option map for a leaf handler. */
155
- constructor(appName: string, commandPath: string[], args: string[], opts: Record<string, string>, schema: CliCommand);
156
- /** Returns whether a presence flag was set (including implicit "1" for boolean options). */
157
- hasFlag(name: string): boolean;
158
- /** Returns the string value for a string-valued option, if present. */
159
- stringOpt(name: string): string | undefined;
160
- /** Parses a stored string as a number; returns null if missing or not a strict double string. */
161
- numberOpt(name: string): number | null;
162
- /**
163
- * Generic typed accessor: parses a stored string using the provided parse function.
164
- * This is the TypeScript-native advantage over the Swift version.
165
- */
166
- typedOpt<T>(name: string, parse: (s: string) => T): T | null;
167
- }
242
+ export declare function cliInvoke(root: CliCommand, argv: string[]): Promise<CliInvokeResult>;
168
243
  /**
169
244
  * Validates the schema, parses argv, prints help or errors, runs completion or the leaf handler, then exits.
170
245
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "//just": "echo this app uses justfile for development tasks"
package/src/completion.ts CHANGED
@@ -8,7 +8,7 @@ It keeps completion aligned with the runtime schema so the generated commands,
8
8
  options, and descriptions stay in sync with the CLI definition.
9
9
  */
10
10
 
11
- import { CliCommand, CliOption } from "./types.ts";
11
+ import { CliCommand, CliOption, CliOptionKind } from "./types.ts";
12
12
 
13
13
  // ── Shared Types ───────────────────────────────────────────────────────────────
14
14
 
@@ -216,6 +216,31 @@ function emitSimulate(ident: string): string {
216
216
  return o;
217
217
  }
218
218
 
219
+ /** Emits bash helper to complete Enum option values when previous token is --name. */
220
+ function emitEnumReplyBash(ident: string, scopes: ScopeRec[]): string {
221
+ let o = "_${ident}_nac_enum_reply() {\n".replace("${ident}", ident);
222
+ o += " local sid=\"$1\" prev=\"$2\" cur=\"$3\"\n";
223
+ o += " case $sid in\n";
224
+ for (const [i, sc] of scopes.entries()) {
225
+ const enumOpts = sc.opts.filter((op) => op.kind === CliOptionKind.Enum && (op.choices?.length ?? 0) > 0);
226
+ if (enumOpts.length === 0) {
227
+ continue;
228
+ }
229
+ o += " " + i + ")\n";
230
+ o += " case $prev in\n";
231
+ for (const op of enumOpts) {
232
+ const words = (op.choices ?? []).map((c) => escShellSingleQuoted(c)).join(" ");
233
+ o += " --" + op.name + ") COMPREPLY=( $(compgen -W '" + words + "' -- \"$cur\") ); return 0 ;;\n";
234
+ }
235
+ o += " esac\n";
236
+ o += " ;;\n";
237
+ }
238
+ o += " esac\n";
239
+ o += " return 1\n";
240
+ o += "}\n";
241
+ return o;
242
+ }
243
+
219
244
  /** Emits the main `COMPREPLY` driver and `complete -F` registration for bash. */
220
245
  function emitMainBodyBash(schema: CliCommand, ident: string): string {
221
246
  const main = mainName(schema.key);
@@ -224,6 +249,7 @@ function emitMainBodyBash(schema: CliCommand, ident: string): string {
224
249
  o += " local prev=\"${COMP_WORDS[COMP_CWORD-1]:-}\"\n";
225
250
  o += " _${ident}_nac_simulate\n".replace("${ident}", ident);
226
251
  o += " local sid=$REPLY_SID\n";
252
+ o += " if _${ident}_nac_enum_reply \"$sid\" \"$prev\" \"$cur\"; then return; fi\n".replace("${ident}", ident);
227
253
  o += " if [[ $cur == -* ]]; then\n";
228
254
  o += " local oname=\"A_${ident}_${sid}_opts\"\n".replace("${ident}", ident);
229
255
  o += " local -a optsarr\n";
@@ -289,6 +315,7 @@ export function completionBashScript(schema: CliCommand): string {
289
315
  out += emitConsumeShort(ident, scopes);
290
316
  out += emitMatchChild(ident, scopes, pathIndex);
291
317
  out += emitSimulate(ident);
318
+ out += emitEnumReplyBash(ident, scopes);
292
319
  out += emitMainBodyBash(schema, ident);
293
320
 
294
321
  return out;
@@ -470,6 +497,31 @@ function emitSimulateZsh(ident: string): string {
470
497
  return o;
471
498
  }
472
499
 
500
+ /** Emits zsh helper to complete Enum option values when previous token is --name. */
501
+ function emitEnumReplyZsh(ident: string, scopes: ScopeRec[]): string {
502
+ let o = "_${ident}_nac_enum_reply() {\n".replace("${ident}", ident);
503
+ o += " local sid=$1 prev=$2\n";
504
+ o += " case $sid in\n";
505
+ for (const [i, sc] of scopes.entries()) {
506
+ const enumOpts = sc.opts.filter((op) => op.kind === CliOptionKind.Enum && (op.choices?.length ?? 0) > 0);
507
+ if (enumOpts.length === 0) {
508
+ continue;
509
+ }
510
+ o += " " + i + ")\n";
511
+ o += " case $prev in\n";
512
+ for (const op of enumOpts) {
513
+ const vals = (op.choices ?? []).map((c) => escShellSingleQuoted(c)).join(" ");
514
+ o += " --" + op.name + ") _values " + vals + "; return 0 ;;\n";
515
+ }
516
+ o += " esac\n";
517
+ o += " ;;\n";
518
+ }
519
+ o += " esac\n";
520
+ o += " return 1\n";
521
+ o += "}\n";
522
+ return o;
523
+ }
524
+
473
525
  /** Zsh: `_main` completer and `compdef` registration. */
474
526
  function emitMainBodyZsh(schema: CliCommand, ident: string): string {
475
527
  const main = mainName(schema.key);
@@ -477,6 +529,7 @@ function emitMainBodyZsh(schema: CliCommand, ident: string): string {
477
529
  o += " local curcontext=\"$curcontext\" ret=1\n";
478
530
  o += " _${ident}_nac_simulate\n".replace("${ident}", ident);
479
531
  o += " local sid=$REPLY_SID\n";
532
+ o += " if _${ident}_nac_enum_reply \"$sid\" \"$words[CURRENT-1]\"; then return 0; fi\n".replace("${ident}", ident);
480
533
  o += " if [[ $PREFIX == -* ]]; then\n";
481
534
  o += " local -a optsarr\n";
482
535
  o += " local oname=\"A_${ident}_${sid}_opts\"\n".replace("${ident}", ident);
@@ -517,6 +570,7 @@ export function completionZshScript(schema: CliCommand): string {
517
570
  out += emitConsumeShortZsh(ident, scopes);
518
571
  out += emitMatchChildZsh(ident, scopes, pathIndex);
519
572
  out += emitSimulateZsh(ident);
573
+ out += emitEnumReplyZsh(ident, scopes);
520
574
  out += emitMainBodyZsh(schema, ident);
521
575
  return out;
522
576
  }
package/src/context.ts CHANGED
@@ -7,7 +7,7 @@ It keeps handlers small with a typed read API for flags, strings, numbers, and c
7
7
  parsed values.
8
8
  */
9
9
 
10
- import type { CliCommand } from "./types.ts";
10
+ import type { CliCommand, CliInvocation } from "./types.ts";
11
11
  import { strictParseDouble } from "./utils.ts";
12
12
 
13
13
  /**
@@ -19,6 +19,7 @@ export class CliContext {
19
19
  readonly args: string[];
20
20
  readonly schema: CliCommand;
21
21
  readonly opts: Record<string, string>;
22
+ readonly invocation: CliInvocation;
22
23
 
23
24
  /** Captures the merged program root, routed path, positional words, and option map for a leaf handler. */
24
25
  constructor(
@@ -27,12 +28,14 @@ export class CliContext {
27
28
  args: string[],
28
29
  opts: Record<string, string>,
29
30
  schema: CliCommand,
31
+ invocation: CliInvocation = "cli",
30
32
  ) {
31
33
  this.appName = appName;
32
34
  this.commandPath = commandPath;
33
35
  this.args = args;
34
36
  this.opts = opts;
35
37
  this.schema = schema;
38
+ this.invocation = invocation;
36
39
  }
37
40
 
38
41
  /** Returns whether a presence flag was set (including implicit "1" for boolean options). */
package/src/help.ts CHANGED
@@ -151,7 +151,7 @@ function wrapText(text: string, width: number): string[] {
151
151
  // ── Option Label Formatting ───────────────────────────────────────────────────
152
152
 
153
153
  /** Suffix for `--name` in usage (e.g. ` <string>`) based on value kind. */
154
- function optKindLabel(k: CliOptionKind): string {
154
+ function optKindLabel(k: CliOptionKind, o?: CliOption): string {
155
155
  switch (k) {
156
156
  case CliOptionKind.Presence:
157
157
  return "";
@@ -159,12 +159,22 @@ function optKindLabel(k: CliOptionKind): string {
159
159
  return " <number>";
160
160
  case CliOptionKind.String:
161
161
  return " <string>";
162
+ case CliOptionKind.Enum: {
163
+ const choices = o?.choices ?? [];
164
+ if (choices.length === 0) {
165
+ return " <choice>";
166
+ }
167
+ if (choices.length <= 4) {
168
+ return " <" + choices.join("|") + ">";
169
+ }
170
+ return " <" + choices.slice(0, 3).join("|") + "|…>";
171
+ }
162
172
  }
163
173
  }
164
174
 
165
175
  /** Formats a flag/value option for help tables: `--name`, optional short, optional kind hint. */
166
176
  export function cliOptionLabel(o: CliOption, color: boolean): string {
167
- let r = "--" + o.name + optKindLabel(o.kind);
177
+ let r = "--" + o.name + optKindLabel(o.kind, o);
168
178
  if (o.shortName) r += ", -" + o.shortName;
169
179
  if (!color) return r;
170
180