argsbarg 7.0.3 → 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 CHANGED
@@ -7,6 +7,12 @@ 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
+
10
16
  ## [7.0.3] - 2026-08-13
11
17
 
12
18
  ### Fixed
@@ -953,7 +959,8 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
953
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`).
954
960
  - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
955
961
 
956
- [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v7.0.3...HEAD
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
957
964
  [7.0.3]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.3
958
965
  [7.0.2]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.2
959
966
  [7.0.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.1
@@ -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** — parent-scoped flags are included (e.g. `stat`’s `--json` appears on `stat_owner_lookup`). Presence options are `boolean`; string, number, and **enum** options match their `CliOptionKind` (`Enum` uses JSON Schema `enum`). Required options are listed in `required`.
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"`).
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "7.0.3",
3
+ "version": "7.0.4",
4
4
  "main": "./src/index.ts",
5
5
  "module": "./src/index.ts",
6
6
  "dependencies": {
@@ -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 trailing options include parent-scoped flags. */
374
- test("trailing options include parent-scoped flags", () => {
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 pr = postParseValidate(root, parse(root, ["group", "leaf", "-u", "alice", "./file", "--json"]));
416
- expect(pr.kind).toBe(ParseKind.Ok);
417
- expect(pr.path).toEqual(["group", "leaf"]);
418
- expect(pr.args).toEqual(["./file"]);
419
- expect(pr.opts.user).toBe("alice");
420
- expect(pr.opts.json).toBe("1");
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
- /** Merges option defs from the program root along the routed command path. */
248
- export function collectOptionDefs(root: CliNode, path: string[]): CliOption[] {
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)) break;
272
+ if (!isCliRouter(node)) {
273
+ break;
274
+ }
254
275
  const ch = findChild(node.commands, seg);
255
- if (!ch) break;
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(collectOptionDefs(root, path), false, argv, i, opts);
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, collectOptionDefs(root, path), forcePositionals, pathParams);
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, collectOptionDefs(root, path), forcePositionals, pathParams);
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, merging in-scope options along `pr.path`.
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 = [...(root.options ?? [])];
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) {
@@ -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
- const positionals = isCliLeaf(node) ? (node.positionals ?? []) : [];
371
- validateOptions(node.key, node.options ?? []);
372
- validatePositionals(node.key, positionals);
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 {
@@ -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 = collectOptionDefs(root, route.commandPath);
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(", ")})`;
@@ -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 = collectOptionDefs(root, tool.path);
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(", ")})`;
@@ -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
- program: CliProgram,
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 argv = route.commandPath.filter((k) => !k.startsWith(":"));
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
- ...collectOptionDefs(
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,
@@ -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
- program: CliProgram,
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 collectOptionDefs(program, argv)) {
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(["public", "flags"]);
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(root: CliProgram, path: string[], leaf: CliLeaf): Record<string, unknown> {
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 visibleOptions(collectOptionDefs(root, path))) {
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(root, path, cmd),
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
- root: CliProgram,
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 collectOptionDefs(root, tool.path)) {
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;
@@ -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 = collectOptionDefs(root, tool.path);
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(", ")})`;
@@ -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 { Cli, CliContext, type CliContext as CliContextType, CliOptionKind, cliErrWithHelp } from "../../index.ts";
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 raw text body with 201", async () => {
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
- const text = await res.text();
333
- expect(text).toContain("lookup user=alice");
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 merges parent options into inputSchema", () => {
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).toBeDefined();
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", "--json", "--user-name", "alice", "./x"]);
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?: { content: string }; isError: boolean };
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?.content).toContain("lookup user=test");
392
+ expect(res.result.structuredContent).toEqual({ user: "test", path: readme });
393
393
  });
394
394
 
395
395
  /** MCP tools/call returns structuredContent for JSON stdout. */