argsbarg 1.4.0 → 1.4.2
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/.cursor/plans/mcp_v1.2_invocation_and_extensions_a4f82c1e.plan.md +647 -0
- package/.cursor/plans/v1.3_parser_ergonomics_b3e91f02.plan.md +455 -0
- package/CHANGELOG.md +28 -1
- package/README.md +11 -7
- package/docs/mcp.md +96 -7
- package/examples/mcp-test.ts +66 -0
- package/index.d.ts +111 -33
- package/package.json +1 -1
- package/src/completion.ts +55 -1
- package/src/context.ts +42 -1
- package/src/help.ts +12 -2
- package/src/index.test.ts +648 -6
- package/src/index.ts +12 -1
- package/src/invoke.ts +1 -1
- package/src/mcp/env.ts +99 -0
- package/src/mcp/server.ts +34 -22
- package/src/mcp/tools.ts +59 -6
- package/src/mcp.ts +4 -0
- package/src/parse.ts +77 -10
- package/src/runtime.ts +1 -1
- package/src/types.ts +66 -11
- package/src/validate.ts +54 -16
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,12 +120,25 @@ 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
|
|
125
|
-
- **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`.
|
|
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`.
|
|
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`. For varargs, agents may also pass a comma-separated string (`"a,b"`) or a single string (`"a"`) — both are coerced to separate argv tokens at dispatch time.
|
|
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"`).
|
|
128
144
|
|
|
@@ -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
|
|
171
|
+
## Schema and custom resources
|
|
156
172
|
|
|
157
|
-
The
|
|
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
|
-
|
|
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
|
|
183
|
-
| `resources/read` | Returns
|
|
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,39 @@
|
|
|
1
1
|
// Generated by dts-bundle-generator v9.5.1
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
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
|
+
/** Returns the value(s) for a named positional slot. Varargs slots return string[]; single slots return string | undefined. */
|
|
27
|
+
positional(name: string): string | string[] | undefined;
|
|
28
|
+
private _posMap;
|
|
29
|
+
private _positionalMap;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* How a leaf handler was dispatched.
|
|
33
|
+
*/
|
|
34
|
+
export type CliInvocation = "cli" | "mcp";
|
|
35
|
+
/**
|
|
36
|
+
* Option kinds: presence (boolean flag), string (free-form text), number (strict double), or enum (fixed choices).
|
|
5
37
|
*/
|
|
6
38
|
export declare enum CliOptionKind {
|
|
7
39
|
/** Boolean flag: no value token (may be implicit `"1"` when set). */
|
|
@@ -9,24 +41,25 @@ export declare enum CliOptionKind {
|
|
|
9
41
|
/** Free-form string value. */
|
|
10
42
|
String = "string",
|
|
11
43
|
/** Strict floating-point value (parsed at validation time). */
|
|
12
|
-
Number = "number"
|
|
44
|
+
Number = "number",
|
|
45
|
+
/** Fixed set of allowed string values. Requires non-empty `choices` on the option. */
|
|
46
|
+
Enum = "enum"
|
|
13
47
|
}
|
|
14
48
|
/**
|
|
15
|
-
* When fallbackCommand is used for missing or unknown
|
|
16
|
-
* Only the program root may set a non-default mode or a non-nil fallbackCommand.
|
|
49
|
+
* When `fallbackCommand` is used for missing or unknown subcommand tokens at a routing node.
|
|
17
50
|
*/
|
|
18
51
|
export declare enum CliFallbackMode {
|
|
19
52
|
/**
|
|
20
|
-
* If argv has no
|
|
53
|
+
* If argv has no next subcommand, route to `fallbackCommand`; if the token is unknown, error.
|
|
21
54
|
*/
|
|
22
55
|
MissingOnly = "missingOnly",
|
|
23
56
|
/**
|
|
24
|
-
* If argv has no
|
|
57
|
+
* If argv has no next subcommand or the token is not a known child, route to `fallbackCommand`.
|
|
25
58
|
*/
|
|
26
59
|
MissingOrUnknown = "missingOrUnknown",
|
|
27
60
|
/**
|
|
28
|
-
* If the
|
|
29
|
-
* When the
|
|
61
|
+
* If the next token is present but not a known child, route to `fallbackCommand`.
|
|
62
|
+
* When the subcommand token is missing (exhausted argv), do not use fallback (implicit scoped help).
|
|
30
63
|
*/
|
|
31
64
|
UnknownOnly = "unknownOnly"
|
|
32
65
|
}
|
|
@@ -44,6 +77,11 @@ export interface CliOption {
|
|
|
44
77
|
shortName?: string;
|
|
45
78
|
/** Whether this option must be provided. Cannot be used with Presence kind. */
|
|
46
79
|
required?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Allowed values. Required when kind === Enum; ignored otherwise.
|
|
82
|
+
* Must be a non-empty array of distinct non-empty strings.
|
|
83
|
+
*/
|
|
84
|
+
choices?: string[];
|
|
47
85
|
}
|
|
48
86
|
/**
|
|
49
87
|
* An ordered positional argument slot, listed on `CliCommand.positionals`.
|
|
@@ -76,6 +114,38 @@ export interface CliMcpServerConfig {
|
|
|
76
114
|
version?: string;
|
|
77
115
|
/** Resource URI for schema export (default: `"argsbarg://schema"`). */
|
|
78
116
|
schemaResourceUri?: string;
|
|
117
|
+
/**
|
|
118
|
+
* Capture the user's login shell environment at MCP server start and merge it
|
|
119
|
+
* into process.env. Solves missing PATH, nvm/rbenv shims, Homebrew binaries,
|
|
120
|
+
* and shell exports that MCP hosts (e.g. Cursor) don't inherit.
|
|
121
|
+
*/
|
|
122
|
+
shellEnv?: boolean | string;
|
|
123
|
+
/**
|
|
124
|
+
* Path to a .env file loaded into process.env at MCP server start, after shellEnv.
|
|
125
|
+
* Supports `~` expansion. Warns on stderr if the file does not exist.
|
|
126
|
+
* Always overwrites — envFile is authoritative for its keys.
|
|
127
|
+
*/
|
|
128
|
+
envFile?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Custom MCP resources exposed alongside the built-in argsbarg://schema resource.
|
|
131
|
+
* URIs must be unique and must not equal schemaResourceUri.
|
|
132
|
+
*/
|
|
133
|
+
resources?: CliMcpResource[];
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* A custom MCP resource exposed under resources/list and resources/read.
|
|
137
|
+
*/
|
|
138
|
+
export interface CliMcpResource {
|
|
139
|
+
/** Resource URI (must be unique; must not equal schemaResourceUri). */
|
|
140
|
+
uri: string;
|
|
141
|
+
/** Short display name for resources/list. */
|
|
142
|
+
name: string;
|
|
143
|
+
/** Optional human description for resources/list. */
|
|
144
|
+
description?: string;
|
|
145
|
+
/** MIME type (default: "text/plain"). */
|
|
146
|
+
mimeType?: string;
|
|
147
|
+
/** Called at resources/read time; must return the resource body. */
|
|
148
|
+
load: () => string;
|
|
79
149
|
}
|
|
80
150
|
/**
|
|
81
151
|
* Leaf-only. Controls how this command appears as an MCP tool.
|
|
@@ -83,6 +153,17 @@ export interface CliMcpServerConfig {
|
|
|
83
153
|
export interface CliMcpToolConfig {
|
|
84
154
|
/** When `false`, omit from `tools/list` (default: exposed). */
|
|
85
155
|
enabled?: boolean;
|
|
156
|
+
/**
|
|
157
|
+
* Override the generated MCP tool description.
|
|
158
|
+
* Default: auto-generated from command path and description.
|
|
159
|
+
*/
|
|
160
|
+
description?: string;
|
|
161
|
+
/**
|
|
162
|
+
* Environment variable names required at runtime.
|
|
163
|
+
* Appended to auto-generated MCP tool descriptions; enforced at tools/call time.
|
|
164
|
+
* Empty string counts as absent.
|
|
165
|
+
*/
|
|
166
|
+
requiresEnv?: string[];
|
|
86
167
|
}
|
|
87
168
|
/**
|
|
88
169
|
* Base properties shared by all command nodes.
|
|
@@ -114,16 +195,16 @@ export type CliCommand = (CliCommandBase & {
|
|
|
114
195
|
positionals?: CliPositional[];
|
|
115
196
|
/** Nested subcommands (empty for leaf commands). */
|
|
116
197
|
commands?: never;
|
|
117
|
-
/** Default
|
|
198
|
+
/** Default subcommand (routing commands only). */
|
|
118
199
|
fallbackCommand?: never;
|
|
119
|
-
/** How fallbackCommand is applied (routing commands only). */
|
|
200
|
+
/** How fallbackCommand is applied at this routing node (routing commands only). */
|
|
120
201
|
fallbackMode?: never;
|
|
121
202
|
}) | (CliCommandBase & {
|
|
122
203
|
/** Nested subcommands. */
|
|
123
204
|
commands: CliCommand[];
|
|
124
|
-
/** Default
|
|
205
|
+
/** Default subcommand when argv omits a command or uses an unknown token at this routing node. */
|
|
125
206
|
fallbackCommand?: string;
|
|
126
|
-
/** How fallbackCommand is applied. */
|
|
207
|
+
/** How fallbackCommand is applied at this routing node (not root-only). */
|
|
127
208
|
fallbackMode?: CliFallbackMode;
|
|
128
209
|
/** Handler function (leaf commands only). */
|
|
129
210
|
handler?: never;
|
|
@@ -142,29 +223,26 @@ export declare class CliSchemaValidationError extends Error {
|
|
|
142
223
|
/** Creates a schema validation error with a human-readable rule violation. */
|
|
143
224
|
constructor(message: string);
|
|
144
225
|
}
|
|
226
|
+
/** Outcome of a non-exiting CLI invocation. */
|
|
227
|
+
export type CliInvokeKind = "ok" | "help" | "schema" | "error";
|
|
228
|
+
/** Result of cliInvoke: captured output and exit metadata without process.exit. */
|
|
229
|
+
export interface CliInvokeResult {
|
|
230
|
+
/** Invocation outcome. */
|
|
231
|
+
kind: CliInvokeKind;
|
|
232
|
+
/** Simulated exit code. */
|
|
233
|
+
exitCode: number;
|
|
234
|
+
/** Captured stdout during handler execution. */
|
|
235
|
+
stdout: string;
|
|
236
|
+
/** Captured stderr during handler execution. */
|
|
237
|
+
stderr: string;
|
|
238
|
+
/** Set when kind === "error" (parse/validation message). */
|
|
239
|
+
errorMsg?: string;
|
|
240
|
+
}
|
|
145
241
|
/**
|
|
146
|
-
*
|
|
242
|
+
* Parses argv against the user root, runs the leaf handler, and returns captured output.
|
|
243
|
+
* Never calls process.exit.
|
|
147
244
|
*/
|
|
148
|
-
export declare
|
|
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
|
-
}
|
|
245
|
+
export declare function cliInvoke(root: CliCommand, argv: string[]): Promise<CliInvokeResult>;
|
|
168
246
|
/**
|
|
169
247
|
* Validates the schema, parses argv, prints help or errors, runs completion or the leaf handler, then exits.
|
|
170
248
|
*
|
package/package.json
CHANGED
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). */
|
|
@@ -65,4 +68,42 @@ export class CliContext {
|
|
|
65
68
|
return null;
|
|
66
69
|
}
|
|
67
70
|
}
|
|
71
|
+
|
|
72
|
+
/** Returns the value(s) for a named positional slot. Varargs slots return string[]; single slots return string | undefined. */
|
|
73
|
+
positional(name: string): string | string[] | undefined {
|
|
74
|
+
return this._positionalMap()[name];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private _posMap: Record<string, string | string[]> | undefined;
|
|
78
|
+
|
|
79
|
+
private _positionalMap(): Record<string, string | string[]> {
|
|
80
|
+
if (this._posMap) return this._posMap;
|
|
81
|
+
|
|
82
|
+
let node: CliCommand = this.schema;
|
|
83
|
+
for (const seg of this.commandPath) {
|
|
84
|
+
const child = (node.commands ?? []).find((c) => c.key === seg);
|
|
85
|
+
if (!child) {
|
|
86
|
+
this._posMap = {};
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
node = child;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const map: Record<string, string | string[]> = {};
|
|
93
|
+
let argIdx = 0;
|
|
94
|
+
for (const p of node.positionals ?? []) {
|
|
95
|
+
const { argMax = 1 } = p;
|
|
96
|
+
if (argMax === 0) {
|
|
97
|
+
map[p.name] = this.args.slice(argIdx);
|
|
98
|
+
argIdx = this.args.length;
|
|
99
|
+
} else {
|
|
100
|
+
const val = this.args[argIdx];
|
|
101
|
+
if (val !== undefined) map[p.name] = val;
|
|
102
|
+
argIdx++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this._posMap = map;
|
|
107
|
+
return map;
|
|
108
|
+
}
|
|
68
109
|
}
|
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
|
|