argsbarg 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/copilot-instructions.md.md +8 -0
- package/.github/pull_request_template.md +13 -0
- package/.private/scratch.md +1 -0
- package/CHANGELOG.md +16 -1
- package/CLAUDE.md +8 -0
- package/README.md +2 -0
- package/examples/nested.ts +12 -1
- package/package.json +1 -1
- package/src/index.test.ts +213 -0
- package/src/parse.ts +76 -6
- package/src/runtime.ts +8 -2
- package/src/schema.ts +77 -0
- package/src/validate.ts +6 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
## Summary
|
|
2
|
+
|
|
3
|
+
<!-- What does this PR change and why? -->
|
|
4
|
+
|
|
5
|
+
## Changelog
|
|
6
|
+
|
|
7
|
+
**Every PR must update `CHANGELOG.md` under `## [Unreleased]`**: use `### Added`, `### Changed`, `### Fixed`, or `### Removed` as appropriate; one idea per bullet.
|
|
8
|
+
|
|
9
|
+
<!-- If you claimed no-op above, briefly say why no changelog entry is warranted. -->
|
|
10
|
+
|
|
11
|
+
## Testing
|
|
12
|
+
|
|
13
|
+
<!-- How did you verify this (local build, tests, manual run, etc.)? -->
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- [ ] --schema feature for ai agents
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.3.0] - 2026-06-18
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`--schema`** — prints the full CLI tree as JSON to stdout (exit 0). Handlers are omitted; the injected `completion` subtree is excluded. Option name `schema` is reserved.
|
|
15
|
+
|
|
16
|
+
## [1.2.1] - 2026-06-18
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Trailing options** — when a leaf command has only bounded positionals (`argMax !== 0`), options may appear after positional arguments (e.g. `cmd ./file --verbose`). Commands with a varargs tail (`argMax: 0`) keep the previous behavior.
|
|
21
|
+
- **`examples/nested.ts`** — `stat` accepts `--json`; `stat owner lookup` prints JSON when the flag is set.
|
|
22
|
+
|
|
10
23
|
## [1.2.0] - 2026-04-24
|
|
11
24
|
|
|
12
25
|
### Added
|
|
@@ -64,7 +77,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
64
77
|
- 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`).
|
|
65
78
|
- Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
|
|
66
79
|
|
|
67
|
-
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v1.
|
|
80
|
+
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v1.3.0...HEAD
|
|
81
|
+
[1.3.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.3.0
|
|
82
|
+
[1.2.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.2.1
|
|
68
83
|
[1.2.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.2.0
|
|
69
84
|
[1.1.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.1.1
|
|
70
85
|
[1.1.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.1.0
|
package/CLAUDE.md
ADDED
package/README.md
CHANGED
|
@@ -88,9 +88,11 @@ Everything you need for a first-class CLI:
|
|
|
88
88
|
Every app gets:
|
|
89
89
|
|
|
90
90
|
- `-h` / `--help` at any routing depth (scoped help).
|
|
91
|
+
- **`--schema`** at the program root — print the full command tree as JSON (for tooling and agents).
|
|
91
92
|
- **`completion bash` / `completion zsh`** — print shell completion scripts to stdout (injected by `cliRun`).
|
|
92
93
|
|
|
93
94
|
Do not declare a top-level command named **`completion`** — it is reserved for this built-in.
|
|
95
|
+
Do not declare an option named **`schema`** — it is reserved for `--schema`.
|
|
94
96
|
|
|
95
97
|
|
|
96
98
|
### Shell completions
|
package/examples/nested.ts
CHANGED
|
@@ -16,6 +16,13 @@ const cli: CliCommand = {
|
|
|
16
16
|
{
|
|
17
17
|
key: "stat",
|
|
18
18
|
description: "File metadata.",
|
|
19
|
+
options: [
|
|
20
|
+
{
|
|
21
|
+
name: "json",
|
|
22
|
+
description: "Emit handler output as JSON.",
|
|
23
|
+
kind: CliOptionKind.Presence,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
19
26
|
commands: [
|
|
20
27
|
{
|
|
21
28
|
key: "owner",
|
|
@@ -46,7 +53,11 @@ const cli: CliCommand = {
|
|
|
46
53
|
console.error("Missing path.");
|
|
47
54
|
process.exit(1);
|
|
48
55
|
}
|
|
49
|
-
|
|
56
|
+
if (ctx.hasFlag("json")) {
|
|
57
|
+
console.log(JSON.stringify({ user, path }));
|
|
58
|
+
} else {
|
|
59
|
+
console.log(`lookup user=${user} path=${path}`);
|
|
60
|
+
}
|
|
50
61
|
},
|
|
51
62
|
},
|
|
52
63
|
],
|
package/package.json
CHANGED
package/src/index.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ shell output regressions.
|
|
|
10
10
|
import { completionBashScript, completionZshScript } from "./completion.ts";
|
|
11
11
|
import { CliCommand, CliFallbackMode, CliOptionKind } from "./index.ts";
|
|
12
12
|
import { ParseKind, parse, postParseValidate } from "./parse.ts";
|
|
13
|
+
import { cliSchemaJson } from "./schema.ts";
|
|
13
14
|
import { cliValidateRoot } from "./validate.ts";
|
|
14
15
|
import { expect, test } from "bun:test";
|
|
15
16
|
import { $ } from "bun";
|
|
@@ -238,6 +239,123 @@ test("completion scripts keep dotted app names in registration names", () => {
|
|
|
238
239
|
expect(zsh).toContain("compdef _minimal_ts minimal.ts");
|
|
239
240
|
});
|
|
240
241
|
|
|
242
|
+
test("trailing options after bounded positionals", () => {
|
|
243
|
+
const root: CliCommand = {
|
|
244
|
+
key: "app",
|
|
245
|
+
description: "",
|
|
246
|
+
commands: [
|
|
247
|
+
{
|
|
248
|
+
key: "x",
|
|
249
|
+
description: "cmd",
|
|
250
|
+
options: [
|
|
251
|
+
{
|
|
252
|
+
name: "verbose",
|
|
253
|
+
description: "",
|
|
254
|
+
kind: CliOptionKind.Presence,
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
positionals: [
|
|
258
|
+
{
|
|
259
|
+
name: "path",
|
|
260
|
+
description: "",
|
|
261
|
+
kind: CliOptionKind.String,
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
handler: () => {},
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
cliValidateRoot(root);
|
|
269
|
+
const pr = postParseValidate(root, parse(root, ["x", "./file", "--verbose"]));
|
|
270
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
271
|
+
expect(pr.args).toEqual(["./file"]);
|
|
272
|
+
expect(pr.opts["verbose"]).toBe("1");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("trailing options include parent-scoped flags", () => {
|
|
276
|
+
const root: CliCommand = {
|
|
277
|
+
key: "app",
|
|
278
|
+
description: "",
|
|
279
|
+
commands: [
|
|
280
|
+
{
|
|
281
|
+
key: "group",
|
|
282
|
+
description: "group",
|
|
283
|
+
options: [
|
|
284
|
+
{
|
|
285
|
+
name: "json",
|
|
286
|
+
description: "",
|
|
287
|
+
kind: CliOptionKind.Presence,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
commands: [
|
|
291
|
+
{
|
|
292
|
+
key: "leaf",
|
|
293
|
+
description: "leaf",
|
|
294
|
+
options: [
|
|
295
|
+
{
|
|
296
|
+
name: "user",
|
|
297
|
+
description: "",
|
|
298
|
+
kind: CliOptionKind.String,
|
|
299
|
+
shortName: "u",
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
positionals: [
|
|
303
|
+
{
|
|
304
|
+
name: "path",
|
|
305
|
+
description: "",
|
|
306
|
+
kind: CliOptionKind.String,
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
handler: () => {},
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
};
|
|
315
|
+
cliValidateRoot(root);
|
|
316
|
+
const pr = postParseValidate(root, parse(root, ["group", "leaf", "-u", "alice", "./file", "--json"]));
|
|
317
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
318
|
+
expect(pr.path).toEqual(["group", "leaf"]);
|
|
319
|
+
expect(pr.args).toEqual(["./file"]);
|
|
320
|
+
expect(pr.opts["user"]).toBe("alice");
|
|
321
|
+
expect(pr.opts["json"]).toBe("1");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("varargs tail does not parse trailing options", () => {
|
|
325
|
+
const root: CliCommand = {
|
|
326
|
+
key: "app",
|
|
327
|
+
description: "",
|
|
328
|
+
commands: [
|
|
329
|
+
{
|
|
330
|
+
key: "x",
|
|
331
|
+
description: "cmd",
|
|
332
|
+
options: [
|
|
333
|
+
{
|
|
334
|
+
name: "json",
|
|
335
|
+
description: "",
|
|
336
|
+
kind: CliOptionKind.Presence,
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
positionals: [
|
|
340
|
+
{
|
|
341
|
+
name: "files",
|
|
342
|
+
description: "",
|
|
343
|
+
kind: CliOptionKind.String,
|
|
344
|
+
argMin: 0,
|
|
345
|
+
argMax: 0,
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
handler: () => {},
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
};
|
|
352
|
+
cliValidateRoot(root);
|
|
353
|
+
const pr = postParseValidate(root, parse(root, ["x", "./file", "--json"]));
|
|
354
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
355
|
+
expect(pr.args).toEqual(["./file", "--json"]);
|
|
356
|
+
expect(pr.opts["json"]).toBeUndefined();
|
|
357
|
+
});
|
|
358
|
+
|
|
241
359
|
test("stops parsing options at --", () => {
|
|
242
360
|
const root: CliCommand = {
|
|
243
361
|
key: "app",
|
|
@@ -357,4 +475,99 @@ test("leaf completion help prints correctly", async () => {
|
|
|
357
475
|
expect(out).toContain("Show help for this command.");
|
|
358
476
|
expect(out).toContain("Output is the whole script.");
|
|
359
477
|
expect(stderr.toString()).toBe("");
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("--schema exports JSON for nested CLIs", async () => {
|
|
481
|
+
const { stdout, stderr, exitCode } = await $`bun run examples/nested.ts --schema`.nothrow().quiet();
|
|
482
|
+
expect(exitCode).toBe(0);
|
|
483
|
+
expect(stderr.toString()).toBe("");
|
|
484
|
+
|
|
485
|
+
const schema = JSON.parse(stdout.toString());
|
|
486
|
+
expect(schema.key).toBe("nested.ts");
|
|
487
|
+
expect(schema.fallbackCommand).toBe("read");
|
|
488
|
+
expect(schema.commands.map((c: { key: string }) => c.key)).toEqual(["stat", "read"]);
|
|
489
|
+
expect(schema.commands).not.toContainEqual(expect.objectContaining({ key: "completion" }));
|
|
490
|
+
|
|
491
|
+
const lookup = schema.commands[0].commands[0].commands[0];
|
|
492
|
+
expect(lookup.key).toBe("lookup");
|
|
493
|
+
expect(lookup.positionals[0].name).toBe("path");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("--schema exports JSON for leaf roots", async () => {
|
|
497
|
+
const { stdout, exitCode } = await $`bun run examples/minimal.ts --schema`.nothrow().quiet();
|
|
498
|
+
expect(exitCode).toBe(0);
|
|
499
|
+
|
|
500
|
+
const schema = JSON.parse(stdout.toString());
|
|
501
|
+
expect(schema.key).toBe("minimal.ts");
|
|
502
|
+
expect(schema.positionals[0].name).toBe("name");
|
|
503
|
+
expect(schema.options[0].name).toBe("verbose");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("parse recognizes --schema at the program root", () => {
|
|
507
|
+
const root: CliCommand = {
|
|
508
|
+
key: "app",
|
|
509
|
+
description: "demo",
|
|
510
|
+
commands: [
|
|
511
|
+
{
|
|
512
|
+
key: "x",
|
|
513
|
+
description: "cmd",
|
|
514
|
+
handler: () => {},
|
|
515
|
+
},
|
|
516
|
+
],
|
|
517
|
+
};
|
|
518
|
+
cliValidateRoot(root);
|
|
519
|
+
const pr = parse(root, ["--schema"]);
|
|
520
|
+
expect(pr.kind).toBe(ParseKind.Schema);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("cliSchemaJson omits handlers and completion built-ins", () => {
|
|
524
|
+
const root: CliCommand = {
|
|
525
|
+
key: "app",
|
|
526
|
+
description: "demo",
|
|
527
|
+
commands: [
|
|
528
|
+
{
|
|
529
|
+
key: "x",
|
|
530
|
+
description: "cmd",
|
|
531
|
+
handler: () => {},
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
key: "completion",
|
|
535
|
+
description: "should not appear",
|
|
536
|
+
commands: [
|
|
537
|
+
{
|
|
538
|
+
key: "bash",
|
|
539
|
+
description: "",
|
|
540
|
+
handler: () => {},
|
|
541
|
+
},
|
|
542
|
+
],
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const schema = JSON.parse(cliSchemaJson(root));
|
|
548
|
+
expect(schema.commands).toHaveLength(1);
|
|
549
|
+
expect(schema.commands[0].key).toBe("x");
|
|
550
|
+
expect(schema).not.toHaveProperty("handler");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("reserved option name schema is rejected", () => {
|
|
554
|
+
const root: CliCommand = {
|
|
555
|
+
key: "app",
|
|
556
|
+
description: "",
|
|
557
|
+
commands: [
|
|
558
|
+
{
|
|
559
|
+
key: "x",
|
|
560
|
+
description: "cmd",
|
|
561
|
+
options: [
|
|
562
|
+
{
|
|
563
|
+
name: "schema",
|
|
564
|
+
description: "",
|
|
565
|
+
kind: CliOptionKind.String,
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
handler: () => {},
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
};
|
|
572
|
+
expect(() => cliValidateRoot(root)).toThrow(/reserved for --schema/);
|
|
360
573
|
});
|
package/src/parse.ts
CHANGED
|
@@ -26,6 +26,8 @@ export enum ParseKind {
|
|
|
26
26
|
Ok = "ok",
|
|
27
27
|
/** User requested help (explicit or implicit). */
|
|
28
28
|
Help = "help",
|
|
29
|
+
/** User requested machine-readable schema export (`--schema`). */
|
|
30
|
+
Schema = "schema",
|
|
29
31
|
/** User error (unknown command, bad option, etc.). */
|
|
30
32
|
Error = "error",
|
|
31
33
|
}
|
|
@@ -54,12 +56,18 @@ export interface ParseResult {
|
|
|
54
56
|
|
|
55
57
|
const helpShort = "-h";
|
|
56
58
|
const helpLong = "--help";
|
|
59
|
+
const schemaLong = "--schema";
|
|
57
60
|
|
|
58
61
|
/** Returns true if the argv token is `-h` or `--help`. */
|
|
59
62
|
function isHelpTok(tok: string): boolean {
|
|
60
63
|
return tok === helpShort || tok === helpLong;
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
/** Returns true if the argv token is `--schema`. */
|
|
67
|
+
function isSchemaTok(tok: string): boolean {
|
|
68
|
+
return tok === schemaLong;
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
/** Looks up a subcommand or routing node by `key`. */
|
|
64
72
|
function findChild(cmds: CliCommand[], name: string): CliCommand | undefined {
|
|
65
73
|
return cmds.find((c) => c.key === name);
|
|
@@ -184,6 +192,7 @@ function consumeOptions(
|
|
|
184
192
|
const tok = argv[idx];
|
|
185
193
|
|
|
186
194
|
if (isHelpTok(tok)) break;
|
|
195
|
+
if (isSchemaTok(tok)) break;
|
|
187
196
|
if (!tok.startsWith("-")) break;
|
|
188
197
|
|
|
189
198
|
if (tok === "--") {
|
|
@@ -207,6 +216,26 @@ function consumeOptions(
|
|
|
207
216
|
|
|
208
217
|
// ── Positional Collection ─────────────────────────────────────────────────────
|
|
209
218
|
|
|
219
|
+
/** Merges option defs from the program root along the routed command path. */
|
|
220
|
+
function collectOptionDefs(root: CliCommand, path: string[]): CliOption[] {
|
|
221
|
+
let defs = [...(root.options ?? [])];
|
|
222
|
+
let cmds = root.commands ?? [];
|
|
223
|
+
|
|
224
|
+
for (const seg of path) {
|
|
225
|
+
const ch = findChild(cmds, seg);
|
|
226
|
+
if (!ch) break;
|
|
227
|
+
defs.push(...(ch.options ?? []));
|
|
228
|
+
cmds = ch.commands ?? [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return defs;
|
|
232
|
+
}
|
|
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
|
+
|
|
210
239
|
/** Fills `args` for a leaf from `startIdx` according to `node.positionals`. */
|
|
211
240
|
function finishLeaf(
|
|
212
241
|
node: CliCommand,
|
|
@@ -214,6 +243,8 @@ function finishLeaf(
|
|
|
214
243
|
argv: string[],
|
|
215
244
|
path: string[],
|
|
216
245
|
opts: Record<string, string>,
|
|
246
|
+
optionDefs: CliOption[],
|
|
247
|
+
forcePositionals: boolean,
|
|
217
248
|
): ParseResult {
|
|
218
249
|
/** Builds a parse error for positional consumption failures. */
|
|
219
250
|
function errorResult(msg: string): ParseResult {
|
|
@@ -243,8 +274,13 @@ function finishLeaf(
|
|
|
243
274
|
args.push(argv[idx]);
|
|
244
275
|
idx += 1;
|
|
245
276
|
} else if (idx < argv.length) {
|
|
246
|
-
|
|
247
|
-
|
|
277
|
+
const tok = argv[idx];
|
|
278
|
+
if (argMin < 1 && tok.startsWith("-")) {
|
|
279
|
+
// Optional slot: leave `-` tokens for trailing option parsing.
|
|
280
|
+
} else {
|
|
281
|
+
args.push(tok);
|
|
282
|
+
idx += 1;
|
|
283
|
+
}
|
|
248
284
|
}
|
|
249
285
|
continue;
|
|
250
286
|
}
|
|
@@ -269,7 +305,23 @@ function finishLeaf(
|
|
|
269
305
|
}
|
|
270
306
|
|
|
271
307
|
if (idx < argv.length) {
|
|
272
|
-
|
|
308
|
+
if (forcePositionals || !allowsTrailingOptions(node.positionals)) {
|
|
309
|
+
return errorResult("Unexpected extra arguments");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (isHelpTok(argv[idx])) {
|
|
313
|
+
return helpResult(path, true);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const tailRep = consumeOptions(optionDefs, false, argv, idx, opts);
|
|
317
|
+
if (tailRep.report.err) {
|
|
318
|
+
return errorResult(tailRep.report.err);
|
|
319
|
+
}
|
|
320
|
+
idx = tailRep.nextIndex;
|
|
321
|
+
|
|
322
|
+
if (idx < argv.length) {
|
|
323
|
+
return errorResult("Unexpected extra arguments");
|
|
324
|
+
}
|
|
273
325
|
}
|
|
274
326
|
|
|
275
327
|
return { kind: ParseKind.Ok, path, opts, args, helpExplicit: false, helpPath: [], errorMsg: "", errorHelpPath: [] };
|
|
@@ -291,6 +343,20 @@ function helpResult(p: string[], explicit: boolean): ParseResult {
|
|
|
291
343
|
};
|
|
292
344
|
}
|
|
293
345
|
|
|
346
|
+
/** Builds a schema-export result for the program root. */
|
|
347
|
+
function schemaResult(): ParseResult {
|
|
348
|
+
return {
|
|
349
|
+
kind: ParseKind.Schema,
|
|
350
|
+
path: [],
|
|
351
|
+
opts: {},
|
|
352
|
+
args: [],
|
|
353
|
+
helpExplicit: false,
|
|
354
|
+
helpPath: [],
|
|
355
|
+
errorMsg: "",
|
|
356
|
+
errorHelpPath: [],
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
294
360
|
/**
|
|
295
361
|
* Parses `argv` against the program root, routing into subcommands and filling `opts` / `args`.
|
|
296
362
|
*/
|
|
@@ -324,12 +390,16 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
|
|
|
324
390
|
return helpResult([], true);
|
|
325
391
|
}
|
|
326
392
|
|
|
393
|
+
if (i < argv.length && !forcePositionals && isSchemaTok(argv[i])) {
|
|
394
|
+
return schemaResult();
|
|
395
|
+
}
|
|
396
|
+
|
|
327
397
|
// Determine which subcommand to route to
|
|
328
398
|
let cmdName: string;
|
|
329
399
|
let node: CliCommand | undefined;
|
|
330
400
|
|
|
331
401
|
if (root.handler) {
|
|
332
|
-
return finishLeaf(root as
|
|
402
|
+
return finishLeaf(root as CliCommand, i, argv, path, opts, root.options ?? [], forcePositionals);
|
|
333
403
|
}
|
|
334
404
|
|
|
335
405
|
if (i >= argv.length) {
|
|
@@ -415,7 +485,7 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
|
|
|
415
485
|
if ((current.commands ?? []).length > 0) {
|
|
416
486
|
return helpResult(path, false);
|
|
417
487
|
}
|
|
418
|
-
return finishLeaf(current, i, argv, path, opts);
|
|
488
|
+
return finishLeaf(current, i, argv, path, opts, collectOptionDefs(root, path), forcePositionals);
|
|
419
489
|
}
|
|
420
490
|
|
|
421
491
|
const tok = argv[i];
|
|
@@ -455,7 +525,7 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
|
|
|
455
525
|
};
|
|
456
526
|
}
|
|
457
527
|
|
|
458
|
-
return finishLeaf(current, i, argv, path, opts);
|
|
528
|
+
return finishLeaf(current, i, argv, path, opts, collectOptionDefs(root, path), forcePositionals);
|
|
459
529
|
}
|
|
460
530
|
}
|
|
461
531
|
|
package/src/runtime.ts
CHANGED
|
@@ -10,7 +10,8 @@ the runtime responsibilities remain easy to reason about.
|
|
|
10
10
|
import { cliBuiltinCompletionGroup, completionBashScript, completionZshScript } from "./completion.ts";
|
|
11
11
|
import { CliContext } from "./context.ts";
|
|
12
12
|
import { cliHelpRender } from "./help.ts";
|
|
13
|
-
import { parse, postParseValidate } from "./parse.ts";
|
|
13
|
+
import { parse, postParseValidate, ParseKind } from "./parse.ts";
|
|
14
|
+
import { cliSchemaJson } from "./schema.ts";
|
|
14
15
|
import { CliCommand } from "./types.ts";
|
|
15
16
|
import { cliValidateRoot } from "./validate.ts";
|
|
16
17
|
|
|
@@ -63,11 +64,16 @@ export async function cliRun(root: CliCommand, argv: string[] = process.argv.sli
|
|
|
63
64
|
let pr = parse(parseRoot, argv);
|
|
64
65
|
pr = postParseValidate(parseRoot, pr);
|
|
65
66
|
|
|
66
|
-
if (pr.kind ===
|
|
67
|
+
if (pr.kind === ParseKind.Help) {
|
|
67
68
|
process.stdout.write(cliHelpRender(parseRoot, pr.helpPath, false));
|
|
68
69
|
process.exit(pr.helpExplicit ? 0 : 1);
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
if (pr.kind === ParseKind.Schema) {
|
|
73
|
+
process.stdout.write(cliSchemaJson(root));
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
if (pr.kind === "error") {
|
|
72
78
|
const color = process.stderr.isTTY;
|
|
73
79
|
const msg = color ? `\u001B[31m${pr.errorMsg}\u001B[0m` : pr.errorMsg;
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/*
|
|
2
|
+
This module serializes the CLI schema tree to JSON for machine-readable introspection.
|
|
3
|
+
It strips handlers and runtime-only nodes so agents can discover commands, options,
|
|
4
|
+
and positionals in one shot.
|
|
5
|
+
|
|
6
|
+
It keeps schema export aligned with the declarative CliCommand model that drives help
|
|
7
|
+
and completion.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
CliCommand,
|
|
12
|
+
CliFallbackMode,
|
|
13
|
+
CliOption,
|
|
14
|
+
CliPositional,
|
|
15
|
+
} from "./types.ts";
|
|
16
|
+
|
|
17
|
+
/** JSON-safe command node (no handlers). */
|
|
18
|
+
export interface CliSchemaExport {
|
|
19
|
+
/** Program or command key. */
|
|
20
|
+
key: string;
|
|
21
|
+
/** Short description shown in help. */
|
|
22
|
+
description: string;
|
|
23
|
+
/** Additional notes shown in help (supports {app} placeholder). */
|
|
24
|
+
notes?: string;
|
|
25
|
+
/** Global or command-level flags/options. */
|
|
26
|
+
options?: CliOption[];
|
|
27
|
+
/** Default top-level subcommand (program root only). */
|
|
28
|
+
fallbackCommand?: string;
|
|
29
|
+
/** How fallbackCommand is applied (program root only). */
|
|
30
|
+
fallbackMode?: CliFallbackMode;
|
|
31
|
+
/** Nested subcommands (routing nodes only). */
|
|
32
|
+
commands?: CliSchemaExport[];
|
|
33
|
+
/** Positional argument definitions (leaf nodes only). */
|
|
34
|
+
positionals?: CliPositional[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Converts one `CliCommand` node into a JSON-safe export (handlers omitted). */
|
|
38
|
+
function exportCommand(cmd: CliCommand): CliSchemaExport {
|
|
39
|
+
const out: CliSchemaExport = {
|
|
40
|
+
key: cmd.key,
|
|
41
|
+
description: cmd.description,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if ((cmd.notes ?? "").length > 0) {
|
|
45
|
+
out.notes = cmd.notes;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if ((cmd.options ?? []).length > 0) {
|
|
49
|
+
out.options = cmd.options;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if ("handler" in cmd && cmd.handler) {
|
|
53
|
+
if ((cmd.positionals ?? []).length > 0) {
|
|
54
|
+
out.positionals = cmd.positionals;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (cmd.fallbackCommand !== undefined) {
|
|
60
|
+
out.fallbackCommand = cmd.fallbackCommand;
|
|
61
|
+
}
|
|
62
|
+
if (cmd.fallbackMode !== undefined) {
|
|
63
|
+
out.fallbackMode = cmd.fallbackMode;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const children = (cmd.commands ?? []).filter((ch) => ch.key !== "completion");
|
|
67
|
+
if (children.length > 0) {
|
|
68
|
+
out.commands = children.map(exportCommand);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Returns pretty-printed JSON for the full program schema (trailing newline). */
|
|
75
|
+
export function cliSchemaJson(root: CliCommand): string {
|
|
76
|
+
return JSON.stringify(exportCommand(root), null, 2) + "\n";
|
|
77
|
+
}
|
package/src/validate.ts
CHANGED
|
@@ -69,6 +69,12 @@ function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
|
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
if (opt.name === "schema") {
|
|
73
|
+
throw new CliSchemaValidationError(
|
|
74
|
+
`Option name "schema" is reserved for --schema: ${cmd.key}/${opt.name}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
if (opt.shortName !== undefined) {
|
|
73
79
|
if (opt.shortName === "h") {
|
|
74
80
|
throw new CliSchemaValidationError(
|