argsbarg 7.0.3 → 7.0.5
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/CHANGELOG.md +15 -1
- package/docs/cli-program.md +1 -0
- package/docs/mcp.md +1 -1
- package/examples/nested.ts +7 -9
- package/package.json +1 -1
- package/src/core/parse.test.ts +213 -8
- package/src/core/parse.ts +95 -76
- package/src/core/validate.ts +12 -3
- package/src/docs/http-guide.ts +2 -2
- package/src/docs/mcp-guide.ts +2 -3
- package/src/http/openapi.ts +4 -8
- package/src/http/routes.ts +7 -4
- package/src/mcp/hidden-mcpb.test.ts +1 -1
- package/src/mcp/tools.ts +25 -7
- package/src/skill/generate.ts +2 -2
- package/src/test/fixtures.ts +0 -7
- package/src/test/integration/http.test.ts +17 -12
- package/src/test/integration/mcp.test.ts +6 -6
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
|
+
## [7.0.5] - 2026-09-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Interleaved options between positionals** — options (presence flags and value options) can now be placed anywhere between bounded or optional positional arguments (e.g. `cmd file1 --force file2`), matching varargs tail behavior rather than requiring all options to precede or follow all bounded positionals.
|
|
15
|
+
|
|
16
|
+
## [7.0.4] - 2026-08-17
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Breaking: leaf-local options only** — options apply on the command node where they are declared (routing groups cannot declare options; program root still may). MCP, OpenAPI, HTTP, and skill wire schemas expose leaf-local options only. `tools/list` is sorted alphabetically by tool name. MCP auto-injects `--yes` for mutating tools; `json`, `yes`, and `verbose` are omitted from MCP/HTTP wire schemas.
|
|
21
|
+
|
|
10
22
|
## [7.0.3] - 2026-08-13
|
|
11
23
|
|
|
12
24
|
### Fixed
|
|
@@ -953,7 +965,9 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
|
|
|
953
965
|
- 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`).
|
|
954
966
|
- Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
|
|
955
967
|
|
|
956
|
-
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v7.0.
|
|
968
|
+
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v7.0.5...HEAD
|
|
969
|
+
[7.0.5]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.5
|
|
970
|
+
[7.0.4]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.4
|
|
957
971
|
[7.0.3]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.3
|
|
958
972
|
[7.0.2]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.2
|
|
959
973
|
[7.0.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.1
|
package/docs/cli-program.md
CHANGED
|
@@ -107,6 +107,7 @@ Use root **`notes`** for cross-cutting hints shown in help (install commands, do
|
|
|
107
107
|
|
|
108
108
|
Descriptions and schemas are copied into MCP tools, HTTP OpenAPI, and generated skills — optimize for smaller, clearer agent payloads:
|
|
109
109
|
|
|
110
|
+
- **Declare options on the leaf command** that uses them — routing groups cannot declare options (program root may). Wire schemas (MCP, OpenAPI, skills) expose leaf-local options only.
|
|
110
111
|
- Prefer **`kind: "json"`** leaves with schemagen `inputSchema` for complex tool bodies (one nested object beats many flat flags).
|
|
111
112
|
- Keep **`description`** strings short and action-oriented; put examples in **`notes`**, not duplicated in every option.
|
|
112
113
|
- Use **`hidden: true`** or **`mcpTool.enabled: false`** for debug/internal commands.
|
package/docs/mcp.md
CHANGED
|
@@ -169,7 +169,7 @@ Set **`outputSchema` on the leaf** (not under `mcpTool`) — see [cli-program.md
|
|
|
169
169
|
|
|
170
170
|
Each tool’s `inputSchema` is a JSON Schema object built from your CLI definition:
|
|
171
171
|
|
|
172
|
-
- **Options** —
|
|
172
|
+
- **Options** — leaf-local flags only (declare on the command that uses them). Presence options are `boolean`; string, number, and **enum** options match their `CliOptionKind` (`Enum` uses JSON Schema `enum`). Required options are listed in `required`. `json`, `yes`, and `verbose` are omitted from MCP wire schemas (the framework handles them on invoke; mutating tools auto-receive `--yes`).
|
|
173
173
|
- **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`. **Varargs must be a JSON array** — comma-separated strings are not accepted (use `format: comma-list` on an option when a single flag should accept `"a,b"` or `["a","b"]`).
|
|
174
174
|
|
|
175
175
|
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"`).
|
package/examples/nested.ts
CHANGED
|
@@ -8,20 +8,13 @@ It demonstrates how the schema scales beyond one command.
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import pkg from "../package.json" with { type: "json" };
|
|
11
|
-
import { Cli, CliFallbackMode, CliOptionKind, type CliProgram } from "../src/index";
|
|
11
|
+
import { Cli, CliFallbackMode, CliOptionKind, type CliProgram, wantsExplicitJson } from "../src/index";
|
|
12
12
|
|
|
13
13
|
const program = {
|
|
14
14
|
commands: [
|
|
15
15
|
{
|
|
16
16
|
key: "stat",
|
|
17
17
|
description: "File metadata.",
|
|
18
|
-
options: [
|
|
19
|
-
{
|
|
20
|
-
name: "json",
|
|
21
|
-
description: "Emit handler output as JSON.",
|
|
22
|
-
kind: CliOptionKind.Presence,
|
|
23
|
-
},
|
|
24
|
-
],
|
|
25
18
|
commands: [
|
|
26
19
|
{
|
|
27
20
|
key: "owner",
|
|
@@ -31,6 +24,11 @@ const program = {
|
|
|
31
24
|
key: "lookup",
|
|
32
25
|
description: "Resolve owner info.",
|
|
33
26
|
options: [
|
|
27
|
+
{
|
|
28
|
+
name: "json",
|
|
29
|
+
description: "Emit handler output as JSON.",
|
|
30
|
+
kind: CliOptionKind.Presence,
|
|
31
|
+
},
|
|
34
32
|
{
|
|
35
33
|
name: "user-name",
|
|
36
34
|
description: "User to look up.",
|
|
@@ -52,7 +50,7 @@ const program = {
|
|
|
52
50
|
console.error("Missing path.");
|
|
53
51
|
process.exit(1);
|
|
54
52
|
}
|
|
55
|
-
if (ctx.hasFlag("json")) {
|
|
53
|
+
if (wantsExplicitJson(ctx, ctx.hasFlag("json"))) {
|
|
56
54
|
const payload = { user, path };
|
|
57
55
|
if (ctx.invocation === "cli") {
|
|
58
56
|
console.log(JSON.stringify(payload));
|
package/package.json
CHANGED
package/src/core/parse.test.ts
CHANGED
|
@@ -370,8 +370,186 @@ test("trailing options after bounded positionals", () => {
|
|
|
370
370
|
expect(pr.opts.verbose).toBe("1");
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
-
/** Tests that
|
|
374
|
-
test("
|
|
373
|
+
/** Tests that options can be interleaved between bounded positionals. */
|
|
374
|
+
test("options interleaved between bounded positionals", () => {
|
|
375
|
+
const root = testProgram({
|
|
376
|
+
key: "app",
|
|
377
|
+
description: "",
|
|
378
|
+
commands: [
|
|
379
|
+
{
|
|
380
|
+
key: "copy",
|
|
381
|
+
description: "copy",
|
|
382
|
+
options: [
|
|
383
|
+
{
|
|
384
|
+
name: "force",
|
|
385
|
+
description: "",
|
|
386
|
+
kind: CliOptionKind.Presence,
|
|
387
|
+
shortName: "f",
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
name: "mode",
|
|
391
|
+
description: "",
|
|
392
|
+
kind: CliOptionKind.String,
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
positionals: [
|
|
396
|
+
{
|
|
397
|
+
name: "src",
|
|
398
|
+
description: "",
|
|
399
|
+
kind: CliOptionKind.String,
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: "dest",
|
|
403
|
+
description: "",
|
|
404
|
+
kind: CliOptionKind.String,
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
handler: () => {},
|
|
408
|
+
},
|
|
409
|
+
],
|
|
410
|
+
});
|
|
411
|
+
cliValidateProgram(root);
|
|
412
|
+
|
|
413
|
+
// Presence flag interleaved between positionals
|
|
414
|
+
const prPresence = postParseValidate(root, parse(root, ["copy", "file1", "--force", "file2"]));
|
|
415
|
+
expect(prPresence.kind).toBe(ParseKind.Ok);
|
|
416
|
+
expect(prPresence.args).toEqual(["file1", "file2"]);
|
|
417
|
+
expect(prPresence.opts.force).toBe("1");
|
|
418
|
+
|
|
419
|
+
// String option with value interleaved between positionals
|
|
420
|
+
const prString = postParseValidate(root, parse(root, ["copy", "file1", "--mode", "fast", "file2"]));
|
|
421
|
+
expect(prString.kind).toBe(ParseKind.Ok);
|
|
422
|
+
expect(prString.args).toEqual(["file1", "file2"]);
|
|
423
|
+
expect(prString.opts.mode).toBe("fast");
|
|
424
|
+
|
|
425
|
+
// Multiple flags interleaved between positionals
|
|
426
|
+
const prMulti = postParseValidate(root, parse(root, ["copy", "file1", "--mode", "fast", "-f", "file2"]));
|
|
427
|
+
expect(prMulti.kind).toBe(ParseKind.Ok);
|
|
428
|
+
expect(prMulti.args).toEqual(["file1", "file2"]);
|
|
429
|
+
expect(prMulti.opts.mode).toBe("fast");
|
|
430
|
+
expect(prMulti.opts.force).toBe("1");
|
|
431
|
+
|
|
432
|
+
// Unknown option interleaved between positionals returns error
|
|
433
|
+
const prUnknown = postParseValidate(root, parse(root, ["copy", "file1", "--unknown", "file2"]));
|
|
434
|
+
expect(prUnknown.kind).toBe(ParseKind.Error);
|
|
435
|
+
expect(prUnknown.errorMsg).toContain("Unknown option: --unknown");
|
|
436
|
+
|
|
437
|
+
// Interleaved help request triggers contextual help
|
|
438
|
+
const prHelp = parse(root, ["copy", "file1", "-h"]);
|
|
439
|
+
expect(prHelp.kind).toBe(ParseKind.Help);
|
|
440
|
+
expect(prHelp.helpPath).toEqual(["copy"]);
|
|
441
|
+
|
|
442
|
+
// Double dash between positionals disables option consumption
|
|
443
|
+
const prDoubleDash = postParseValidate(root, parse(root, ["copy", "file1", "--", "--force"]));
|
|
444
|
+
expect(prDoubleDash.kind).toBe(ParseKind.Ok);
|
|
445
|
+
expect(prDoubleDash.args).toEqual(["file1", "--force"]);
|
|
446
|
+
expect(prDoubleDash.opts.force).toBeUndefined();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
/** Tests that options can be interleaved with optional positionals. */
|
|
450
|
+
test("options interleaved with optional positionals", () => {
|
|
451
|
+
const root = testProgram({
|
|
452
|
+
key: "app",
|
|
453
|
+
description: "",
|
|
454
|
+
commands: [
|
|
455
|
+
{
|
|
456
|
+
key: "deploy",
|
|
457
|
+
description: "deploy",
|
|
458
|
+
options: [
|
|
459
|
+
{
|
|
460
|
+
name: "force",
|
|
461
|
+
description: "",
|
|
462
|
+
kind: CliOptionKind.Presence,
|
|
463
|
+
},
|
|
464
|
+
],
|
|
465
|
+
positionals: [
|
|
466
|
+
{
|
|
467
|
+
name: "env",
|
|
468
|
+
description: "",
|
|
469
|
+
kind: CliOptionKind.String,
|
|
470
|
+
argMin: 0,
|
|
471
|
+
argMax: 1,
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
name: "target",
|
|
475
|
+
description: "",
|
|
476
|
+
kind: CliOptionKind.String,
|
|
477
|
+
argMin: 0,
|
|
478
|
+
argMax: 1,
|
|
479
|
+
},
|
|
480
|
+
],
|
|
481
|
+
handler: () => {},
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
});
|
|
485
|
+
cliValidateProgram(root);
|
|
486
|
+
|
|
487
|
+
// Interleaved between two optional positionals
|
|
488
|
+
const prBoth = postParseValidate(root, parse(root, ["deploy", "prod", "--force", "us-east"]));
|
|
489
|
+
expect(prBoth.kind).toBe(ParseKind.Ok);
|
|
490
|
+
expect(prBoth.args).toEqual(["prod", "us-east"]);
|
|
491
|
+
expect(prBoth.opts.force).toBe("1");
|
|
492
|
+
|
|
493
|
+
// Option after first optional positional when second is omitted
|
|
494
|
+
const prOne = postParseValidate(root, parse(root, ["deploy", "prod", "--force"]));
|
|
495
|
+
expect(prOne.kind).toBe(ParseKind.Ok);
|
|
496
|
+
expect(prOne.args).toEqual(["prod"]);
|
|
497
|
+
expect(prOne.opts.force).toBe("1");
|
|
498
|
+
|
|
499
|
+
// Option before optional positionals when all are omitted
|
|
500
|
+
const prNone = postParseValidate(root, parse(root, ["deploy", "--force"]));
|
|
501
|
+
expect(prNone.kind).toBe(ParseKind.Ok);
|
|
502
|
+
expect(prNone.args).toEqual([]);
|
|
503
|
+
expect(prNone.opts.force).toBe("1");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
/** Tests that options can be interleaved between bounded positional and varargs tail. */
|
|
507
|
+
test("options interleaved between bounded positional and varargs tail", () => {
|
|
508
|
+
const root = testProgram({
|
|
509
|
+
key: "app",
|
|
510
|
+
description: "",
|
|
511
|
+
commands: [
|
|
512
|
+
{
|
|
513
|
+
key: "upload",
|
|
514
|
+
description: "upload",
|
|
515
|
+
options: [
|
|
516
|
+
{
|
|
517
|
+
name: "json",
|
|
518
|
+
description: "",
|
|
519
|
+
kind: CliOptionKind.Presence,
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
positionals: [
|
|
523
|
+
{
|
|
524
|
+
name: "target",
|
|
525
|
+
description: "",
|
|
526
|
+
kind: CliOptionKind.String,
|
|
527
|
+
argMin: 1,
|
|
528
|
+
argMax: 1,
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
name: "files",
|
|
532
|
+
description: "",
|
|
533
|
+
kind: CliOptionKind.String,
|
|
534
|
+
argMin: 1,
|
|
535
|
+
argMax: 0,
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
handler: () => {},
|
|
539
|
+
},
|
|
540
|
+
],
|
|
541
|
+
});
|
|
542
|
+
cliValidateProgram(root);
|
|
543
|
+
|
|
544
|
+
// Flag between target and files does not get captured as first file
|
|
545
|
+
const pr = postParseValidate(root, parse(root, ["upload", "s3", "--json", "a.txt", "b.txt"]));
|
|
546
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
547
|
+
expect(pr.args).toEqual(["s3", "a.txt", "b.txt"]);
|
|
548
|
+
expect(pr.opts.json).toBe("1");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
/** Tests that options on routing groups are rejected at schema validation. */
|
|
552
|
+
test("rejects options on routing groups", () => {
|
|
375
553
|
const root = testProgram({
|
|
376
554
|
key: "app",
|
|
377
555
|
description: "",
|
|
@@ -386,11 +564,38 @@ test("trailing options include parent-scoped flags", () => {
|
|
|
386
564
|
kind: CliOptionKind.Presence,
|
|
387
565
|
},
|
|
388
566
|
],
|
|
567
|
+
commands: [
|
|
568
|
+
{
|
|
569
|
+
key: "leaf",
|
|
570
|
+
description: "leaf",
|
|
571
|
+
handler: () => {},
|
|
572
|
+
},
|
|
573
|
+
],
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
});
|
|
577
|
+
expect(() => cliValidateProgram(root)).toThrow(/routing group/);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
/** Tests that leaf flags are only accepted on the leaf command segment. */
|
|
581
|
+
test("leaf flags are only accepted on the leaf command segment", () => {
|
|
582
|
+
const root = testProgram({
|
|
583
|
+
key: "app",
|
|
584
|
+
description: "",
|
|
585
|
+
commands: [
|
|
586
|
+
{
|
|
587
|
+
key: "group",
|
|
588
|
+
description: "group",
|
|
389
589
|
commands: [
|
|
390
590
|
{
|
|
391
591
|
key: "leaf",
|
|
392
592
|
description: "leaf",
|
|
393
593
|
options: [
|
|
594
|
+
{
|
|
595
|
+
name: "json",
|
|
596
|
+
description: "",
|
|
597
|
+
kind: CliOptionKind.Presence,
|
|
598
|
+
},
|
|
394
599
|
{
|
|
395
600
|
name: "user",
|
|
396
601
|
description: "",
|
|
@@ -412,12 +617,12 @@ test("trailing options include parent-scoped flags", () => {
|
|
|
412
617
|
],
|
|
413
618
|
});
|
|
414
619
|
cliValidateProgram(root);
|
|
415
|
-
const
|
|
416
|
-
expect(
|
|
417
|
-
expect(
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
expect(
|
|
620
|
+
const ok = postParseValidate(root, parse(root, ["group", "leaf", "-u", "alice", "./file", "--json"]));
|
|
621
|
+
expect(ok.kind).toBe(ParseKind.Ok);
|
|
622
|
+
expect(ok.opts.json).toBe("1");
|
|
623
|
+
|
|
624
|
+
const bad = parse(root, ["group", "--json", "leaf", "-u", "alice", "./file"]);
|
|
625
|
+
expect(bad.kind).toBe(ParseKind.Error);
|
|
421
626
|
});
|
|
422
627
|
|
|
423
628
|
/** Varargs tail parses trailing options. */
|
package/src/core/parse.ts
CHANGED
|
@@ -244,15 +244,38 @@ function consumeOptions(
|
|
|
244
244
|
|
|
245
245
|
// ── Positional Collection ─────────────────────────────────────────────────────
|
|
246
246
|
|
|
247
|
-
/**
|
|
248
|
-
|
|
247
|
+
/** Resolves the command node at the end of a routed path. */
|
|
248
|
+
function resolveNodeAtPath(root: CliNode, path: string[]): CliNode | undefined {
|
|
249
|
+
if (path.length === 0) {
|
|
250
|
+
return root;
|
|
251
|
+
}
|
|
252
|
+
let node: CliNode = root;
|
|
253
|
+
for (const seg of path) {
|
|
254
|
+
if (!isCliRouter(node)) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
const ch = findChild(node.commands, seg);
|
|
258
|
+
if (!ch) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
node = ch;
|
|
262
|
+
}
|
|
263
|
+
return node;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Options declared on each command node along the path (root + each segment). Used for post-parse validation. */
|
|
267
|
+
export function collectPathOptionDefs(root: CliNode, path: string[]): CliOption[] {
|
|
249
268
|
const defs = [...(root.options ?? [])];
|
|
250
269
|
let node: CliNode = root;
|
|
251
270
|
|
|
252
271
|
for (const seg of path) {
|
|
253
|
-
if (!isCliRouter(node))
|
|
272
|
+
if (!isCliRouter(node)) {
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
254
275
|
const ch = findChild(node.commands, seg);
|
|
255
|
-
if (!ch)
|
|
276
|
+
if (!ch) {
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
256
279
|
defs.push(...(ch.options ?? []));
|
|
257
280
|
node = ch;
|
|
258
281
|
}
|
|
@@ -260,6 +283,15 @@ export function collectOptionDefs(root: CliNode, path: string[]): CliOption[] {
|
|
|
260
283
|
return defs;
|
|
261
284
|
}
|
|
262
285
|
|
|
286
|
+
/** Options declared on the leaf command at path (wire schemas and MCP/HTTP tool args). */
|
|
287
|
+
export function collectOptionDefs(root: CliNode, path: string[]): CliOption[] {
|
|
288
|
+
const node = resolveNodeAtPath(root, path);
|
|
289
|
+
if (!node || !isCliLeaf(node)) {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
return [...(node.options ?? [])];
|
|
293
|
+
}
|
|
294
|
+
|
|
263
295
|
/** Fills `args` for a json leaf from `startIdx` (0 or 1 JSON string positional). */
|
|
264
296
|
function finishJsonLeaf(
|
|
265
297
|
_node: CliLeaf,
|
|
@@ -319,9 +351,47 @@ function finishLeaf(
|
|
|
319
351
|
const args: string[] = [];
|
|
320
352
|
let forcePositionals = forcePositionalsIn;
|
|
321
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Consumes any pending options, `--`, or help flags at the current argv index.
|
|
356
|
+
* Sets `forcePositionals = true` when `--` is encountered.
|
|
357
|
+
* Returns a help or error ParseResult if parsing halts, or null to continue positional consumption.
|
|
358
|
+
*/
|
|
359
|
+
function consumePendingOptions(): ParseResult | null {
|
|
360
|
+
while (!forcePositionals && idx < argv.length) {
|
|
361
|
+
const tok = argv[idx];
|
|
362
|
+
if (tok === "--") {
|
|
363
|
+
forcePositionals = true;
|
|
364
|
+
idx += 1;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
if (isHelpTok(tok)) {
|
|
368
|
+
return helpResult(path, true, pathParams);
|
|
369
|
+
}
|
|
370
|
+
if (tok.startsWith("-")) {
|
|
371
|
+
const rep = consumeOptions(optionDefs, false, argv, idx, opts);
|
|
372
|
+
if (rep.report.err) {
|
|
373
|
+
return errorResult(rep.report.err, path, [], pathParams);
|
|
374
|
+
}
|
|
375
|
+
if (rep.report.sawDoubleDash) {
|
|
376
|
+
forcePositionals = true;
|
|
377
|
+
}
|
|
378
|
+
if (rep.nextIndex > idx) {
|
|
379
|
+
idx = rep.nextIndex;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
return errorResult(`Unexpected option token: ${tok}`, path, [], pathParams);
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
322
389
|
for (const p of node.positionals ?? []) {
|
|
323
390
|
const { argMin = 1, argMax = 1 } = p;
|
|
324
391
|
if (argMax === 1) {
|
|
392
|
+
const pendingErr = consumePendingOptions();
|
|
393
|
+
if (pendingErr) return pendingErr;
|
|
394
|
+
|
|
325
395
|
if (argMin >= 1) {
|
|
326
396
|
if (idx >= argv.length) {
|
|
327
397
|
return errorResult(`Missing positional argument: ${p.name}`, path, [], pathParams);
|
|
@@ -329,13 +399,8 @@ function finishLeaf(
|
|
|
329
399
|
args.push(argv[idx]);
|
|
330
400
|
idx += 1;
|
|
331
401
|
} else if (idx < argv.length) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
// Optional slot: leave `-` tokens for trailing option parsing.
|
|
335
|
-
} else {
|
|
336
|
-
args.push(tok);
|
|
337
|
-
idx += 1;
|
|
338
|
-
}
|
|
402
|
+
args.push(argv[idx]);
|
|
403
|
+
idx += 1;
|
|
339
404
|
}
|
|
340
405
|
continue;
|
|
341
406
|
}
|
|
@@ -343,40 +408,20 @@ function finishLeaf(
|
|
|
343
408
|
let count = 0;
|
|
344
409
|
if (argMax === 0) {
|
|
345
410
|
while (idx < argv.length) {
|
|
346
|
-
const
|
|
411
|
+
const pendingErr = consumePendingOptions();
|
|
412
|
+
if (pendingErr) return pendingErr;
|
|
413
|
+
if (idx >= argv.length) break;
|
|
347
414
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
continue;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
if (!forcePositionals && isHelpTok(tok)) {
|
|
355
|
-
return helpResult(path, true, pathParams);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
if (!forcePositionals && tok.startsWith("-")) {
|
|
359
|
-
// MUST be false — lenient mode swallows unknown flags as positionals silently
|
|
360
|
-
const tailRep = consumeOptions(optionDefs, false, argv, idx, opts);
|
|
361
|
-
if (tailRep.report.err) {
|
|
362
|
-
return errorResult(tailRep.report.err, path, [], pathParams);
|
|
363
|
-
}
|
|
364
|
-
if (tailRep.report.sawDoubleDash) {
|
|
365
|
-
forcePositionals = true;
|
|
366
|
-
}
|
|
367
|
-
if (tailRep.nextIndex > idx) {
|
|
368
|
-
idx = tailRep.nextIndex;
|
|
369
|
-
continue;
|
|
370
|
-
}
|
|
371
|
-
return errorResult(`Unexpected option token: ${tok}`, path, [], pathParams);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
args.push(tok);
|
|
375
|
-
idx++;
|
|
376
|
-
count++;
|
|
415
|
+
args.push(argv[idx]);
|
|
416
|
+
idx += 1;
|
|
417
|
+
count += 1;
|
|
377
418
|
}
|
|
378
419
|
} else {
|
|
379
420
|
while (count < argMax && idx < argv.length) {
|
|
421
|
+
const pendingErr = consumePendingOptions();
|
|
422
|
+
if (pendingErr) return pendingErr;
|
|
423
|
+
if (idx >= argv.length) break;
|
|
424
|
+
|
|
380
425
|
args.push(argv[idx]);
|
|
381
426
|
idx += 1;
|
|
382
427
|
count += 1;
|
|
@@ -387,24 +432,11 @@ function finishLeaf(
|
|
|
387
432
|
}
|
|
388
433
|
}
|
|
389
434
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
return errorResult("Unexpected extra arguments", path, [], pathParams);
|
|
393
|
-
}
|
|
435
|
+
const trailingErr = consumePendingOptions();
|
|
436
|
+
if (trailingErr) return trailingErr;
|
|
394
437
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const tailRep = consumeOptions(optionDefs, false, argv, idx, opts);
|
|
400
|
-
if (tailRep.report.err) {
|
|
401
|
-
return errorResult(tailRep.report.err, path, [], pathParams);
|
|
402
|
-
}
|
|
403
|
-
idx = tailRep.nextIndex;
|
|
404
|
-
|
|
405
|
-
if (idx < argv.length) {
|
|
406
|
-
return errorResult("Unexpected extra arguments", path, [], pathParams);
|
|
407
|
-
}
|
|
438
|
+
if (idx < argv.length) {
|
|
439
|
+
return errorResult("Unexpected extra arguments", path, [], pathParams);
|
|
408
440
|
}
|
|
409
441
|
|
|
410
442
|
return {
|
|
@@ -618,7 +650,7 @@ export function parse(root: CliNode, argv: string[]): ParseResult {
|
|
|
618
650
|
}
|
|
619
651
|
|
|
620
652
|
if (!forcePositionals) {
|
|
621
|
-
const orep = consumeOptions(
|
|
653
|
+
const orep = consumeOptions(current.options ?? [], false, argv, i, opts);
|
|
622
654
|
if (orep.report.err) {
|
|
623
655
|
return errorResult(orep.report.err, path);
|
|
624
656
|
}
|
|
@@ -650,7 +682,7 @@ export function parse(root: CliNode, argv: string[]): ParseResult {
|
|
|
650
682
|
if (!isCliLeaf(current)) {
|
|
651
683
|
return helpResult(path, false, pathParams);
|
|
652
684
|
}
|
|
653
|
-
return finishLeaf(current, i, argv, path, opts,
|
|
685
|
+
return finishLeaf(current, i, argv, path, opts, current.options ?? [], forcePositionals, pathParams);
|
|
654
686
|
}
|
|
655
687
|
|
|
656
688
|
const tok = argv[i];
|
|
@@ -695,32 +727,19 @@ export function parse(root: CliNode, argv: string[]): ParseResult {
|
|
|
695
727
|
if (!isCliLeaf(current)) {
|
|
696
728
|
return helpResult(path, false, pathParams);
|
|
697
729
|
}
|
|
698
|
-
return finishLeaf(current, i, argv, path, opts,
|
|
730
|
+
return finishLeaf(current, i, argv, path, opts, current.options ?? [], forcePositionals, pathParams);
|
|
699
731
|
}
|
|
700
732
|
}
|
|
701
733
|
|
|
702
734
|
// ── Post-Parse Validation ─────────────────────────────────────────────────────
|
|
703
735
|
|
|
704
736
|
/**
|
|
705
|
-
* Validates option keys and numeric values for an Ok parse
|
|
737
|
+
* Validates option keys and numeric values for an Ok parse along `pr.path`.
|
|
706
738
|
*/
|
|
707
739
|
export function postParseValidate(root: CliNode, pr: ParseResult): ParseResult {
|
|
708
740
|
if (pr.kind !== ParseKind.Ok) return pr;
|
|
709
741
|
|
|
710
|
-
const defs =
|
|
711
|
-
let node: CliNode = root;
|
|
712
|
-
|
|
713
|
-
for (const seg of pr.path) {
|
|
714
|
-
if (!isCliRouter(node)) {
|
|
715
|
-
return errorResult("Internal path error", pr.path);
|
|
716
|
-
}
|
|
717
|
-
const ch = findChild(node.commands, seg);
|
|
718
|
-
if (!ch) {
|
|
719
|
-
return errorResult("Internal path error", pr.path);
|
|
720
|
-
}
|
|
721
|
-
defs.push(...(ch.options ?? []));
|
|
722
|
-
node = ch;
|
|
723
|
-
}
|
|
742
|
+
const defs = collectPathOptionDefs(root, pr.path);
|
|
724
743
|
|
|
725
744
|
const opts = { ...pr.opts };
|
|
726
745
|
for (const d of defs) {
|
package/src/core/validate.ts
CHANGED
|
@@ -328,6 +328,11 @@ function walkNode(node: CliNode, program: CliProgram, isRoot: boolean): void {
|
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
if (isCliRouter(node)) {
|
|
331
|
+
if (!isRoot && (node.options ?? []).length > 0) {
|
|
332
|
+
throw new CliSchemaValidationError(
|
|
333
|
+
`Options on routing group '${node.key}' are not supported — declare options on leaf commands`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
331
336
|
const seenNames = new Set<string>();
|
|
332
337
|
let paramRouterCount = 0;
|
|
333
338
|
for (const child of node.commands) {
|
|
@@ -367,9 +372,13 @@ function walkNode(node: CliNode, program: CliProgram, isRoot: boolean): void {
|
|
|
367
372
|
}
|
|
368
373
|
}
|
|
369
374
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
375
|
+
if (isCliRouter(node) && !isRoot) {
|
|
376
|
+
validatePositionals(node.key, []);
|
|
377
|
+
} else {
|
|
378
|
+
const positionals = isCliLeaf(node) ? (node.positionals ?? []) : [];
|
|
379
|
+
validateOptions(node.key, node.options ?? []);
|
|
380
|
+
validatePositionals(node.key, positionals);
|
|
381
|
+
}
|
|
373
382
|
}
|
|
374
383
|
|
|
375
384
|
function validateOptions(scopeKey: string, options: import("./types.ts").CliOption[]): void {
|
package/src/docs/http-guide.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { defaultConfigEntryTitle } from "../config/entry.ts";
|
|
2
2
|
import { displayAppConfigPath } from "../config/file.ts";
|
|
3
|
-
import { collectOptionDefs } from "../core/parse.ts";
|
|
4
3
|
import { CliOptionKind, type CliProgram } from "../core/types.ts";
|
|
5
4
|
import { httpUserPathGlob, resolveHttpPathPrefix } from "../http/paths.ts";
|
|
6
5
|
import { collectHttpRoutes } from "../http/routes.ts";
|
|
7
6
|
import { resolveHttpListenAddress } from "../http/server.ts";
|
|
7
|
+
import { leafWireOptions } from "../mcp/tools.ts";
|
|
8
8
|
|
|
9
9
|
/** Formats one HTTP route for the auto-generated HTTP guide. */
|
|
10
10
|
function formatRouteLine(root: CliProgram, route: ReturnType<typeof collectHttpRoutes>[number]): string {
|
|
11
11
|
const cliPath = route.commandPath.join(" ");
|
|
12
12
|
let line = `- \`${route.method} ${route.openApiPath}\` (CLI: \`${root.key} ${cliPath}\`) — ${route.leaf.description}`;
|
|
13
|
-
const opts =
|
|
13
|
+
const opts = leafWireOptions(route.leaf);
|
|
14
14
|
const flags = opts.filter((o) => o.kind === CliOptionKind.Presence).map((o) => `--${o.name}`);
|
|
15
15
|
if (flags.length > 0) {
|
|
16
16
|
line += ` (flags: ${flags.join(", ")})`;
|
package/src/docs/mcp-guide.ts
CHANGED
|
@@ -2,9 +2,8 @@ import { defaultConfigEntryTitle } from "../config/entry.ts";
|
|
|
2
2
|
import { displayAppConfigPath } from "../config/file.ts";
|
|
3
3
|
import { expectedMcpEntry } from "../configure/artifacts/mcp-config.ts";
|
|
4
4
|
import { resolveClaudeDesktopMcpPath, userHome } from "../configure/artifacts/paths.ts";
|
|
5
|
-
import { collectOptionDefs } from "../core/parse.ts";
|
|
6
5
|
import { CliOptionKind, type CliProgram } from "../core/types.ts";
|
|
7
|
-
import { collectMcpTools, type McpToolDef, mcpServerId, resolveMcpSchemaUri } from "../mcp/tools.ts";
|
|
6
|
+
import { collectMcpTools, leafWireOptions, type McpToolDef, mcpServerId, resolveMcpSchemaUri } from "../mcp/tools.ts";
|
|
8
7
|
import { resolveCapabilities } from "../runtime/capabilities.ts";
|
|
9
8
|
import { resolveDocsTopicResourceUri } from "./mcp-resources.ts";
|
|
10
9
|
import { docsEnabled, docsUserTopicKeys, resolveDocsConfig } from "./resolve.ts";
|
|
@@ -52,7 +51,7 @@ function appendManualClientSetup(
|
|
|
52
51
|
function formatToolLine(root: CliProgram, tool: McpToolDef): string {
|
|
53
52
|
const cliPath = tool.path.length > 0 ? `${root.key} ${tool.path.join(" ")}` : root.key;
|
|
54
53
|
let line = `- \`${cliPath}\` — ${tool.description}`;
|
|
55
|
-
const opts =
|
|
54
|
+
const opts = leafWireOptions(tool.leaf);
|
|
56
55
|
const flags = opts.filter((o) => o.kind === CliOptionKind.Presence).map((o) => `--${o.name}`);
|
|
57
56
|
if (flags.length > 0) {
|
|
58
57
|
line += ` (flags: ${flags.join(", ")})`;
|
package/src/http/openapi.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
Hand-built OpenAPI 3.1 document from exposed HTTP REST routes.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { collectOptionDefs } from "../core/parse.ts";
|
|
6
5
|
import type { CliHttpMethod, CliNode, CliProgram } from "../core/types.ts";
|
|
7
6
|
import { CliOptionKind, isCliLeaf, isJsonLeaf } from "../core/types.ts";
|
|
7
|
+
import { leafWireOptions } from "../mcp/tools.ts";
|
|
8
8
|
import { collectHttpRoutes, defaultSuccessStatus } from "./routes.ts";
|
|
9
9
|
import { dereferenceJsonSchema } from "./schema-deref.ts";
|
|
10
10
|
|
|
@@ -35,7 +35,7 @@ function errorResponseEntry(program: CliProgram, description: string): Record<st
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function buildInputSchema(
|
|
38
|
-
|
|
38
|
+
_program: CliProgram,
|
|
39
39
|
route: ReturnType<typeof collectHttpRoutes>[number],
|
|
40
40
|
): Record<string, unknown> {
|
|
41
41
|
const leaf = route.leaf;
|
|
@@ -48,8 +48,7 @@ function buildInputSchema(
|
|
|
48
48
|
properties[p] = { type: "string" };
|
|
49
49
|
required.push(p);
|
|
50
50
|
}
|
|
51
|
-
const
|
|
52
|
-
for (const opt of collectOptionDefs(program, argv)) {
|
|
51
|
+
for (const opt of leafWireOptions(leaf)) {
|
|
53
52
|
if (opt.kind === CliOptionKind.Json) {
|
|
54
53
|
continue;
|
|
55
54
|
}
|
|
@@ -244,10 +243,7 @@ export function generateOpenApi(program: CliProgram): Record<string, unknown> {
|
|
|
244
243
|
if (method === "get" || method === "delete") {
|
|
245
244
|
op.parameters = [
|
|
246
245
|
...((op.parameters as unknown[]) ?? []),
|
|
247
|
-
...
|
|
248
|
-
program,
|
|
249
|
-
route.commandPath.filter((k) => !k.startsWith(":")),
|
|
250
|
-
).map((opt) => ({
|
|
246
|
+
...leafWireOptions(route.leaf).map((opt) => ({
|
|
251
247
|
name: opt.name,
|
|
252
248
|
in: "query",
|
|
253
249
|
required: opt.required ?? false,
|
package/src/http/routes.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
HTTP REST route collection and request matching from the CLI command tree.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { collectOptionDefs } from "../core/parse.ts";
|
|
6
5
|
import {
|
|
7
6
|
type CliHttpMethod,
|
|
8
7
|
type CliLeaf,
|
|
@@ -12,7 +11,7 @@ import {
|
|
|
12
11
|
isJsonLeaf,
|
|
13
12
|
CliOptionKind as OptKind,
|
|
14
13
|
} from "../core/types.ts";
|
|
15
|
-
import { formatMcpOptionValue } from "../mcp/tools.ts";
|
|
14
|
+
import { formatMcpOptionValue, leafHasYesOption, leafWireOptions } from "../mcp/tools.ts";
|
|
16
15
|
import { isHttpDisabled, isHttpHidden } from "../runtime/exposure.ts";
|
|
17
16
|
import { buildHttpUserPath, httpUserPathRegexPrefix, resolveHttpPathPrefix } from "./paths.ts";
|
|
18
17
|
|
|
@@ -232,7 +231,7 @@ export function matchHttpRoute(program: CliProgram, method: string, pathname: st
|
|
|
232
231
|
|
|
233
232
|
/** Builds argv from an HTTP route match, query string, and optional JSON body. */
|
|
234
233
|
export function httpRequestToArgv(
|
|
235
|
-
|
|
234
|
+
_program: CliProgram,
|
|
236
235
|
route: HttpRouteDef,
|
|
237
236
|
pathParams: Record<string, string>,
|
|
238
237
|
query: Record<string, string>,
|
|
@@ -268,7 +267,7 @@ export function httpRequestToArgv(
|
|
|
268
267
|
}
|
|
269
268
|
}
|
|
270
269
|
|
|
271
|
-
for (const opt of
|
|
270
|
+
for (const opt of leafWireOptions(leaf)) {
|
|
272
271
|
if (opt.kind === OptKind.Json) {
|
|
273
272
|
continue;
|
|
274
273
|
}
|
|
@@ -289,6 +288,10 @@ export function httpRequestToArgv(
|
|
|
289
288
|
argv.push(`--${opt.name}`, formatted);
|
|
290
289
|
}
|
|
291
290
|
|
|
291
|
+
if (leafHasYesOption(leaf) && !argv.includes("--yes")) {
|
|
292
|
+
argv.push("--yes");
|
|
293
|
+
}
|
|
294
|
+
|
|
292
295
|
for (const p of leaf.positionals ?? []) {
|
|
293
296
|
const val = merged[p.name] ?? pathParams[p.name];
|
|
294
297
|
const { argMin = 1, argMax = 1 } = p;
|
|
@@ -95,7 +95,7 @@ describe("hidden commands and options", () => {
|
|
|
95
95
|
|
|
96
96
|
test("MCP tools omit hidden commands", () => {
|
|
97
97
|
const tools = collectMcpTools(hiddenFixture);
|
|
98
|
-
expect(tools.map((t) => t.name)).toEqual(["
|
|
98
|
+
expect(tools.map((t) => t.name)).toEqual(["flags", "public"]);
|
|
99
99
|
});
|
|
100
100
|
});
|
|
101
101
|
|
package/src/mcp/tools.ts
CHANGED
|
@@ -3,7 +3,6 @@ This module maps CliProgram leaf nodes to MCP tool definitions and converts
|
|
|
3
3
|
flat JSON tool arguments into argv for Cli.invoke.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { collectOptionDefs } from "../core/parse.ts";
|
|
7
6
|
import { cliSchemaJson } from "../core/schema.ts";
|
|
8
7
|
import {
|
|
9
8
|
type CliLeaf,
|
|
@@ -23,6 +22,9 @@ import { isMcpHidden, visibleOptions } from "../runtime/exposure.ts";
|
|
|
23
22
|
|
|
24
23
|
const DURATION_PATTERN = "^\\d+[hdms]?$";
|
|
25
24
|
|
|
25
|
+
/** Presence flags omitted from MCP wire schemas (handled by the framework on invoke). */
|
|
26
|
+
const MCP_WIRE_OMIT_PRESENCE = new Set(["json", "yes", "verbose"]);
|
|
27
|
+
|
|
26
28
|
/** Default URI pattern for the CLI schema MCP resource (`<mcpId>://schema`). */
|
|
27
29
|
export function defaultMcpSchemaUri(mcpId: string): string {
|
|
28
30
|
return `${mcpId}://schema`;
|
|
@@ -70,6 +72,18 @@ export function mcpToolName(root: CliProgram, path: string[]): string {
|
|
|
70
72
|
return path.map(sanitizeToolSegment).join("_");
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
/** Leaf options exposed on MCP/HTTP wire schemas (omits framework-handled presence flags). */
|
|
76
|
+
export function leafWireOptions(leaf: CliLeaf): CliOption[] {
|
|
77
|
+
return visibleOptions(leaf.options).filter(
|
|
78
|
+
(opt) => !(opt.kind === CliOptionKind.Presence && MCP_WIRE_OMIT_PRESENCE.has(opt.name)),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when the leaf declares a `yes` presence option (auto-injected on MCP invoke). */
|
|
83
|
+
export function leafHasYesOption(leaf: CliLeaf): boolean {
|
|
84
|
+
return visibleOptions(leaf.options).some((opt) => opt.name === "yes" && opt.kind === CliOptionKind.Presence);
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
/** JSON Schema property for one option. */
|
|
74
88
|
function optionProperty(opt: CliOption): Record<string, unknown> {
|
|
75
89
|
const base: Record<string, unknown> = { description: opt.description };
|
|
@@ -140,7 +154,7 @@ function positionalProperty(p: CliPositional): Record<string, unknown> {
|
|
|
140
154
|
}
|
|
141
155
|
|
|
142
156
|
/** Builds inputSchema for a leaf command. */
|
|
143
|
-
function buildInputSchema(
|
|
157
|
+
function buildInputSchema(leaf: CliLeaf): Record<string, unknown> {
|
|
144
158
|
if (isJsonLeaf(leaf) && leaf.inputSchema !== undefined) {
|
|
145
159
|
return leaf.inputSchema;
|
|
146
160
|
}
|
|
@@ -148,7 +162,7 @@ function buildInputSchema(root: CliProgram, path: string[], leaf: CliLeaf): Reco
|
|
|
148
162
|
const properties: Record<string, unknown> = {};
|
|
149
163
|
const required: string[] = [];
|
|
150
164
|
|
|
151
|
-
for (const opt of
|
|
165
|
+
for (const opt of leafWireOptions(leaf)) {
|
|
152
166
|
properties[opt.name] = optionProperty(opt);
|
|
153
167
|
if (opt.required) {
|
|
154
168
|
required.push(opt.name);
|
|
@@ -237,7 +251,7 @@ export function collectMcpTools(root: CliProgram): McpToolDef[] {
|
|
|
237
251
|
description: resolveToolDescription(root, path, cmd),
|
|
238
252
|
path,
|
|
239
253
|
leaf: cmd,
|
|
240
|
-
inputSchema: cmd.inputSchema ?? buildInputSchema(
|
|
254
|
+
inputSchema: cmd.inputSchema ?? buildInputSchema(cmd),
|
|
241
255
|
...(outputSchema === undefined ? {} : { outputSchema }),
|
|
242
256
|
});
|
|
243
257
|
return;
|
|
@@ -255,7 +269,7 @@ export function collectMcpTools(root: CliProgram): McpToolDef[] {
|
|
|
255
269
|
}
|
|
256
270
|
}
|
|
257
271
|
|
|
258
|
-
return out;
|
|
272
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
259
273
|
}
|
|
260
274
|
|
|
261
275
|
/** Resolves MCP server name and version for initialize. */
|
|
@@ -276,7 +290,7 @@ export function resolveMcpSchemaUri(root: CliProgram): string {
|
|
|
276
290
|
|
|
277
291
|
/** Converts flat MCP tool arguments to argv for Cli.invoke. */
|
|
278
292
|
export function mcpToolCallToArgv(
|
|
279
|
-
|
|
293
|
+
_root: CliProgram,
|
|
280
294
|
tool: McpToolDef,
|
|
281
295
|
args: Record<string, unknown>,
|
|
282
296
|
): string[] | { error: string } {
|
|
@@ -286,7 +300,7 @@ export function mcpToolCallToArgv(
|
|
|
286
300
|
|
|
287
301
|
const argv = [...tool.path];
|
|
288
302
|
|
|
289
|
-
for (const opt of
|
|
303
|
+
for (const opt of leafWireOptions(tool.leaf)) {
|
|
290
304
|
if (opt.kind === CliOptionKind.Json) {
|
|
291
305
|
continue;
|
|
292
306
|
}
|
|
@@ -307,6 +321,10 @@ export function mcpToolCallToArgv(
|
|
|
307
321
|
argv.push(`--${opt.name}`, formatted);
|
|
308
322
|
}
|
|
309
323
|
|
|
324
|
+
if (leafHasYesOption(tool.leaf) && !argv.includes("--yes")) {
|
|
325
|
+
argv.push("--yes");
|
|
326
|
+
}
|
|
327
|
+
|
|
310
328
|
for (const p of tool.leaf.positionals ?? []) {
|
|
311
329
|
const val = args[p.name];
|
|
312
330
|
const { argMin = 1, argMax = 1 } = p;
|
package/src/skill/generate.ts
CHANGED
|
@@ -3,11 +3,11 @@ This module generates Agent Skills content (SKILL.md + reference.md) from a CLI
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { defaultConfigEntryTitle } from "../config/entry.ts";
|
|
6
|
-
import { collectOptionDefs } from "../core/parse.ts";
|
|
7
6
|
import { CliOptionKind, type CliProgram } from "../core/types.ts";
|
|
8
7
|
import { generateCliGuide } from "../docs/cli-guide.ts";
|
|
9
8
|
import {
|
|
10
9
|
collectMcpTools,
|
|
10
|
+
leafWireOptions,
|
|
11
11
|
type McpToolDef,
|
|
12
12
|
mcpServerId,
|
|
13
13
|
resolveMcpSchemaUri,
|
|
@@ -69,7 +69,7 @@ function commandCatalogPath(root: CliProgram, tool: McpToolDef): string {
|
|
|
69
69
|
function formatCommandEntry(root: CliProgram, tool: McpToolDef): string {
|
|
70
70
|
const cliPath = commandCatalogPath(root, tool);
|
|
71
71
|
let line = `- **\`${cliPath}\`** — ${tool.leaf.description}`;
|
|
72
|
-
const opts =
|
|
72
|
+
const opts = leafWireOptions(tool.leaf);
|
|
73
73
|
const flags = opts.filter((o) => o.kind === CliOptionKind.Presence).map((o) => `--${o.name}`);
|
|
74
74
|
if (flags.length > 0) {
|
|
75
75
|
line += ` (flags: ${flags.join(", ")})`;
|
package/src/test/fixtures.ts
CHANGED
|
@@ -14,13 +14,6 @@ export const nestedMcpFixture = testProgram({
|
|
|
14
14
|
{
|
|
15
15
|
key: "stat",
|
|
16
16
|
description: "File metadata.",
|
|
17
|
-
options: [
|
|
18
|
-
{
|
|
19
|
-
name: "json",
|
|
20
|
-
description: "Emit handler output as JSON.",
|
|
21
|
-
kind: CliOptionKind.Presence,
|
|
22
|
-
},
|
|
23
|
-
],
|
|
24
17
|
commands: [
|
|
25
18
|
{
|
|
26
19
|
key: "owner",
|
|
@@ -9,7 +9,14 @@ import { cliValidateProgram } from "../../core/validate.ts";
|
|
|
9
9
|
import { generateOpenApi } from "../../http/openapi.ts";
|
|
10
10
|
import { API_CORS_HEADERS } from "../../http/result.ts";
|
|
11
11
|
import { handleApiRequest } from "../../http/server.ts";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
Cli,
|
|
14
|
+
CliContext,
|
|
15
|
+
type CliContext as CliContextType,
|
|
16
|
+
CliOptionKind,
|
|
17
|
+
cliErrWithHelp,
|
|
18
|
+
wantsExplicitJson,
|
|
19
|
+
} from "../../index.ts";
|
|
13
20
|
import { LogEmitter } from "../../log/emitter.ts";
|
|
14
21
|
import { createServerRuntime } from "../../server/context.ts";
|
|
15
22
|
import { resolveHttpServeConfig } from "../../server/overrides.ts";
|
|
@@ -24,13 +31,6 @@ function nestedApiFixture() {
|
|
|
24
31
|
{
|
|
25
32
|
key: "stat",
|
|
26
33
|
description: "File metadata.",
|
|
27
|
-
options: [
|
|
28
|
-
{
|
|
29
|
-
name: "json",
|
|
30
|
-
description: "Emit handler output as JSON.",
|
|
31
|
-
kind: CliOptionKind.Presence,
|
|
32
|
-
},
|
|
33
|
-
],
|
|
34
34
|
commands: [
|
|
35
35
|
{
|
|
36
36
|
key: "owner",
|
|
@@ -40,6 +40,11 @@ function nestedApiFixture() {
|
|
|
40
40
|
key: "lookup",
|
|
41
41
|
description: "Resolve owner info.",
|
|
42
42
|
options: [
|
|
43
|
+
{
|
|
44
|
+
name: "json",
|
|
45
|
+
description: "Emit handler output as JSON.",
|
|
46
|
+
kind: CliOptionKind.Presence,
|
|
47
|
+
},
|
|
43
48
|
{
|
|
44
49
|
name: "user-name",
|
|
45
50
|
description: "User to look up.",
|
|
@@ -57,7 +62,7 @@ function nestedApiFixture() {
|
|
|
57
62
|
handler: (ctx: CliContextType) => {
|
|
58
63
|
const user = ctx.stringOpt("user-name") ?? "unknown";
|
|
59
64
|
const path = ctx.positional("path") ?? "";
|
|
60
|
-
if (ctx.hasFlag("json")) {
|
|
65
|
+
if (wantsExplicitJson(ctx, ctx.hasFlag("json"))) {
|
|
61
66
|
return { user, path };
|
|
62
67
|
}
|
|
63
68
|
return `lookup user=${user} path=${path}`;
|
|
@@ -318,7 +323,7 @@ describe("HTTP API routes", () => {
|
|
|
318
323
|
expect(await res.json()).toEqual({ user: "alice", path: readme });
|
|
319
324
|
});
|
|
320
325
|
|
|
321
|
-
test("POST /api/... returns
|
|
326
|
+
test("POST /api/... returns JSON body by default with 201", async () => {
|
|
322
327
|
const readme = join(import.meta.dir, "..", "..", "..", "README.md");
|
|
323
328
|
const res = await apiRequest(
|
|
324
329
|
program,
|
|
@@ -329,8 +334,8 @@ describe("HTTP API routes", () => {
|
|
|
329
334
|
}),
|
|
330
335
|
);
|
|
331
336
|
expect(res.status).toBe(201);
|
|
332
|
-
|
|
333
|
-
expect(
|
|
337
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
338
|
+
expect(await res.json()).toEqual({ user: "alice", path: readme });
|
|
334
339
|
});
|
|
335
340
|
|
|
336
341
|
test("POST /tools returns 404 (legacy path removed)", async () => {
|
|
@@ -153,11 +153,12 @@ test("outputSchema must be a JSON Schema object", () => {
|
|
|
153
153
|
expect(() => cliValidateProgram(root)).toThrow(/outputSchema must be a JSON Schema object/);
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
-
test("collectMcpTools
|
|
156
|
+
test("collectMcpTools uses leaf-local options in inputSchema", () => {
|
|
157
157
|
const tools = collectMcpTools(nestedMcpFixture);
|
|
158
158
|
const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
|
|
159
159
|
const schema = lookup.inputSchema as { properties: Record<string, unknown>; required?: string[] };
|
|
160
|
-
expect(schema.properties.json).
|
|
160
|
+
expect(schema.properties.json).toBeUndefined();
|
|
161
|
+
expect(schema.properties["user-name"]).toBeDefined();
|
|
161
162
|
expect(schema.required).toContain("path");
|
|
162
163
|
});
|
|
163
164
|
|
|
@@ -202,9 +203,8 @@ test("mcpToolCallToArgv builds nested lookup argv", () => {
|
|
|
202
203
|
const argv = mcpToolCallToArgv(nestedMcpFixture, lookup, {
|
|
203
204
|
"user-name": "alice",
|
|
204
205
|
path: "./x",
|
|
205
|
-
json: true,
|
|
206
206
|
});
|
|
207
|
-
expect(argv).toEqual(["stat", "owner", "lookup", "--
|
|
207
|
+
expect(argv).toEqual(["stat", "owner", "lookup", "--user-name", "alice", "./x"]);
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
test("mcpToolCallToArgv expands varargs positionals", () => {
|
|
@@ -386,10 +386,10 @@ test("MCP tools/call runs stat_owner_lookup", async () => {
|
|
|
386
386
|
},
|
|
387
387
|
]);
|
|
388
388
|
const res = responses.get(4) as {
|
|
389
|
-
result: { content: { text: string }[]; structuredContent?: {
|
|
389
|
+
result: { content: { text: string }[]; structuredContent?: { user: string; path: string }; isError: boolean };
|
|
390
390
|
};
|
|
391
391
|
expect(res.result.isError).toBe(false);
|
|
392
|
-
expect(res.result.structuredContent
|
|
392
|
+
expect(res.result.structuredContent).toEqual({ user: "test", path: readme });
|
|
393
393
|
});
|
|
394
394
|
|
|
395
395
|
/** MCP tools/call returns structuredContent for JSON stdout. */
|