argsbarg 7.0.2 → 7.0.4
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/config/validate.test.ts +21 -1
- package/src/config/validate.ts +12 -2
- package/src/core/parse.test.ts +35 -8
- package/src/core/parse.ts +41 -22
- 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.4] - 2026-08-17
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
16
|
+
## [7.0.3] - 2026-08-13
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **Partial config validation** — skip attaching empty `definitions` / `$defs` companion schemas (fixes `Duplicate schema URI "https://github.com/cfworker"` when schemagen emits `"definitions": {}`).
|
|
21
|
+
|
|
10
22
|
## [7.0.2] - 2026-08-13
|
|
11
23
|
|
|
12
24
|
|
|
@@ -947,7 +959,9 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
|
|
|
947
959
|
- 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`).
|
|
948
960
|
- Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
|
|
949
961
|
|
|
950
|
-
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v7.0.
|
|
962
|
+
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v7.0.4...HEAD
|
|
963
|
+
[7.0.4]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.4
|
|
964
|
+
[7.0.3]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.3
|
|
951
965
|
[7.0.2]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.2
|
|
952
966
|
[7.0.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.1
|
|
953
967
|
[7.0.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.0
|
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
|
@@ -3,7 +3,12 @@ Tests for config/validate module behavior.
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
parseConfigSetValue,
|
|
8
|
+
resolveSchemaDraft,
|
|
9
|
+
validateConfigDocument,
|
|
10
|
+
validateConfigDocumentPartial,
|
|
11
|
+
} from "./validate.ts";
|
|
7
12
|
|
|
8
13
|
const rootSchema = {
|
|
9
14
|
type: "object",
|
|
@@ -121,6 +126,21 @@ describe("config/validate", () => {
|
|
|
121
126
|
expect(validateConfigDocument({}, schema).valid).toBe(false);
|
|
122
127
|
});
|
|
123
128
|
|
|
129
|
+
test("validateConfigDocumentPartial accepts schemagen root with empty definitions", () => {
|
|
130
|
+
const schema = {
|
|
131
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
132
|
+
type: "object",
|
|
133
|
+
additionalProperties: false,
|
|
134
|
+
required: ["email"],
|
|
135
|
+
properties: {
|
|
136
|
+
email: { type: "string" },
|
|
137
|
+
services: { type: "array", items: { type: "string" } },
|
|
138
|
+
},
|
|
139
|
+
definitions: {},
|
|
140
|
+
};
|
|
141
|
+
expect(validateConfigDocumentPartial({ email: "a@example.com", services: ["a"] }, schema).valid).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
124
144
|
test("validates draft 2020-12 $defs refs", () => {
|
|
125
145
|
const schema = {
|
|
126
146
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
package/src/config/validate.ts
CHANGED
|
@@ -129,10 +129,20 @@ function attachRootCompanionSchemas(validator: Validator, root: JsonSchema, acti
|
|
|
129
129
|
return;
|
|
130
130
|
}
|
|
131
131
|
const companion: Schema = {};
|
|
132
|
-
if (
|
|
132
|
+
if (
|
|
133
|
+
typeof root.definitions === "object" &&
|
|
134
|
+
root.definitions !== null &&
|
|
135
|
+
!Array.isArray(root.definitions) &&
|
|
136
|
+
Object.keys(root.definitions).length > 0
|
|
137
|
+
) {
|
|
133
138
|
companion.definitions = root.definitions;
|
|
134
139
|
}
|
|
135
|
-
if (
|
|
140
|
+
if (
|
|
141
|
+
typeof root.$defs === "object" &&
|
|
142
|
+
root.$defs !== null &&
|
|
143
|
+
!Array.isArray(root.$defs) &&
|
|
144
|
+
Object.keys(root.$defs).length > 0
|
|
145
|
+
) {
|
|
136
146
|
companion.$defs = root.$defs;
|
|
137
147
|
}
|
|
138
148
|
if (Object.keys(companion).length > 0) {
|
package/src/core/parse.test.ts
CHANGED
|
@@ -370,8 +370,8 @@ 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 on routing groups are rejected at schema validation. */
|
|
374
|
+
test("rejects options on routing groups", () => {
|
|
375
375
|
const root = testProgram({
|
|
376
376
|
key: "app",
|
|
377
377
|
description: "",
|
|
@@ -386,11 +386,38 @@ test("trailing options include parent-scoped flags", () => {
|
|
|
386
386
|
kind: CliOptionKind.Presence,
|
|
387
387
|
},
|
|
388
388
|
],
|
|
389
|
+
commands: [
|
|
390
|
+
{
|
|
391
|
+
key: "leaf",
|
|
392
|
+
description: "leaf",
|
|
393
|
+
handler: () => {},
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
});
|
|
399
|
+
expect(() => cliValidateProgram(root)).toThrow(/routing group/);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
/** Tests that leaf flags are only accepted on the leaf command segment. */
|
|
403
|
+
test("leaf flags are only accepted on the leaf command segment", () => {
|
|
404
|
+
const root = testProgram({
|
|
405
|
+
key: "app",
|
|
406
|
+
description: "",
|
|
407
|
+
commands: [
|
|
408
|
+
{
|
|
409
|
+
key: "group",
|
|
410
|
+
description: "group",
|
|
389
411
|
commands: [
|
|
390
412
|
{
|
|
391
413
|
key: "leaf",
|
|
392
414
|
description: "leaf",
|
|
393
415
|
options: [
|
|
416
|
+
{
|
|
417
|
+
name: "json",
|
|
418
|
+
description: "",
|
|
419
|
+
kind: CliOptionKind.Presence,
|
|
420
|
+
},
|
|
394
421
|
{
|
|
395
422
|
name: "user",
|
|
396
423
|
description: "",
|
|
@@ -412,12 +439,12 @@ test("trailing options include parent-scoped flags", () => {
|
|
|
412
439
|
],
|
|
413
440
|
});
|
|
414
441
|
cliValidateProgram(root);
|
|
415
|
-
const
|
|
416
|
-
expect(
|
|
417
|
-
expect(
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
expect(
|
|
442
|
+
const ok = postParseValidate(root, parse(root, ["group", "leaf", "-u", "alice", "./file", "--json"]));
|
|
443
|
+
expect(ok.kind).toBe(ParseKind.Ok);
|
|
444
|
+
expect(ok.opts.json).toBe("1");
|
|
445
|
+
|
|
446
|
+
const bad = parse(root, ["group", "--json", "leaf", "-u", "alice", "./file"]);
|
|
447
|
+
expect(bad.kind).toBe(ParseKind.Error);
|
|
421
448
|
});
|
|
422
449
|
|
|
423
450
|
/** 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,
|
|
@@ -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. */
|