argsbarg 1.4.1 → 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/v1.3_parser_ergonomics_b3e91f02.plan.md +455 -0
- package/CHANGELOG.md +14 -1
- package/README.md +5 -2
- package/docs/mcp.md +1 -1
- package/index.d.ts +13 -10
- package/package.json +1 -1
- package/src/context.ts +38 -0
- package/src/index.test.ts +307 -3
- package/src/mcp/tools.ts +13 -5
- package/src/parse.ts +62 -10
- package/src/types.ts +9 -10
- package/src/validate.ts +16 -16
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: v1.3 — parser ergonomics
|
|
3
|
+
overview: "Four targeted parser and DX improvements: nested fallbackCommand on any routing node, varargs + trailing options interleaved (fixes --flag after positionals), ctx.positional(name) named accessor, and MCP string→array coercion for varargs positionals."
|
|
4
|
+
todos:
|
|
5
|
+
- id: types-jsdoc
|
|
6
|
+
content: "Step 1: Update JSDoc on fallbackCommand/fallbackMode to remove 'root-only' / 'top-level' language; no type changes needed"
|
|
7
|
+
status: completed
|
|
8
|
+
- id: validate-fallback
|
|
9
|
+
content: "Step 2: Remove root-only restriction on fallbackCommand/fallbackMode in validate.ts; add validation that fallbackCommand names a valid child key on any routing node"
|
|
10
|
+
status: completed
|
|
11
|
+
- id: parser-fallback
|
|
12
|
+
content: "Step 3: Extend routing loop to apply fallbackCommand/fallbackMode at non-root routing nodes (mirrors existing root logic)"
|
|
13
|
+
status: completed
|
|
14
|
+
- id: parser-varargs-trailing
|
|
15
|
+
content: "Step 4: Fix finishLeaf varargs loop to interleave known-option scanning instead of greedy-consuming all remaining tokens"
|
|
16
|
+
status: completed
|
|
17
|
+
- id: ctx-positional
|
|
18
|
+
content: "Step 5: Add ctx.positional(name) accessor to CliContext — named positional lookup computed from schema + commandPath + args"
|
|
19
|
+
status: completed
|
|
20
|
+
- id: mcp-coerce
|
|
21
|
+
content: "Step 6: Coerce string → string[] in mcpToolCallToArgv for varargs positionals (comma-split or single-item)"
|
|
22
|
+
status: completed
|
|
23
|
+
- id: tests
|
|
24
|
+
content: "Step 7: Unit + integration tests for all four features"
|
|
25
|
+
status: completed
|
|
26
|
+
- id: docs-changelog
|
|
27
|
+
content: "Step 8: Update README, CHANGELOG, docs/mcp.md; run just typegen and just test"
|
|
28
|
+
status: completed
|
|
29
|
+
isProject: false
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
# v1.3 — parser ergonomics
|
|
33
|
+
|
|
34
|
+
Four parser and DX improvements. No new runtime dependencies, no public API removals, no MCP protocol changes. All four were explicitly deferred from v1.2 and follow directly from consumer app feedback.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Background
|
|
39
|
+
|
|
40
|
+
From reading the current source before writing this plan:
|
|
41
|
+
|
|
42
|
+
- **`fallbackCommand` types already support non-root** — `CliCommand` union allows `fallbackCommand` on any router node. The restriction is purely in `validate.ts` lines 39–52 (`!isRoot && cmd.fallbackCommand !== undefined → throw`). Parser fix is in the routing `while(true)` loop.
|
|
43
|
+
- **Trailing options are already partially wired** — `finishLeaf` calls `consumeOptions` on the tail for bounded-arity positionals (see `allowsTrailingOptions`). The gap is varargs (`argMax === 0`): the greedy loop at line 289–304 consumes all remaining tokens before any trailing option scan can happen.
|
|
44
|
+
- **`ctx.positional(name)`** — `CliContext` has `args: string[]` and `schema: CliCommand`. Named positional access can be computed from those; no parser change required.
|
|
45
|
+
- **MCP string→array coercion** — `mcpToolCallToArgv` currently requires `args[pos.name]` to be `string[]` for varargs. Accepts only arrays; agents passing `"a,b"` or `"a"` silently fail.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Implementation order
|
|
50
|
+
|
|
51
|
+
1. JSDoc cleanup (no logic)
|
|
52
|
+
2. Validation: remove root-only fallback restriction
|
|
53
|
+
3. Parser: nested fallback routing
|
|
54
|
+
4. Parser: varargs + trailing options interleaved
|
|
55
|
+
5. `ctx.positional(name)`
|
|
56
|
+
6. MCP string→array coercion
|
|
57
|
+
7. Tests
|
|
58
|
+
8. Docs + changelog
|
|
59
|
+
|
|
60
|
+
Do steps 3 and 4 independently — they touch different parts of `parse.ts`. Step 3 is the routing loop; step 4 is `finishLeaf`. Both must pass existing tests before moving on.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Step 1: JSDoc ([`src/types.ts`](src/types.ts))
|
|
65
|
+
|
|
66
|
+
No type changes. Update JSDoc on the router branch of `CliCommand`:
|
|
67
|
+
|
|
68
|
+
- `fallbackCommand`: change "Default **top-level** subcommand" → "Default subcommand"
|
|
69
|
+
- `fallbackMode`: change "How fallbackCommand is applied" — add note that both fields work on any routing node, not just root
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Step 2: Validation ([`src/validate.ts`](src/validate.ts))
|
|
74
|
+
|
|
75
|
+
### Remove root-only restriction
|
|
76
|
+
|
|
77
|
+
Delete lines 39–52 entirely:
|
|
78
|
+
```typescript
|
|
79
|
+
// DELETE:
|
|
80
|
+
if (!isRoot && cmd.fallbackCommand !== undefined) {
|
|
81
|
+
throw new CliSchemaValidationError(
|
|
82
|
+
"Fallback is only supported on the program root (not on " + cmd.key + ")",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (!isRoot && cmd.fallbackMode !== undefined && ...) {
|
|
86
|
+
throw new CliSchemaValidationError(
|
|
87
|
+
"fallbackMode may only be set on the program root (not on " + cmd.key + ")",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Add fallbackCommand child validation (apply to ALL routing nodes, not just root)
|
|
93
|
+
|
|
94
|
+
After the duplicate child name check (the `seenNames` loop), add:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// fallbackMode without fallbackCommand is always invalid — check unconditionally
|
|
98
|
+
if (cmd.fallbackMode !== undefined && cmd.fallbackCommand === undefined) {
|
|
99
|
+
throw new CliSchemaValidationError(
|
|
100
|
+
`fallbackMode requires fallbackCommand on '${cmd.key}'`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (cmd.fallbackCommand !== undefined) {
|
|
105
|
+
const children = cmd.commands ?? [];
|
|
106
|
+
const valid = children.find((c) => c.key === cmd.fallbackCommand);
|
|
107
|
+
if (!valid) {
|
|
108
|
+
throw new CliSchemaValidationError(
|
|
109
|
+
`fallbackCommand '${cmd.fallbackCommand}' is not a child of '${cmd.key}'`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Note:** The existing root-level validation already does the child-lookup check implicitly via the runtime. Moving it into schema validation means it's caught at startup, not at dispatch time.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Step 3: Parser — nested `fallbackCommand` ([`src/parse.ts`](src/parse.ts))
|
|
120
|
+
|
|
121
|
+
The routing `while(true)` loop (line ~459) has two places where non-root routing nodes currently error. Both need fallback logic inserted before the error return.
|
|
122
|
+
|
|
123
|
+
### Place 1: End of argv, current node has children (line ~485)
|
|
124
|
+
|
|
125
|
+
Currently: `return helpResult(path, false)` when `current.commands.length > 0` and argv is exhausted.
|
|
126
|
+
|
|
127
|
+
Add before that return:
|
|
128
|
+
```typescript
|
|
129
|
+
if ((current.commands ?? []).length > 0) {
|
|
130
|
+
const fb = current.fallbackCommand;
|
|
131
|
+
const fm = current.fallbackMode ?? CliFallbackMode.MissingOnly;
|
|
132
|
+
if (
|
|
133
|
+
fb !== undefined &&
|
|
134
|
+
(fm === CliFallbackMode.MissingOnly || fm === CliFallbackMode.MissingOrUnknown)
|
|
135
|
+
) {
|
|
136
|
+
const fbNode = findChild(current.commands ?? [], fb);
|
|
137
|
+
if (fbNode) {
|
|
138
|
+
path.push(fb);
|
|
139
|
+
current = fbNode;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return helpResult(path, false);
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Place 2: Unknown subcommand token (line ~515–526)
|
|
148
|
+
|
|
149
|
+
Currently: `return error "Unknown subcommand: tok"` when the token doesn't match any child.
|
|
150
|
+
|
|
151
|
+
Add before that return:
|
|
152
|
+
```typescript
|
|
153
|
+
if ((current.commands ?? []).length > 0) {
|
|
154
|
+
const fb = current.fallbackCommand;
|
|
155
|
+
const fm = current.fallbackMode ?? CliFallbackMode.MissingOnly;
|
|
156
|
+
const canRouteUnknown =
|
|
157
|
+
fb !== undefined &&
|
|
158
|
+
(fm === CliFallbackMode.MissingOrUnknown || fm === CliFallbackMode.UnknownOnly);
|
|
159
|
+
|
|
160
|
+
if (canRouteUnknown) {
|
|
161
|
+
const fbNode = findChild(current.commands ?? [], fb!);
|
|
162
|
+
if (fbNode) {
|
|
163
|
+
// Do NOT advance i — the unrecognized token stays as argv for the fallback
|
|
164
|
+
path.push(fb!);
|
|
165
|
+
current = fbNode;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { kind: ParseKind.Error, ..., errorMsg: `Unknown subcommand: ${tok}` };
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
This mirrors the root-level fallback logic at lines 406–452 exactly. The same `CliFallbackMode` semantics apply at every routing level.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Step 4: Parser — varargs + trailing options ([`src/parse.ts`](src/parse.ts) — `finishLeaf`)
|
|
178
|
+
|
|
179
|
+
### The problem
|
|
180
|
+
|
|
181
|
+
`finishLeaf`'s varargs branch (line ~289–304):
|
|
182
|
+
```typescript
|
|
183
|
+
if (argMax === 0) {
|
|
184
|
+
while (idx < argv.length) {
|
|
185
|
+
args.push(argv[idx]); // ← greedy: swallows --json and all flags
|
|
186
|
+
idx += 1;
|
|
187
|
+
count += 1;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
All remaining tokens (including `--flag` tokens) are consumed as positionals. The trailing option scan at lines 307–325 never runs because `idx === argv.length` after the greedy loop.
|
|
193
|
+
|
|
194
|
+
### The fix
|
|
195
|
+
|
|
196
|
+
Replace the greedy varargs loop with an interleaved loop that recognizes known options:
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
if (argMax === 0) {
|
|
200
|
+
while (idx < argv.length) {
|
|
201
|
+
const tok = argv[idx];
|
|
202
|
+
|
|
203
|
+
// -- separator: force remaining tokens as positionals
|
|
204
|
+
if (!forcePositionals && tok === "--") {
|
|
205
|
+
forcePositionals = true;
|
|
206
|
+
idx++;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Scoped help inside varargs tail — mirrors bounded path at lines 312–314
|
|
211
|
+
if (!forcePositionals && isHelpTok(tok)) {
|
|
212
|
+
return helpResult(path, true);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Possible option token: attempt to consume as a known flag
|
|
216
|
+
if (!forcePositionals && tok.startsWith("-")) {
|
|
217
|
+
// MUST be false — lenient mode swallows unknown flags as positionals silently
|
|
218
|
+
const tailRep = consumeOptions(optionDefs, false, argv, idx, opts);
|
|
219
|
+
if (tailRep.report.err) {
|
|
220
|
+
return errorResult(tailRep.report.err);
|
|
221
|
+
}
|
|
222
|
+
if (tailRep.report.sawDoubleDash) {
|
|
223
|
+
forcePositionals = true;
|
|
224
|
+
}
|
|
225
|
+
if (tailRep.nextIndex > idx) {
|
|
226
|
+
// Consumed one or more options — loop without pushing a positional
|
|
227
|
+
idx = tailRep.nextIndex;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
// consumeOptions stopped without advancing (unknown token in lenient mode
|
|
231
|
+
// would return stoppedOnUnknown, but we pass lenientUnknown=false, so
|
|
232
|
+
// any unknown -flag is already an error above). Fallthrough to error.
|
|
233
|
+
return errorResult(`Unexpected option token: ${tok}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Plain positional token
|
|
237
|
+
args.push(tok);
|
|
238
|
+
idx++;
|
|
239
|
+
count++;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Key rules:**
|
|
245
|
+
- `consumeOptions` is called with `lenientUnknown: false` — unknown flags in varargs tails are still errors, not silently treated as positionals
|
|
246
|
+
- `--` terminates option scanning exactly as before; tokens after it go straight to `args`
|
|
247
|
+
- Bounded positionals are unchanged — their existing trailing option scan at lines 307–325 still runs and is unaffected
|
|
248
|
+
|
|
249
|
+
**Remove `allowsTrailingOptions`** — it becomes dead code once varargs interleaves. Delete the function and its call site.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Step 5: `ctx.positional(name)` ([`src/context.ts`](src/context.ts))
|
|
254
|
+
|
|
255
|
+
### Accessor
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
/**
|
|
259
|
+
* Returns the value(s) for a named positional slot.
|
|
260
|
+
* Varargs slots (argMax === 0) return string[]; single slots return string | undefined.
|
|
261
|
+
*/
|
|
262
|
+
positional(name: string): string | string[] | undefined {
|
|
263
|
+
return this._positionalMap()[name];
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Implementation (`_positionalMap`)
|
|
268
|
+
|
|
269
|
+
Compute lazily and cache. Walk `this.schema` along `this.commandPath` to find the leaf, then map positional definitions to slices of `this.args`:
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
private _posMap: Record<string, string | string[]> | undefined;
|
|
273
|
+
|
|
274
|
+
private _positionalMap(): Record<string, string | string[]> {
|
|
275
|
+
if (this._posMap) return this._posMap;
|
|
276
|
+
|
|
277
|
+
let node: CliCommand = this.schema;
|
|
278
|
+
for (const seg of this.commandPath) {
|
|
279
|
+
const child = (node.commands ?? []).find((c) => c.key === seg);
|
|
280
|
+
if (!child) { this._posMap = {}; return {}; }
|
|
281
|
+
node = child;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const map: Record<string, string | string[]> = {};
|
|
285
|
+
let argIdx = 0;
|
|
286
|
+
for (const p of node.positionals ?? []) {
|
|
287
|
+
const { argMax = 1 } = p;
|
|
288
|
+
if (argMax === 0) {
|
|
289
|
+
map[p.name] = this.args.slice(argIdx);
|
|
290
|
+
argIdx = this.args.length;
|
|
291
|
+
} else {
|
|
292
|
+
const val = this.args[argIdx];
|
|
293
|
+
if (val !== undefined) map[p.name] = val;
|
|
294
|
+
argIdx++;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
this._posMap = map;
|
|
299
|
+
return map;
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**Return type semantics:**
|
|
304
|
+
- Varargs (`argMax === 0`) → always `string[]` (may be empty)
|
|
305
|
+
- Single slot (`argMax === 1`) → `string` if present, `undefined` if optional and absent
|
|
306
|
+
- Always consistent with `ctx.args` — `ctx.positional` is a named view over the same data
|
|
307
|
+
|
|
308
|
+
**No changes to `ctx.args`** — existing handlers continue to work; this is purely additive.
|
|
309
|
+
|
|
310
|
+
### Export
|
|
311
|
+
|
|
312
|
+
Add `positional` to the public method list in JSDoc. No changes to `src/index.ts` needed — `CliContext` is already exported and consumers get the new method automatically.
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Step 6: MCP string→array coercion ([`src/mcp/tools.ts`](src/mcp/tools.ts) — `mcpToolCallToArgv`)
|
|
317
|
+
|
|
318
|
+
### Current behavior (varargs positional)
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
// Current:
|
|
322
|
+
const val = args[pos.name];
|
|
323
|
+
// assumes val is string[] — agents passing "a,b" or "a" get undefined behavior
|
|
324
|
+
argv.push(...(val as string[]));
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Fix
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
const raw = args[pos.name];
|
|
331
|
+
let items: string[];
|
|
332
|
+
if (Array.isArray(raw)) {
|
|
333
|
+
items = raw.map(String);
|
|
334
|
+
} else if (typeof raw === "string") {
|
|
335
|
+
// Coerce: comma-split or single item
|
|
336
|
+
items = raw.includes(",")
|
|
337
|
+
? raw.split(",").map((s) => s.trim()).filter(Boolean)
|
|
338
|
+
: raw.trim() ? [raw.trim()] : [];
|
|
339
|
+
} else {
|
|
340
|
+
items = [];
|
|
341
|
+
}
|
|
342
|
+
argv.push(...items);
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**`inputSchema` stays `{ type: "array", items: { type: "string" } }`** — array is the correct type for well-behaved agents. The coercion is a silent recovery from agents that pass strings anyway. Do not change the schema to `oneOf` — that complicates the JSON Schema unnecessarily.
|
|
346
|
+
|
|
347
|
+
**Single string `"a"` → `["a"]`** (not comma-split). Comma is the delimiter only when present.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## Step 7: Tests ([`src/index.test.ts`](src/index.test.ts))
|
|
352
|
+
|
|
353
|
+
### Nested `fallbackCommand` — unit tests
|
|
354
|
+
|
|
355
|
+
1. Router node with `fallbackCommand` and empty argv → routes to fallback leaf.
|
|
356
|
+
2. Router node with `fallbackCommand` + `MissingOrUnknown` + unknown token → routes to fallback (token stays in argv for fallback leaf).
|
|
357
|
+
3. Router node with `fallbackCommand` + `MissingOnly` + unknown token → error "Unknown subcommand".
|
|
358
|
+
4. `cliValidateRoot` rejects `fallbackCommand` naming a non-existent child on a non-root node.
|
|
359
|
+
5. `cliValidateRoot` accepts `fallbackCommand` on a non-root router node when child exists.
|
|
360
|
+
6. Scoped help: `myapp docs --help` on a router with fallback → shows docs subcommands, not fallback content.
|
|
361
|
+
|
|
362
|
+
### Varargs + trailing options — unit tests
|
|
363
|
+
|
|
364
|
+
7. `cliInvoke(root, ["read", "file.txt", "--json"])` where `read` has varargs positional and `--json` is a known option → `kind: "ok"`, `args: ["file.txt"]`, `opts: { json: "1" }`.
|
|
365
|
+
8. `cliInvoke(root, ["read", "--json", "file.txt"])` → same result (option before varargs).
|
|
366
|
+
9. `cliInvoke(root, ["read", "a.txt", "b.txt", "--json"])` → `args: ["a.txt", "b.txt"]`, `opts: { json: "1" }`.
|
|
367
|
+
10. `cliInvoke(root, ["read", "file.txt", "--", "--json"])` → `args: ["file.txt", "--json"]`, `opts: {}` (`--` forces positional).
|
|
368
|
+
11. `cliInvoke(root, ["read", "--unknown"])` → `kind: "error"`, errorMsg mentions `--unknown`.
|
|
369
|
+
12. `parse(root, ["read", "file.txt", "--help"])` on a varargs leaf → `kind: Help`, `helpPath` contains `"read"` (scoped help, not an error). This guards the `isHelpTok` insertion.
|
|
370
|
+
|
|
371
|
+
**Regression test inversion:** The existing test `"varargs tail does not parse trailing options"` (approximately line 337 in `index.test.ts`) currently *asserts the broken behavior* — `--json` ends up in `args`. Invert it: after Step 4, assert `--json` is in `opts` and absent from `args`. Do this as part of Step 7, not before.
|
|
372
|
+
|
|
373
|
+
### `ctx.positional(name)` — unit tests
|
|
374
|
+
|
|
375
|
+
13. Handler reads `ctx.positional("path")` on single-slot positional — returns the string value.
|
|
376
|
+
14. Handler reads `ctx.positional("files")` on varargs positional — returns `string[]`.
|
|
377
|
+
15. Handler reads `ctx.positional("opt")` on absent optional positional — returns `undefined`.
|
|
378
|
+
16. `ctx.positional("files")` consistent with `ctx.args`: `ctx.positional("files")` deep-equals `ctx.args` when there is one varargs positional.
|
|
379
|
+
|
|
380
|
+
### MCP string→array coercion — unit tests
|
|
381
|
+
|
|
382
|
+
17. `mcpToolCallToArgv` with varargs positional: `args: { files: "a,b" }` → argv includes `"a"`, `"b"` as separate tokens.
|
|
383
|
+
18. `mcpToolCallToArgv` with varargs positional: `args: { files: "a" }` (no comma) → argv includes `"a"` once.
|
|
384
|
+
19. `mcpToolCallToArgv` with varargs positional: `args: { files: ["a","b"] }` (array) → unchanged behavior.
|
|
385
|
+
20. `mcpToolCallToArgv` with varargs positional: `args: { files: "" }` (empty string) → no tokens appended.
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
## Step 8: Docs + changelog
|
|
390
|
+
|
|
391
|
+
### [`README.md`](README.md)
|
|
392
|
+
|
|
393
|
+
- Update `fallbackCommand` / `fallbackMode` table to note they work on any routing node.
|
|
394
|
+
- Add `ctx.positional(name)` to the Reading values section.
|
|
395
|
+
|
|
396
|
+
### [`docs/mcp.md`](docs/mcp.md)
|
|
397
|
+
|
|
398
|
+
- Note MCP string→array coercion under tool arguments: agents may pass a comma-separated string for varargs positionals.
|
|
399
|
+
|
|
400
|
+
### [`CHANGELOG.md`](CHANGELOG.md) — `[Unreleased]` → Added / Fixed
|
|
401
|
+
|
|
402
|
+
**Added:**
|
|
403
|
+
- `fallbackCommand` / `fallbackMode` now work on any routing node, not just the program root
|
|
404
|
+
- `ctx.positional(name)` — named positional lookup; varargs return `string[]`, single slots return `string | undefined`
|
|
405
|
+
- MCP varargs coercion: agents may pass `"a,b"` or `"a"` where `string[]` is expected
|
|
406
|
+
|
|
407
|
+
**Fixed:**
|
|
408
|
+
- Known options (`--flag`) after varargs positionals now parse correctly instead of being consumed as positional tokens
|
|
409
|
+
|
|
410
|
+
### Typegen
|
|
411
|
+
|
|
412
|
+
```
|
|
413
|
+
just typegen
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Verify `index.d.ts` reflects the new `positional` method on `CliContext`. No other new public types in this release.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## Pitfalls (do NOT)
|
|
421
|
+
|
|
422
|
+
- Change `inputSchema` for varargs from `array` to `oneOf` — coercion is silent, schema stays canonical
|
|
423
|
+
- Remove `ctx.args` — additive only; existing handlers must continue to work
|
|
424
|
+
- Apply nested fallback logic before the existing root fallback — the root-level fallback code (lines 406–452 in `parse.ts`) stays as-is; the new code mirrors it in the routing loop body
|
|
425
|
+
- Use `lenientUnknown: true` in the varargs interleaved option scan — unknown flags in varargs tails must still error
|
|
426
|
+
- Break `allowsTrailingOptions` callers before deleting it — verify it has only one call site (line 308) before removing
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## File change summary
|
|
431
|
+
|
|
432
|
+
| File | Action |
|
|
433
|
+
|------|--------|
|
|
434
|
+
| [`src/types.ts`](src/types.ts) | JSDoc updates only |
|
|
435
|
+
| [`src/validate.ts`](src/validate.ts) | Remove root-only fallback restriction; add child-key validation for all routing nodes |
|
|
436
|
+
| [`src/parse.ts`](src/parse.ts) | Nested fallback in routing loop; varargs interleaved option scan; remove `allowsTrailingOptions` |
|
|
437
|
+
| [`src/context.ts`](src/context.ts) | `positional(name)` accessor + `_positionalMap` private helper |
|
|
438
|
+
| [`src/mcp/tools.ts`](src/mcp/tools.ts) | String→array coercion in `mcpToolCallToArgv` |
|
|
439
|
+
| [`src/index.test.ts`](src/index.test.ts) | All new tests |
|
|
440
|
+
| [`README.md`](README.md) | fallback + ctx.positional docs |
|
|
441
|
+
| [`docs/mcp.md`](docs/mcp.md) | MCP coercion note |
|
|
442
|
+
| [`CHANGELOG.md`](CHANGELOG.md) | Unreleased entries |
|
|
443
|
+
| [`index.d.ts`](index.d.ts) | Regenerated via `just typegen` |
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
## Out of scope (explicitly deferred)
|
|
448
|
+
|
|
449
|
+
- `ctx.positional` type overloads by argMax — return type is `string | string[] | undefined` for all slots; narrowing by schema type is possible but requires conditional types and adds complexity without clear benefit
|
|
450
|
+
- `mcpTool.group` / `risk` / `requiresTty` annotations
|
|
451
|
+
- `structuredError` for JSON on exit(1)
|
|
452
|
+
- Streaming tool calls
|
|
453
|
+
- Shared option groups / `extends`
|
|
454
|
+
- Output truncation in MCP results
|
|
455
|
+
- GNU-style option parsing before command routing (options mixed with subcommand tokens at root level)
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.4.2] - 2026-06-19
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`fallbackCommand` / `fallbackMode` on any routing node** — nested routers can define default subcommand routing, not just the program root.
|
|
15
|
+
- **`ctx.positional(name)`** — named positional lookup; varargs return `string[]`, single slots return `string | undefined`.
|
|
16
|
+
- **MCP varargs coercion** — agents may pass `"a,b"` or `"a"` where `string[]` is expected.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **Known options after varargs positionals** — `--flag` tokens after a varargs tail parse as options instead of being consumed as positional arguments.
|
|
21
|
+
|
|
10
22
|
## [1.4.1] - 2026-06-19
|
|
11
23
|
|
|
12
24
|
### Added
|
|
@@ -111,7 +123,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
111
123
|
- Migrate schemas: rename every `children` property to **`commands`**; move positional definitions to **`CliPositional`** objects on `positionals` and strip `positional` / `argMin` / `argMax` from flag definitions under `options` (flags only carry `name`, `description`, `kind`, and optional `shortName`).
|
|
112
124
|
- Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
|
|
113
125
|
|
|
114
|
-
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v1.4.
|
|
126
|
+
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v1.4.2...HEAD
|
|
127
|
+
[1.4.2]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.4.2
|
|
115
128
|
[1.4.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.4.1
|
|
116
129
|
[1.4.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.4.0
|
|
117
130
|
[1.3.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.3.1
|
package/README.md
CHANGED
|
@@ -132,7 +132,7 @@ bun add bun-argsbarg
|
|
|
132
132
|
|
|
133
133
|
## How it works
|
|
134
134
|
|
|
135
|
-
1. Build a **program root** `CliCommand` using pure TypeScript objects: `key` is the app/binary name, `commands` are top-level subcommands, `options` are global flags. The root must not set `handler` or declare `positionals` (validated at startup). Use `fallbackCommand` / `fallbackMode` on
|
|
135
|
+
1. Build a **program root** `CliCommand` using pure TypeScript objects: `key` is the app/binary name, `commands` are top-level subcommands, `options` are global flags. The root must not set `handler` or declare `positionals` (validated at startup). Use `fallbackCommand` / `fallbackMode` on any **routing node** for default subcommand routing (not root-only).
|
|
136
136
|
2. Call `await cliRun(root)` with that root — validates, parses argv, renders help or errors, invokes the leaf handler, and `process.exit`s with status **0** on success, **1** on implicit help or error (explicit `--help` → **0**).
|
|
137
137
|
3. From a handler, `cliErrWithHelp(ctx, "message")` prints a red error line plus contextual help on stderr and exits **1**.
|
|
138
138
|
|
|
@@ -144,7 +144,9 @@ bun add bun-argsbarg
|
|
|
144
144
|
| `MissingOrUnknown` | Default command | Default command (token becomes argv for the default) |
|
|
145
145
|
| `UnknownOnly` | Root help (exit 1) | Default command |
|
|
146
146
|
|
|
147
|
-
With `MissingOrUnknown` / `UnknownOnly`, unrecognized **
|
|
147
|
+
With `MissingOrUnknown` / `UnknownOnly`, unrecognized flags at the **current routing node** stop option consumption and the remainder is passed to the default command.
|
|
148
|
+
|
|
149
|
+
Set `fallbackCommand` / `fallbackMode` on nested routers too — e.g. `docs` with `fallbackCommand: "guide"` routes `myapp docs` to the guide leaf without requiring a root-level default.
|
|
148
150
|
|
|
149
151
|
### Positionals (help labels)
|
|
150
152
|
|
|
@@ -163,6 +165,7 @@ Add `CliPositional` entries to the command’s `positionals` list (separate from
|
|
|
163
165
|
- `ctx.stringOpt("name")` / `ctx.numberOpt("count")` — `string | undefined` / `number | null`.
|
|
164
166
|
- `ctx.typedOpt<T>("custom", parseFn)` — pass a custom parsing function for type-safe option resolution.
|
|
165
167
|
- `ctx.args` — positional words in order as `string[]`.
|
|
168
|
+
- `ctx.positional("name")` — named positional lookup; varargs slots return `string[]`, single slots return `string | undefined`.
|
|
166
169
|
- `ctx.schema` — merged program root (`CliCommand`) for contextual help.
|
|
167
170
|
|
|
168
171
|
|
package/docs/mcp.md
CHANGED
|
@@ -138,7 +138,7 @@ mcpTool: {
|
|
|
138
138
|
Each tool’s `inputSchema` is a JSON Schema object built from your CLI definition:
|
|
139
139
|
|
|
140
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`.
|
|
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.
|
|
142
142
|
|
|
143
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"`).
|
|
144
144
|
|
package/index.d.ts
CHANGED
|
@@ -23,6 +23,10 @@ export declare class CliContext {
|
|
|
23
23
|
* This is the TypeScript-native advantage over the Swift version.
|
|
24
24
|
*/
|
|
25
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;
|
|
26
30
|
}
|
|
27
31
|
/**
|
|
28
32
|
* How a leaf handler was dispatched.
|
|
@@ -42,21 +46,20 @@ export declare enum CliOptionKind {
|
|
|
42
46
|
Enum = "enum"
|
|
43
47
|
}
|
|
44
48
|
/**
|
|
45
|
-
* When fallbackCommand is used for missing or unknown
|
|
46
|
-
* 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.
|
|
47
50
|
*/
|
|
48
51
|
export declare enum CliFallbackMode {
|
|
49
52
|
/**
|
|
50
|
-
* If argv has no
|
|
53
|
+
* If argv has no next subcommand, route to `fallbackCommand`; if the token is unknown, error.
|
|
51
54
|
*/
|
|
52
55
|
MissingOnly = "missingOnly",
|
|
53
56
|
/**
|
|
54
|
-
* If argv has no
|
|
57
|
+
* If argv has no next subcommand or the token is not a known child, route to `fallbackCommand`.
|
|
55
58
|
*/
|
|
56
59
|
MissingOrUnknown = "missingOrUnknown",
|
|
57
60
|
/**
|
|
58
|
-
* If the
|
|
59
|
-
* 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).
|
|
60
63
|
*/
|
|
61
64
|
UnknownOnly = "unknownOnly"
|
|
62
65
|
}
|
|
@@ -192,16 +195,16 @@ export type CliCommand = (CliCommandBase & {
|
|
|
192
195
|
positionals?: CliPositional[];
|
|
193
196
|
/** Nested subcommands (empty for leaf commands). */
|
|
194
197
|
commands?: never;
|
|
195
|
-
/** Default
|
|
198
|
+
/** Default subcommand (routing commands only). */
|
|
196
199
|
fallbackCommand?: never;
|
|
197
|
-
/** How fallbackCommand is applied (routing commands only). */
|
|
200
|
+
/** How fallbackCommand is applied at this routing node (routing commands only). */
|
|
198
201
|
fallbackMode?: never;
|
|
199
202
|
}) | (CliCommandBase & {
|
|
200
203
|
/** Nested subcommands. */
|
|
201
204
|
commands: CliCommand[];
|
|
202
|
-
/** Default
|
|
205
|
+
/** Default subcommand when argv omits a command or uses an unknown token at this routing node. */
|
|
203
206
|
fallbackCommand?: string;
|
|
204
|
-
/** How fallbackCommand is applied. */
|
|
207
|
+
/** How fallbackCommand is applied at this routing node (not root-only). */
|
|
205
208
|
fallbackMode?: CliFallbackMode;
|
|
206
209
|
/** Handler function (leaf commands only). */
|
|
207
210
|
handler?: never;
|
package/package.json
CHANGED
package/src/context.ts
CHANGED
|
@@ -68,4 +68,42 @@ export class CliContext {
|
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
70
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
|
+
}
|
|
71
109
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -334,7 +334,7 @@ test("trailing options include parent-scoped flags", () => {
|
|
|
334
334
|
expect(pr.opts["json"]).toBe("1");
|
|
335
335
|
});
|
|
336
336
|
|
|
337
|
-
test("varargs tail
|
|
337
|
+
test("varargs tail parses trailing options", () => {
|
|
338
338
|
const root: CliCommand = {
|
|
339
339
|
key: "app",
|
|
340
340
|
description: "",
|
|
@@ -365,8 +365,8 @@ test("varargs tail does not parse trailing options", () => {
|
|
|
365
365
|
cliValidateRoot(root);
|
|
366
366
|
const pr = postParseValidate(root, parse(root, ["x", "./file", "--json"]));
|
|
367
367
|
expect(pr.kind).toBe(ParseKind.Ok);
|
|
368
|
-
expect(pr.args).toEqual(["./file"
|
|
369
|
-
expect(pr.opts["json"]).
|
|
368
|
+
expect(pr.args).toEqual(["./file"]);
|
|
369
|
+
expect(pr.opts["json"]).toBe("1");
|
|
370
370
|
});
|
|
371
371
|
|
|
372
372
|
test("stops parsing options at --", () => {
|
|
@@ -1330,4 +1330,308 @@ test("MCP envFile loads vars for tool handlers", async () => {
|
|
|
1330
1330
|
const res = responses.get(15) as { result: { isError: boolean; content: { text: string }[] } };
|
|
1331
1331
|
expect(res.result.isError).toBe(false);
|
|
1332
1332
|
expect(res.result.content[0]!.text.trim()).toBe("file-value");
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
// ── v1.3 parser ergonomics ────────────────────────────────────────────────────
|
|
1336
|
+
|
|
1337
|
+
function varargsReadFixture(): CliCommand {
|
|
1338
|
+
return {
|
|
1339
|
+
key: "app",
|
|
1340
|
+
description: "",
|
|
1341
|
+
commands: [
|
|
1342
|
+
{
|
|
1343
|
+
key: "read",
|
|
1344
|
+
description: "Read files.",
|
|
1345
|
+
options: [
|
|
1346
|
+
{
|
|
1347
|
+
name: "json",
|
|
1348
|
+
description: "",
|
|
1349
|
+
kind: CliOptionKind.Presence,
|
|
1350
|
+
},
|
|
1351
|
+
],
|
|
1352
|
+
positionals: [
|
|
1353
|
+
{
|
|
1354
|
+
name: "files",
|
|
1355
|
+
description: "",
|
|
1356
|
+
kind: CliOptionKind.String,
|
|
1357
|
+
argMin: 0,
|
|
1358
|
+
argMax: 0,
|
|
1359
|
+
},
|
|
1360
|
+
],
|
|
1361
|
+
handler: () => {},
|
|
1362
|
+
},
|
|
1363
|
+
],
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function nestedDocsFallbackFixture(): CliCommand {
|
|
1368
|
+
return {
|
|
1369
|
+
key: "app",
|
|
1370
|
+
description: "",
|
|
1371
|
+
commands: [
|
|
1372
|
+
{
|
|
1373
|
+
key: "docs",
|
|
1374
|
+
description: "Documentation commands.",
|
|
1375
|
+
fallbackCommand: "guide",
|
|
1376
|
+
fallbackMode: CliFallbackMode.MissingOnly,
|
|
1377
|
+
commands: [
|
|
1378
|
+
{
|
|
1379
|
+
key: "guide",
|
|
1380
|
+
description: "User guide.",
|
|
1381
|
+
handler: () => {},
|
|
1382
|
+
},
|
|
1383
|
+
{
|
|
1384
|
+
key: "api",
|
|
1385
|
+
description: "API reference.",
|
|
1386
|
+
handler: () => {},
|
|
1387
|
+
},
|
|
1388
|
+
],
|
|
1389
|
+
},
|
|
1390
|
+
],
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
test("nested fallback routes to default when argv exhausted at router", () => {
|
|
1395
|
+
const root = nestedDocsFallbackFixture();
|
|
1396
|
+
cliValidateRoot(root);
|
|
1397
|
+
const pr = postParseValidate(root, parse(root, ["docs"]));
|
|
1398
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
1399
|
+
expect(pr.path).toEqual(["docs", "guide"]);
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
test("nested fallback MissingOrUnknown routes unknown token to default", () => {
|
|
1403
|
+
const root: CliCommand = {
|
|
1404
|
+
key: "app",
|
|
1405
|
+
description: "",
|
|
1406
|
+
commands: [
|
|
1407
|
+
{
|
|
1408
|
+
key: "docs",
|
|
1409
|
+
description: "Documentation commands.",
|
|
1410
|
+
fallbackCommand: "guide",
|
|
1411
|
+
fallbackMode: CliFallbackMode.MissingOrUnknown,
|
|
1412
|
+
commands: [
|
|
1413
|
+
{
|
|
1414
|
+
key: "guide",
|
|
1415
|
+
description: "User guide.",
|
|
1416
|
+
positionals: [
|
|
1417
|
+
{
|
|
1418
|
+
name: "topic",
|
|
1419
|
+
description: "",
|
|
1420
|
+
kind: CliOptionKind.String,
|
|
1421
|
+
argMin: 0,
|
|
1422
|
+
argMax: 0,
|
|
1423
|
+
},
|
|
1424
|
+
],
|
|
1425
|
+
handler: () => {},
|
|
1426
|
+
},
|
|
1427
|
+
{
|
|
1428
|
+
key: "api",
|
|
1429
|
+
description: "API reference.",
|
|
1430
|
+
handler: () => {},
|
|
1431
|
+
},
|
|
1432
|
+
],
|
|
1433
|
+
},
|
|
1434
|
+
],
|
|
1435
|
+
};
|
|
1436
|
+
cliValidateRoot(root);
|
|
1437
|
+
const pr = postParseValidate(root, parse(root, ["docs", "extra-topic"]));
|
|
1438
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
1439
|
+
expect(pr.path).toEqual(["docs", "guide"]);
|
|
1440
|
+
expect(pr.args).toEqual(["extra-topic"]);
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
test("nested fallback MissingOnly errors on unknown subcommand", () => {
|
|
1444
|
+
const root = nestedDocsFallbackFixture();
|
|
1445
|
+
cliValidateRoot(root);
|
|
1446
|
+
const pr = parse(root, ["docs", "nope"]);
|
|
1447
|
+
expect(pr.kind).toBe(ParseKind.Error);
|
|
1448
|
+
expect(pr.errorMsg).toContain("Unknown subcommand");
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
test("cliValidateRoot rejects invalid nested fallbackCommand", () => {
|
|
1452
|
+
const root: CliCommand = {
|
|
1453
|
+
key: "app",
|
|
1454
|
+
description: "",
|
|
1455
|
+
commands: [
|
|
1456
|
+
{
|
|
1457
|
+
key: "docs",
|
|
1458
|
+
description: "",
|
|
1459
|
+
fallbackCommand: "missing",
|
|
1460
|
+
commands: [
|
|
1461
|
+
{
|
|
1462
|
+
key: "guide",
|
|
1463
|
+
description: "",
|
|
1464
|
+
handler: () => {},
|
|
1465
|
+
},
|
|
1466
|
+
],
|
|
1467
|
+
},
|
|
1468
|
+
],
|
|
1469
|
+
};
|
|
1470
|
+
expect(() => cliValidateRoot(root)).toThrow(/fallbackCommand 'missing' is not a child of 'docs'/);
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
test("cliValidateRoot accepts nested fallbackCommand when child exists", () => {
|
|
1474
|
+
const root = nestedDocsFallbackFixture();
|
|
1475
|
+
expect(() => cliValidateRoot(root)).not.toThrow();
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
test("nested router scoped help does not route to fallback", () => {
|
|
1479
|
+
const root = nestedDocsFallbackFixture();
|
|
1480
|
+
cliValidateRoot(root);
|
|
1481
|
+
const pr = parse(root, ["docs", "--help"]);
|
|
1482
|
+
expect(pr.kind).toBe(ParseKind.Help);
|
|
1483
|
+
expect(pr.helpPath).toEqual(["docs"]);
|
|
1484
|
+
expect(pr.helpExplicit).toBe(true);
|
|
1485
|
+
const help = cliHelpRender(root, pr.helpPath, false);
|
|
1486
|
+
expect(help).toContain("api");
|
|
1487
|
+
expect(help).toContain("guide");
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
test("varargs trailing option after positionals via cliInvoke", async () => {
|
|
1491
|
+
const root = varargsReadFixture();
|
|
1492
|
+
cliValidateRoot(root);
|
|
1493
|
+
const pr = postParseValidate(root, parse(root, ["read", "file.txt", "--json"]));
|
|
1494
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
1495
|
+
expect(pr.args).toEqual(["file.txt"]);
|
|
1496
|
+
expect(pr.opts["json"]).toBe("1");
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
test("varargs option before positionals", () => {
|
|
1500
|
+
const root = varargsReadFixture();
|
|
1501
|
+
cliValidateRoot(root);
|
|
1502
|
+
const pr = postParseValidate(root, parse(root, ["read", "--json", "file.txt"]));
|
|
1503
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
1504
|
+
expect(pr.args).toEqual(["file.txt"]);
|
|
1505
|
+
expect(pr.opts["json"]).toBe("1");
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
test("varargs multiple files then trailing option", () => {
|
|
1509
|
+
const root = varargsReadFixture();
|
|
1510
|
+
cliValidateRoot(root);
|
|
1511
|
+
const pr = postParseValidate(root, parse(root, ["read", "a.txt", "b.txt", "--json"]));
|
|
1512
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
1513
|
+
expect(pr.args).toEqual(["a.txt", "b.txt"]);
|
|
1514
|
+
expect(pr.opts["json"]).toBe("1");
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
test("varargs double dash forces positional", () => {
|
|
1518
|
+
const root = varargsReadFixture();
|
|
1519
|
+
cliValidateRoot(root);
|
|
1520
|
+
const pr = postParseValidate(root, parse(root, ["read", "file.txt", "--", "--json"]));
|
|
1521
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
1522
|
+
expect(pr.args).toEqual(["file.txt", "--json"]);
|
|
1523
|
+
expect(pr.opts["json"]).toBeUndefined();
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
test("varargs unknown flag errors", async () => {
|
|
1527
|
+
const root = varargsReadFixture();
|
|
1528
|
+
cliValidateRoot(root);
|
|
1529
|
+
const result = await cliInvoke(root, ["read", "--unknown"]);
|
|
1530
|
+
expect(result.kind).toBe("error");
|
|
1531
|
+
expect(result.stderr).toContain("--unknown");
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
test("varargs scoped help in tail", () => {
|
|
1535
|
+
const root = varargsReadFixture();
|
|
1536
|
+
cliValidateRoot(root);
|
|
1537
|
+
const pr = parse(root, ["read", "file.txt", "--help"]);
|
|
1538
|
+
expect(pr.kind).toBe(ParseKind.Help);
|
|
1539
|
+
expect(pr.helpPath).toContain("read");
|
|
1540
|
+
expect(pr.helpExplicit).toBe(true);
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
test("ctx.positional returns single slot value", async () => {
|
|
1544
|
+
const root: CliCommand = {
|
|
1545
|
+
key: "app",
|
|
1546
|
+
description: "",
|
|
1547
|
+
commands: [
|
|
1548
|
+
{
|
|
1549
|
+
key: "x",
|
|
1550
|
+
description: "",
|
|
1551
|
+
positionals: [{ name: "path", description: "", kind: CliOptionKind.String }],
|
|
1552
|
+
handler: (ctx) => {
|
|
1553
|
+
captured = ctx.positional("path");
|
|
1554
|
+
},
|
|
1555
|
+
},
|
|
1556
|
+
],
|
|
1557
|
+
};
|
|
1558
|
+
let captured: string | string[] | undefined;
|
|
1559
|
+
cliValidateRoot(root);
|
|
1560
|
+
await cliInvoke(root, ["x", "./file"]);
|
|
1561
|
+
expect(captured).toBe("./file");
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
test("ctx.positional returns varargs array", async () => {
|
|
1565
|
+
const root = varargsReadFixture();
|
|
1566
|
+
let captured: string | string[] | undefined;
|
|
1567
|
+
root.commands![0]!.handler = (ctx) => {
|
|
1568
|
+
captured = ctx.positional("files");
|
|
1569
|
+
};
|
|
1570
|
+
cliValidateRoot(root);
|
|
1571
|
+
await cliInvoke(root, ["read", "a.txt", "b.txt"]);
|
|
1572
|
+
expect(captured).toEqual(["a.txt", "b.txt"]);
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
test("ctx.positional returns undefined for absent optional slot", async () => {
|
|
1576
|
+
const root: CliCommand = {
|
|
1577
|
+
key: "app",
|
|
1578
|
+
description: "",
|
|
1579
|
+
commands: [
|
|
1580
|
+
{
|
|
1581
|
+
key: "x",
|
|
1582
|
+
description: "",
|
|
1583
|
+
positionals: [
|
|
1584
|
+
{ name: "opt", description: "", kind: CliOptionKind.String, argMin: 0, argMax: 1 },
|
|
1585
|
+
],
|
|
1586
|
+
handler: (ctx) => {
|
|
1587
|
+
captured = ctx.positional("opt");
|
|
1588
|
+
},
|
|
1589
|
+
},
|
|
1590
|
+
],
|
|
1591
|
+
};
|
|
1592
|
+
let captured: string | string[] | undefined;
|
|
1593
|
+
cliValidateRoot(root);
|
|
1594
|
+
await cliInvoke(root, ["x"]);
|
|
1595
|
+
expect(captured).toBeUndefined();
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
test("ctx.positional varargs matches ctx.args", async () => {
|
|
1599
|
+
const root = varargsReadFixture();
|
|
1600
|
+
let positional: string | string[] | undefined;
|
|
1601
|
+
let args: string[] = [];
|
|
1602
|
+
root.commands![0]!.handler = (ctx) => {
|
|
1603
|
+
positional = ctx.positional("files");
|
|
1604
|
+
args = ctx.args;
|
|
1605
|
+
};
|
|
1606
|
+
cliValidateRoot(root);
|
|
1607
|
+
await cliInvoke(root, ["read", "a.txt", "b.txt"]);
|
|
1608
|
+
expect(positional).toEqual(args);
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
test("mcpToolCallToArgv coerces comma-separated string for varargs", () => {
|
|
1612
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
1613
|
+
const read = tools.find((t) => t.name === "read")!;
|
|
1614
|
+
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "a,b" });
|
|
1615
|
+
expect(argv).toEqual(["read", "a", "b"]);
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
test("mcpToolCallToArgv coerces single string for varargs", () => {
|
|
1619
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
1620
|
+
const read = tools.find((t) => t.name === "read")!;
|
|
1621
|
+
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "a" });
|
|
1622
|
+
expect(argv).toEqual(["read", "a"]);
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
test("mcpToolCallToArgv array varargs unchanged", () => {
|
|
1626
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
1627
|
+
const read = tools.find((t) => t.name === "read")!;
|
|
1628
|
+
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: ["a", "b"] });
|
|
1629
|
+
expect(argv).toEqual(["read", "a", "b"]);
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
test("mcpToolCallToArgv empty string varargs appends nothing", () => {
|
|
1633
|
+
const tools = collectMcpTools(nestedMcpFixture);
|
|
1634
|
+
const read = tools.find((t) => t.name === "read")!;
|
|
1635
|
+
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "" });
|
|
1636
|
+
expect(argv).toEqual(["read"]);
|
|
1333
1637
|
});
|
package/src/mcp/tools.ts
CHANGED
|
@@ -232,12 +232,20 @@ export function mcpToolCallToArgv(
|
|
|
232
232
|
const { argMin = 1, argMax = 1 } = p;
|
|
233
233
|
|
|
234
234
|
if (argMax === 0) {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
235
|
+
const raw = args[p.name];
|
|
236
|
+
let items: string[];
|
|
237
|
+
if (Array.isArray(raw)) {
|
|
238
|
+
items = raw.map(String);
|
|
239
|
+
} else if (typeof raw === "string") {
|
|
240
|
+
items = raw.includes(",")
|
|
241
|
+
? raw.split(",").map((s) => s.trim()).filter(Boolean)
|
|
242
|
+
: raw.trim()
|
|
243
|
+
? [raw.trim()]
|
|
244
|
+
: [];
|
|
245
|
+
} else {
|
|
246
|
+
items = [];
|
|
240
247
|
}
|
|
248
|
+
argv.push(...items);
|
|
241
249
|
continue;
|
|
242
250
|
}
|
|
243
251
|
|
package/src/parse.ts
CHANGED
|
@@ -231,11 +231,6 @@ export function collectOptionDefs(root: CliCommand, path: string[]): CliOption[]
|
|
|
231
231
|
return defs;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
/** True when every positional slot has bounded arity (no `argMax: 0` varargs tail). */
|
|
235
|
-
function allowsTrailingOptions(positionals: CliCommand["positionals"]): boolean {
|
|
236
|
-
return (positionals ?? []).every((p) => (p.argMax ?? 1) !== 0);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
234
|
/** Fills `args` for a leaf from `startIdx` according to `node.positionals`. */
|
|
240
235
|
function finishLeaf(
|
|
241
236
|
node: CliCommand,
|
|
@@ -244,7 +239,7 @@ function finishLeaf(
|
|
|
244
239
|
path: string[],
|
|
245
240
|
opts: Record<string, string>,
|
|
246
241
|
optionDefs: CliOption[],
|
|
247
|
-
|
|
242
|
+
forcePositionalsIn: boolean,
|
|
248
243
|
): ParseResult {
|
|
249
244
|
/** Builds a parse error for positional consumption failures. */
|
|
250
245
|
function errorResult(msg: string): ParseResult {
|
|
@@ -263,6 +258,7 @@ function finishLeaf(
|
|
|
263
258
|
|
|
264
259
|
let idx = startIdx;
|
|
265
260
|
const args: string[] = [];
|
|
261
|
+
let forcePositionals = forcePositionalsIn;
|
|
266
262
|
|
|
267
263
|
for (const p of node.positionals ?? []) {
|
|
268
264
|
const { argMin = 1, argMax = 1 } = p;
|
|
@@ -288,9 +284,37 @@ function finishLeaf(
|
|
|
288
284
|
let count = 0;
|
|
289
285
|
if (argMax === 0) {
|
|
290
286
|
while (idx < argv.length) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
287
|
+
const tok = argv[idx];
|
|
288
|
+
|
|
289
|
+
if (!forcePositionals && tok === "--") {
|
|
290
|
+
forcePositionals = true;
|
|
291
|
+
idx++;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!forcePositionals && isHelpTok(tok)) {
|
|
296
|
+
return helpResult(path, true);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!forcePositionals && tok.startsWith("-")) {
|
|
300
|
+
// MUST be false — lenient mode swallows unknown flags as positionals silently
|
|
301
|
+
const tailRep = consumeOptions(optionDefs, false, argv, idx, opts);
|
|
302
|
+
if (tailRep.report.err) {
|
|
303
|
+
return errorResult(tailRep.report.err);
|
|
304
|
+
}
|
|
305
|
+
if (tailRep.report.sawDoubleDash) {
|
|
306
|
+
forcePositionals = true;
|
|
307
|
+
}
|
|
308
|
+
if (tailRep.nextIndex > idx) {
|
|
309
|
+
idx = tailRep.nextIndex;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
return errorResult(`Unexpected option token: ${tok}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
args.push(tok);
|
|
316
|
+
idx++;
|
|
317
|
+
count++;
|
|
294
318
|
}
|
|
295
319
|
} else {
|
|
296
320
|
while (count < argMax && idx < argv.length) {
|
|
@@ -305,7 +329,7 @@ function finishLeaf(
|
|
|
305
329
|
}
|
|
306
330
|
|
|
307
331
|
if (idx < argv.length) {
|
|
308
|
-
if (forcePositionals
|
|
332
|
+
if (forcePositionals) {
|
|
309
333
|
return errorResult("Unexpected extra arguments");
|
|
310
334
|
}
|
|
311
335
|
|
|
@@ -483,6 +507,19 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
|
|
|
483
507
|
|
|
484
508
|
if (i >= argv.length) {
|
|
485
509
|
if ((current.commands ?? []).length > 0) {
|
|
510
|
+
const fb = current.fallbackCommand;
|
|
511
|
+
const fm = current.fallbackMode ?? CliFallbackMode.MissingOnly;
|
|
512
|
+
if (
|
|
513
|
+
fb !== undefined &&
|
|
514
|
+
(fm === CliFallbackMode.MissingOnly || fm === CliFallbackMode.MissingOrUnknown)
|
|
515
|
+
) {
|
|
516
|
+
const fbNode = findChild(current.commands ?? [], fb);
|
|
517
|
+
if (fbNode) {
|
|
518
|
+
path.push(fb);
|
|
519
|
+
current = fbNode;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
486
523
|
return helpResult(path, false);
|
|
487
524
|
}
|
|
488
525
|
return finishLeaf(current, i, argv, path, opts, collectOptionDefs(root, path), forcePositionals);
|
|
@@ -513,6 +550,21 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
|
|
|
513
550
|
}
|
|
514
551
|
|
|
515
552
|
if ((current.commands ?? []).length > 0) {
|
|
553
|
+
const fb = current.fallbackCommand;
|
|
554
|
+
const fm = current.fallbackMode ?? CliFallbackMode.MissingOnly;
|
|
555
|
+
const canRouteUnknown =
|
|
556
|
+
fb !== undefined &&
|
|
557
|
+
(fm === CliFallbackMode.MissingOrUnknown || fm === CliFallbackMode.UnknownOnly);
|
|
558
|
+
|
|
559
|
+
if (canRouteUnknown) {
|
|
560
|
+
const fbNode = findChild(current.commands ?? [], fb!);
|
|
561
|
+
if (fbNode) {
|
|
562
|
+
path.push(fb!);
|
|
563
|
+
current = fbNode;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
516
568
|
return {
|
|
517
569
|
kind: ParseKind.Error,
|
|
518
570
|
path,
|
package/src/types.ts
CHANGED
|
@@ -28,21 +28,20 @@ export enum CliOptionKind {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* When fallbackCommand is used for missing or unknown
|
|
32
|
-
* Only the program root may set a non-default mode or a non-nil fallbackCommand.
|
|
31
|
+
* When `fallbackCommand` is used for missing or unknown subcommand tokens at a routing node.
|
|
33
32
|
*/
|
|
34
33
|
export enum CliFallbackMode {
|
|
35
34
|
/**
|
|
36
|
-
* If argv has no
|
|
35
|
+
* If argv has no next subcommand, route to `fallbackCommand`; if the token is unknown, error.
|
|
37
36
|
*/
|
|
38
37
|
MissingOnly = "missingOnly",
|
|
39
38
|
/**
|
|
40
|
-
* If argv has no
|
|
39
|
+
* If argv has no next subcommand or the token is not a known child, route to `fallbackCommand`.
|
|
41
40
|
*/
|
|
42
41
|
MissingOrUnknown = "missingOrUnknown",
|
|
43
42
|
/**
|
|
44
|
-
* If the
|
|
45
|
-
* When the
|
|
43
|
+
* If the next token is present but not a known child, route to `fallbackCommand`.
|
|
44
|
+
* When the subcommand token is missing (exhausted argv), do not use fallback (implicit scoped help).
|
|
46
45
|
*/
|
|
47
46
|
UnknownOnly = "unknownOnly",
|
|
48
47
|
}
|
|
@@ -186,17 +185,17 @@ export type CliCommand =
|
|
|
186
185
|
positionals?: CliPositional[];
|
|
187
186
|
/** Nested subcommands (empty for leaf commands). */
|
|
188
187
|
commands?: never;
|
|
189
|
-
/** Default
|
|
188
|
+
/** Default subcommand (routing commands only). */
|
|
190
189
|
fallbackCommand?: never;
|
|
191
|
-
/** How fallbackCommand is applied (routing commands only). */
|
|
190
|
+
/** How fallbackCommand is applied at this routing node (routing commands only). */
|
|
192
191
|
fallbackMode?: never;
|
|
193
192
|
})
|
|
194
193
|
| (CliCommandBase & {
|
|
195
194
|
/** Nested subcommands. */
|
|
196
195
|
commands: CliCommand[];
|
|
197
|
-
/** Default
|
|
196
|
+
/** Default subcommand when argv omits a command or uses an unknown token at this routing node. */
|
|
198
197
|
fallbackCommand?: string;
|
|
199
|
-
/** How fallbackCommand is applied. */
|
|
198
|
+
/** How fallbackCommand is applied at this routing node (not root-only). */
|
|
200
199
|
fallbackMode?: CliFallbackMode;
|
|
201
200
|
/** Handler function (leaf commands only). */
|
|
202
201
|
handler?: never;
|
package/src/validate.ts
CHANGED
|
@@ -35,22 +35,6 @@ export function cliValidateRoot(root: CliCommand): void {
|
|
|
35
35
|
|
|
36
36
|
/** Recursively validates a command node: handlers vs children, options, and positionals. */
|
|
37
37
|
function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
|
|
38
|
-
// Fallback only on root
|
|
39
|
-
if (!isRoot && cmd.fallbackCommand !== undefined) {
|
|
40
|
-
throw new CliSchemaValidationError(
|
|
41
|
-
"Fallback is only supported on the program root (not on " + cmd.key + ")",
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
if (
|
|
45
|
-
!isRoot &&
|
|
46
|
-
cmd.fallbackMode !== undefined &&
|
|
47
|
-
cmd.fallbackMode !== CliFallbackMode.MissingOnly
|
|
48
|
-
) {
|
|
49
|
-
throw new CliSchemaValidationError(
|
|
50
|
-
"fallbackMode may only be set on the program root (not on " + cmd.key + ")",
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
38
|
if (!isRoot && cmd.mcpServer !== undefined) {
|
|
55
39
|
throw new CliSchemaValidationError(
|
|
56
40
|
"mcpServer is only supported on the program root (not on " + cmd.key + ")",
|
|
@@ -89,6 +73,22 @@ function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
|
|
|
89
73
|
seenNames.add(child.key);
|
|
90
74
|
}
|
|
91
75
|
|
|
76
|
+
if (cmd.fallbackMode !== undefined && cmd.fallbackCommand === undefined) {
|
|
77
|
+
throw new CliSchemaValidationError(
|
|
78
|
+
`fallbackMode requires fallbackCommand on '${cmd.key}'`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (cmd.fallbackCommand !== undefined) {
|
|
83
|
+
const children = cmd.commands ?? [];
|
|
84
|
+
const valid = children.find((c) => c.key === cmd.fallbackCommand);
|
|
85
|
+
if (!valid) {
|
|
86
|
+
throw new CliSchemaValidationError(
|
|
87
|
+
`fallbackCommand '${cmd.fallbackCommand}' is not a child of '${cmd.key}'`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
92
|
// Validate options (short name uniqueness, reserved -h, required presence)
|
|
93
93
|
const seenShorts = new Set<string>();
|
|
94
94
|
for (const opt of cmd.options ?? []) {
|