argsbarg 6.1.1 → 6.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ ## [6.1.2] - 2026-07-23
11
+
12
+ ### Added
13
+
14
+ - **`kind: "json"` on `CliLeaf`** — pure JSON body leaves with no CLI flags. Requires `inputSchema`; forbids `options` and `positionals`. CLI accepts one JSON positional or piped stdin; MCP/HTTP use the tool args object directly. **`isJsonLeaf()`** helper exported.
15
+
10
16
  ## [6.1.1] - 2026-07-23
11
17
 
12
18
  ### Added
@@ -764,7 +770,8 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
764
770
  - 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`).
765
771
  - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
766
772
 
767
- [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v6.1.1...HEAD
773
+ [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v6.1.2...HEAD
774
+ [6.1.2]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.1.2
768
775
  [6.1.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.1.1
769
776
  [6.1.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.1.0
770
777
  [6.0.2]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.0.2
@@ -255,6 +255,30 @@ Use **`ctx.jsonOpt("invoice")`**, **`ctx.inputs`**, or **`ctx.inputsAs<MyInput>(
255
255
 
256
256
  At most one `pipable` Json option per leaf. Json option names must appear in `inputSchema.properties` when a custom `inputSchema` is set.
257
257
 
258
+ ### Pure JSON leaves (`kind: "json"`)
259
+
260
+ When the entire tool body is JSON (no CLI flags), set **`kind: "json"`** on the leaf with **`inputSchema`** and **no `options` or `positionals`**:
261
+
262
+ ```typescript
263
+ {
264
+ key: "render-invoice",
265
+ description: "Render an invoice from template data",
266
+ kind: "json",
267
+ inputSchema,
268
+ handler: (ctx) => {
269
+ const { format, invoice } = ctx.inputsAs<RenderInvoiceInput>();
270
+ // ...
271
+ },
272
+ }
273
+ ```
274
+
275
+ | Surface | How input is supplied |
276
+ | --- | --- |
277
+ | CLI | One JSON positional **or** pipe a JSON document to stdin |
278
+ | MCP / HTTP | Full tool args object (`ctx.toolArgs` / POST body) |
279
+
280
+ Example CLI: `jq '{format:"pdf", invoice:.}' data.json | myapp render-invoice`
281
+
258
282
  See [output-schema.md](output-schema.md) for schemagen `inputType` and [api-server.md](api-server.md) for HTTP tool bodies.
259
283
 
260
284
  `CliLeafInputs` is intentionally untyped at the framework level. Narrow in your app (`read*Flags(ctx)` returning a typed struct) rather than expecting inference from `satisfies CliLeaf`.
package/index.d.ts CHANGED
@@ -490,10 +490,17 @@ export interface CliNodeBase {
490
490
  /** Global or command-level flags/options. */
491
491
  options?: CliOption[];
492
492
  }
493
+ /** Leaf input mode: `json` = pure JSON body (no CLI flags). */
494
+ export type CliLeafKind = "json";
493
495
  /**
494
496
  * A leaf command node with a handler and optional positionals.
495
497
  */
496
498
  export type CliLeaf = CliNodeBase & {
499
+ /**
500
+ * When `"json"`, the leaf accepts a single JSON document (CLI positional or piped stdin;
501
+ * MCP/HTTP tool args = body). Requires `inputSchema`; forbids `options` and `positionals`.
502
+ */
503
+ kind?: CliLeafKind;
497
504
  /** Handler function for leaf commands. */
498
505
  handler: CliHandler;
499
506
  /** Positional argument definitions. */
@@ -545,6 +552,8 @@ export type CliProgram = CliNode & {
545
552
  /** When set with `enabled: true`, enables the `docs` built-in command group. */
546
553
  docs?: CliDocsConfig;
547
554
  };
555
+ /** True when the leaf accepts a pure JSON body (no CLI flags). */
556
+ export declare function isJsonLeaf(leaf: CliLeaf): boolean;
548
557
  /**
549
558
  * Handler closure type for leaf commands.
550
559
  * Supports sync and async handlers; non-undefined return values become implicit JSON responses for headless invocations.
@@ -672,7 +681,7 @@ export declare function readJsonOptionValue(ctx: CliContext, name: string): unkn
672
681
  * Reads piped stdin for a pipable Json option when the flag is omitted (CLI only).
673
682
  * Call from {@link Cli.run} before constructing the handler context.
674
683
  */
675
- export declare function preloadPipableJson(program: CliProgram, commandPath: string[], opts: Record<string, string>, invocation: CliInvocation): Promise<Record<string, unknown>>;
684
+ export declare function preloadPipableJson(program: CliProgram, commandPath: string[], opts: Record<string, string>, invocation: CliInvocation, args?: string[]): Promise<Record<string, unknown>>;
676
685
  /**
677
686
  * Loads coerced leaf inputs and validates against `leaf.inputSchema` when set.
678
687
  * Used by {@link CliContext.inputs}; prefer `ctx.inputs` or `ctx.inputsAs()` in handlers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "6.1.1",
3
+ "version": "6.1.2",
4
4
  "main": "./src/index.ts",
5
5
  "module": "./src/index.ts",
6
6
  "dependencies": {
package/src/cli.ts CHANGED
@@ -127,7 +127,7 @@ export class Cli {
127
127
 
128
128
  let preloadedJson: Record<string, unknown> = {};
129
129
  try {
130
- preloadedJson = await preloadPipableJson(this.program, pr.path, pr.opts, "cli");
130
+ preloadedJson = await preloadPipableJson(this.program, pr.path, pr.opts, "cli", pr.args);
131
131
  } catch (err) {
132
132
  if (err instanceof LeafInputError) {
133
133
  this.exitLeafInputError(err, pr.path);
package/src/help.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  type CliRouter,
17
17
  isCliLeaf,
18
18
  isCliRouter,
19
+ isJsonLeaf,
19
20
  } from "./types.ts";
20
21
 
21
22
  // ── ANSI Style Helpers ────────────────────────────────────────────────────────
@@ -328,6 +329,7 @@ function usageLines(
328
329
  helpPath: string[],
329
330
  hasCommands: boolean,
330
331
  hasArgs: boolean,
332
+ jsonLeaf: boolean,
331
333
  color: boolean,
332
334
  ): string[] {
333
335
  let fullPath = appName;
@@ -337,6 +339,7 @@ function usageLines(
337
339
  const usageOpts = color ? style.aquaBold("[OPTIONS]") : "[OPTIONS]";
338
340
  const usageCmd = color ? style.aquaBold("COMMAND") : "COMMAND";
339
341
  const usageArgs = color ? style.aquaBold("[ARGS]...") : "[ARGS]...";
342
+ const usageJson = color ? style.aquaBold("[JSON]") : "[JSON]";
340
343
 
341
344
  const out: string[] = [];
342
345
  if (helpPath.length === 0) {
@@ -347,6 +350,10 @@ function usageLines(
347
350
  }
348
351
  return out;
349
352
  }
353
+ if (jsonLeaf) {
354
+ out.push(`${fullPath} ${usageJson}`);
355
+ return out;
356
+ }
350
357
  out.push(`${fullPath} ${usageOpts}${hasArgs ? ` ${usageArgs}` : ""}`);
351
358
  if (hasCommands) {
352
359
  out.push(`${fullPath} ${usageCmd} ${usageArgs}`);
@@ -354,6 +361,25 @@ function usageLines(
354
361
  return out;
355
362
  }
356
363
 
364
+ /** Table rows for `kind: "json"` leaf input (schema properties + stdin hint). */
365
+ function rowsForJsonInput(inputSchema: Record<string, unknown> | undefined): HelpRow[] {
366
+ const hint = "Pass a JSON document as an argument or pipe to stdin.";
367
+ const rows: HelpRow[] = [{ label: "JSON", description: hint }];
368
+ const props = inputSchema?.properties;
369
+ if (!props || typeof props !== "object" || Array.isArray(props)) {
370
+ return rows;
371
+ }
372
+ const required = new Set(Array.isArray(inputSchema?.required) ? inputSchema.required.map((k) => String(k)) : []);
373
+ for (const [name, prop] of Object.entries(props as Record<string, { description?: string }>)) {
374
+ const desc = prop.description ?? "";
375
+ rows.push({
376
+ label: name,
377
+ description: required.has(name) ? `(required) ${desc}` : desc,
378
+ });
379
+ }
380
+ return rows;
381
+ }
382
+
357
383
  /** Table rows for named options, including synthetic built-in rows. */
358
384
  function rowsForOptions(defs: CliOption[], color: boolean): HelpRow[] {
359
385
  const rows: HelpRow[] = [];
@@ -407,7 +433,7 @@ export function cliHelpRender(schema: CliRouter, helpPath: string[], _useStderr:
407
433
  lines.push(
408
434
  renderTextBox(
409
435
  "Usage",
410
- usageLines(schema.key, helpPath, (schema.commands ?? []).length > 0, false, color),
436
+ usageLines(schema.key, helpPath, (schema.commands ?? []).length > 0, false, false, color),
411
437
  hw,
412
438
  color,
413
439
  ).join("\n"),
@@ -446,6 +472,7 @@ export function cliHelpRender(schema: CliRouter, helpPath: string[], _useStderr:
446
472
  lines.push(color ? style.white(node.description) : node.description);
447
473
  lines.push("");
448
474
  }
475
+ const nodeIsJsonLeaf = isCliLeaf(node) && isJsonLeaf(node);
449
476
  lines.push(
450
477
  renderTextBox(
451
478
  "Usage",
@@ -454,6 +481,7 @@ export function cliHelpRender(schema: CliRouter, helpPath: string[], _useStderr:
454
481
  helpPath,
455
482
  isCliRouter(node) && node.commands.length > 0,
456
483
  isCliLeaf(node) && (node.positionals ?? []).length > 0,
484
+ nodeIsJsonLeaf,
457
485
  color,
458
486
  ),
459
487
  hw,
@@ -461,21 +489,29 @@ export function cliHelpRender(schema: CliRouter, helpPath: string[], _useStderr:
461
489
  ).join("\n"),
462
490
  );
463
491
 
464
- const optBox = renderTableBox("Options", rowsForOptions(visibleOptions(node.options), color), hw, color);
465
- if (optBox.length > 0) {
466
- lines.push("");
467
- lines.push(optBox.join("\n"));
468
- }
492
+ if (nodeIsJsonLeaf && isCliLeaf(node)) {
493
+ const inputBox = renderTableBox("Input", rowsForJsonInput(node.inputSchema), hw, color);
494
+ if (inputBox.length > 0) {
495
+ lines.push("");
496
+ lines.push(inputBox.join("\n"));
497
+ }
498
+ } else {
499
+ const optBox = renderTableBox("Options", rowsForOptions(visibleOptions(node.options), color), hw, color);
500
+ if (optBox.length > 0) {
501
+ lines.push("");
502
+ lines.push(optBox.join("\n"));
503
+ }
469
504
 
470
- const posBox = renderTableBox(
471
- "Arguments",
472
- rowsForPositionals(isCliLeaf(node) ? (node.positionals ?? []) : [], color),
473
- hw,
474
- color,
475
- );
476
- if (posBox.length > 0) {
477
- lines.push("");
478
- lines.push(posBox.join("\n"));
505
+ const posBox = renderTableBox(
506
+ "Arguments",
507
+ rowsForPositionals(isCliLeaf(node) ? (node.positionals ?? []) : [], color),
508
+ hw,
509
+ color,
510
+ );
511
+ if (posBox.length > 0) {
512
+ lines.push("");
513
+ lines.push(posBox.join("\n"));
514
+ }
479
515
  }
480
516
 
481
517
  const subcmds = isCliRouter(node) ? node.commands : [];
package/src/index.ts CHANGED
@@ -51,6 +51,7 @@ export type {
51
51
  CliDocsTopic,
52
52
  CliHandler,
53
53
  CliInvocation,
54
+ CliLeafKind,
54
55
  CliMcpBundleConfig,
55
56
  CliMcpResource,
56
57
  CliMcpServerConfig,
@@ -69,5 +70,6 @@ export {
69
70
  CliOptionKind,
70
71
  CliSchemaValidationError,
71
72
  CliValueFormat,
73
+ isJsonLeaf,
72
74
  } from "./types.ts";
73
75
  export { isInteractiveTty } from "./utils.ts";
@@ -0,0 +1,156 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Cli } from "./index.ts";
3
+ import { ParseKind, parse } from "./parse.ts";
4
+ import { CliOptionKind, type CliProgram, CliSchemaValidationError } from "./types.ts";
5
+ import { cliValidateProgram } from "./validate.ts";
6
+
7
+ const bodySchema = {
8
+ type: "object",
9
+ properties: {
10
+ format: { type: "string", enum: ["pdf", "html"] },
11
+ invoice: {
12
+ type: "object",
13
+ properties: { id: { type: "string" } },
14
+ required: ["id"],
15
+ additionalProperties: false,
16
+ },
17
+ },
18
+ required: ["format", "invoice"],
19
+ additionalProperties: false,
20
+ } as const;
21
+
22
+ function jsonLeafProgram() {
23
+ return {
24
+ key: "json-leaf-test",
25
+ version: "1.0.0",
26
+ description: "json leaf tests",
27
+ commands: [
28
+ {
29
+ key: "render",
30
+ description: "Render from JSON body",
31
+ kind: "json",
32
+ inputSchema: bodySchema,
33
+ handler: (ctx) => ctx.inputsAs<{ format: string; invoice: { id: string } }>(),
34
+ },
35
+ ],
36
+ } satisfies CliProgram;
37
+ }
38
+
39
+ describe("kind: json leaf", () => {
40
+ test("validate requires inputSchema and forbids options/positionals", () => {
41
+ expect(() =>
42
+ cliValidateProgram({
43
+ key: "bad",
44
+ version: "1",
45
+ description: "bad",
46
+ commands: [{ key: "x", description: "x", kind: "json", handler: () => {} }],
47
+ }),
48
+ ).toThrow(CliSchemaValidationError);
49
+
50
+ expect(() =>
51
+ cliValidateProgram({
52
+ key: "bad",
53
+ version: "1",
54
+ description: "bad",
55
+ commands: [
56
+ {
57
+ key: "x",
58
+ description: "x",
59
+ kind: "json",
60
+ inputSchema: bodySchema,
61
+ options: [{ name: "f", description: "f", kind: CliOptionKind.String }],
62
+ handler: () => {},
63
+ },
64
+ ],
65
+ }),
66
+ ).toThrow(CliSchemaValidationError);
67
+
68
+ expect(() =>
69
+ cliValidateProgram({
70
+ key: "bad",
71
+ version: "1",
72
+ description: "bad",
73
+ commands: [
74
+ {
75
+ key: "x",
76
+ description: "x",
77
+ kind: "json",
78
+ inputSchema: bodySchema,
79
+ positionals: [{ name: "file", description: "file", kind: CliOptionKind.String }],
80
+ handler: () => {},
81
+ },
82
+ ],
83
+ }),
84
+ ).toThrow(CliSchemaValidationError);
85
+ });
86
+
87
+ test("parse rejects CLI flags on json leaf", () => {
88
+ const root = jsonLeafProgram();
89
+ const pr = parse(root, ["render", "--format", "pdf"]);
90
+ expect(pr.kind).toBe(ParseKind.Error);
91
+ expect(pr.errorMsg).toContain("JSON commands do not accept options");
92
+ });
93
+
94
+ test("parse accepts JSON positional", () => {
95
+ const root = jsonLeafProgram();
96
+ const pr = parse(root, ["render", '{"format":"pdf","invoice":{"id":"1"}}']);
97
+ expect(pr.kind).toBe(ParseKind.Ok);
98
+ expect(pr.args).toEqual(['{"format":"pdf","invoice":{"id":"1"}}']);
99
+ });
100
+
101
+ test("invoke reads body from toolArgs", async () => {
102
+ const cli = new Cli(jsonLeafProgram());
103
+ const result = await cli.invoke(["render"], {
104
+ invocation: "api",
105
+ toolArgs: { format: "pdf", invoice: { id: "INV-1" } },
106
+ });
107
+ expect(result.kind).toBe("ok");
108
+ expect(result.response?.body).toEqual({ format: "pdf", invoice: { id: "INV-1" } });
109
+ });
110
+
111
+ test("invoke reads body from JSON positional argv", async () => {
112
+ const cli = new Cli(jsonLeafProgram());
113
+ const result = await cli.invoke(["render", '{"format":"html","invoice":{"id":"2"}}'], {
114
+ invocation: "mcp",
115
+ });
116
+ expect(result.kind).toBe("ok");
117
+ expect(result.response?.body).toEqual({ format: "html", invoice: { id: "2" } });
118
+ });
119
+
120
+ test("invoke errors when body missing", async () => {
121
+ const cli = new Cli(jsonLeafProgram());
122
+ const result = await cli.invoke(["render"], { invocation: "api" });
123
+ expect(result.kind).toBe("error");
124
+ expect(result.errorMsg).toContain("Missing JSON input");
125
+ });
126
+
127
+ test("invoke validates inputSchema before handler", async () => {
128
+ let called = false;
129
+ const base = jsonLeafProgram();
130
+ const program = {
131
+ ...base,
132
+ commands: [
133
+ {
134
+ ...base.commands[0],
135
+ handler: () => {
136
+ called = true;
137
+ },
138
+ },
139
+ ],
140
+ } satisfies CliProgram;
141
+ const cli = new Cli(program);
142
+ const result = await cli.invoke(["render"], {
143
+ invocation: "api",
144
+ toolArgs: { format: "pdf", invoice: { id: 123 } },
145
+ });
146
+ expect(result.kind).toBe("error");
147
+ expect(called).toBe(false);
148
+ });
149
+
150
+ test("non-object JSON body returns error", async () => {
151
+ const cli = new Cli(jsonLeafProgram());
152
+ const result = await cli.invoke(["render", '"not-an-object"'], { invocation: "cli" });
153
+ expect(result.kind).toBe("error");
154
+ expect(result.errorMsg).toContain("JSON input must be a JSON object");
155
+ });
156
+ });
@@ -6,7 +6,7 @@ import { validateConfigDocument } from "./config/validate.ts";
6
6
  import type { CliContext, CliLeafInputs } from "./context.ts";
7
7
  import { collectOptionDefs } from "./parse.ts";
8
8
  import type { CliInvocation, CliLeaf, CliNode, CliOption, CliProgram } from "./types.ts";
9
- import { CliOptionKind, CliValueFormat, isCliLeaf, isCliRouter } from "./types.ts";
9
+ import { CliOptionKind, CliValueFormat, isCliLeaf, isCliRouter, isJsonLeaf } from "./types.ts";
10
10
  import { isInteractiveTty } from "./utils.ts";
11
11
 
12
12
  /** Thrown when leaf input resolution or validation fails. */
@@ -17,9 +17,12 @@ export class LeafInputError extends Error {
17
17
  }
18
18
  }
19
19
 
20
- function leafNode(ctx: CliContext): CliLeaf | undefined {
21
- let node: CliNode = ctx.program;
22
- for (const seg of ctx.commandPath) {
20
+ /** Internal key for piped stdin on `kind: "json"` leaves. */
21
+ export const JSON_LEAF_BODY_KEY = "__jsonLeafBody";
22
+
23
+ function resolveLeaf(program: CliProgram, commandPath: string[]): CliLeaf | undefined {
24
+ let node: CliNode = program;
25
+ for (const seg of commandPath) {
23
26
  if (!isCliRouter(node)) return undefined;
24
27
  const child = node.commands.find((c) => c.key === seg);
25
28
  if (!child) return undefined;
@@ -28,6 +31,10 @@ function leafNode(ctx: CliContext): CliLeaf | undefined {
28
31
  return isCliLeaf(node) ? node : undefined;
29
32
  }
30
33
 
34
+ function leafNode(ctx: CliContext): CliLeaf | undefined {
35
+ return resolveLeaf(ctx.program, ctx.commandPath);
36
+ }
37
+
31
38
  /** Parses a JSON string from a `--name` flag value. */
32
39
  export function parseJsonText(raw: string, label: string): unknown {
33
40
  const trimmed = raw.trim();
@@ -54,6 +61,23 @@ async function readPipedJsonStdin(): Promise<unknown> {
54
61
  }
55
62
  }
56
63
 
64
+ function jsonLeafBodyHelp(): string {
65
+ return "Missing JSON input: pass a JSON document as an argument or pipe to stdin";
66
+ }
67
+
68
+ async function readPipedJsonStdinForJsonLeaf(): Promise<unknown> {
69
+ const raw = await new Response(Bun.stdin).text();
70
+ const trimmed = raw.trim();
71
+ if (trimmed.length === 0) {
72
+ throw new LeafInputError(jsonLeafBodyHelp());
73
+ }
74
+ try {
75
+ return JSON.parse(trimmed);
76
+ } catch {
77
+ throw new LeafInputError("stdin is not valid JSON");
78
+ }
79
+ }
80
+
57
81
  function pipableJsonHelp(opt: CliOption): string {
58
82
  return `Missing required option --${opt.name}: pass JSON via --${opt.name} '<json>' or pipe a JSON document to stdin`;
59
83
  }
@@ -99,10 +123,17 @@ export async function preloadPipableJson(
99
123
  commandPath: string[],
100
124
  opts: Record<string, string>,
101
125
  invocation: CliInvocation,
126
+ args: string[] = [],
102
127
  ): Promise<Record<string, unknown>> {
103
128
  if (invocation !== "cli" || isInteractiveTty) {
104
129
  return {};
105
130
  }
131
+
132
+ const leaf = resolveLeaf(program, commandPath);
133
+ if (leaf && isJsonLeaf(leaf) && args.length === 0) {
134
+ return { [JSON_LEAF_BODY_KEY]: await readPipedJsonStdinForJsonLeaf() };
135
+ }
136
+
106
137
  for (const opt of collectOptionDefs(program, commandPath)) {
107
138
  if (opt.kind === CliOptionKind.Json && opt.pipable && !(opt.name in opts)) {
108
139
  return { [opt.name]: await readPipedJsonStdin() };
@@ -150,6 +181,31 @@ export function loadLeafInputs(ctx: CliContext): CliLeafInputs {
150
181
  const leaf = leafNode(ctx);
151
182
  if (!leaf) return {};
152
183
 
184
+ if (isJsonLeaf(leaf)) {
185
+ let body: unknown;
186
+ if (ctx.toolArgs !== undefined) {
187
+ body = ctx.toolArgs;
188
+ } else if (ctx.args.length > 0) {
189
+ const [arg0] = ctx.args;
190
+ if (arg0 === undefined) {
191
+ throw new LeafInputError(jsonLeafBodyHelp());
192
+ }
193
+ body = parseJsonText(arg0, "JSON argument");
194
+ } else if (JSON_LEAF_BODY_KEY in ctx.preloadedJson) {
195
+ body = ctx.preloadedJson[JSON_LEAF_BODY_KEY];
196
+ } else {
197
+ throw new LeafInputError(jsonLeafBodyHelp());
198
+ }
199
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
200
+ throw new LeafInputError("JSON input must be a JSON object");
201
+ }
202
+ const out = body as CliLeafInputs;
203
+ if (leaf.inputSchema !== undefined) {
204
+ validateAgainstInputSchema(out, leaf.inputSchema);
205
+ }
206
+ return omitUndefinedInputs(out);
207
+ }
208
+
153
209
  const out: CliLeafInputs = {};
154
210
  const options = collectOptionDefs(ctx.program, ctx.commandPath);
155
211
 
package/src/mcp/tools.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type CliProgram,
18
18
  CliValueFormat,
19
19
  isCliLeaf,
20
+ isJsonLeaf,
20
21
  leafOutputSchema,
21
22
  } from "../types.ts";
22
23
 
@@ -150,6 +151,10 @@ function positionalProperty(p: CliPositional): Record<string, unknown> {
150
151
 
151
152
  /** Builds inputSchema for a leaf command. */
152
153
  function buildInputSchema(root: CliProgram, path: string[], leaf: CliLeaf): Record<string, unknown> {
154
+ if (isJsonLeaf(leaf) && leaf.inputSchema !== undefined) {
155
+ return leaf.inputSchema;
156
+ }
157
+
153
158
  const properties: Record<string, unknown> = {};
154
159
  const required: string[] = [];
155
160
 
@@ -286,6 +291,10 @@ export function mcpToolCallToArgv(
286
291
  tool: McpToolDef,
287
292
  args: Record<string, unknown>,
288
293
  ): string[] | { error: string } {
294
+ if (isJsonLeaf(tool.leaf)) {
295
+ return [...tool.path];
296
+ }
297
+
289
298
  const argv = [...tool.path];
290
299
 
291
300
  for (const opt of collectOptionDefs(root, tool.path)) {
package/src/parse.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  CliOptionKind,
17
17
  isCliLeaf,
18
18
  isCliRouter,
19
+ isJsonLeaf,
19
20
  } from "./types.ts";
20
21
  import { fullStringIsDouble } from "./utils.ts";
21
22
 
@@ -237,6 +238,48 @@ export function collectOptionDefs(root: CliNode, path: string[]): CliOption[] {
237
238
  return defs;
238
239
  }
239
240
 
241
+ /** Fills `args` for a json leaf from `startIdx` (0 or 1 JSON string positional). */
242
+ function finishJsonLeaf(
243
+ _node: CliLeaf,
244
+ startIdx: number,
245
+ argv: string[],
246
+ path: string[],
247
+ opts: Record<string, string>,
248
+ ): ParseResult {
249
+ let idx = startIdx;
250
+ const args: string[] = [];
251
+
252
+ if (idx < argv.length) {
253
+ const tok = argv[idx];
254
+ if (isHelpTok(tok)) {
255
+ return helpResult(path, true);
256
+ }
257
+ if (tok === "--") {
258
+ return errorResult("Unexpected extra arguments", path, []);
259
+ }
260
+ if (tok.startsWith("-")) {
261
+ return errorResult(`JSON commands do not accept options: ${tok}`, path, []);
262
+ }
263
+ args.push(tok);
264
+ idx += 1;
265
+ }
266
+
267
+ if (idx < argv.length) {
268
+ return errorResult("Unexpected extra arguments", path, []);
269
+ }
270
+
271
+ return {
272
+ kind: ParseKind.Ok,
273
+ path,
274
+ opts,
275
+ args,
276
+ helpExplicit: false,
277
+ helpPath: [],
278
+ errorMsg: "",
279
+ errorHelpPath: [],
280
+ };
281
+ }
282
+
240
283
  /** Fills `args` for a leaf from `startIdx` according to `node.positionals`. */
241
284
  function finishLeaf(
242
285
  node: CliLeaf,
@@ -412,6 +455,9 @@ export function parse(root: CliNode, argv: string[]): ParseResult {
412
455
  let node: CliNode | undefined;
413
456
 
414
457
  if (isCliLeaf(root)) {
458
+ if (isJsonLeaf(root)) {
459
+ return finishJsonLeaf(root, i, argv, path, opts);
460
+ }
415
461
  return finishLeaf(root, i, argv, path, opts, root.options ?? [], forcePositionals);
416
462
  }
417
463
 
@@ -473,6 +519,10 @@ export function parse(root: CliNode, argv: string[]): ParseResult {
473
519
 
474
520
  // Walk the command tree
475
521
  while (true) {
522
+ if (isCliLeaf(current) && isJsonLeaf(current)) {
523
+ return finishJsonLeaf(current, i, argv, path, opts);
524
+ }
525
+
476
526
  if (!forcePositionals) {
477
527
  const orep = consumeOptions(collectOptionDefs(root, path), false, argv, i, opts);
478
528
  if (orep.report.err) {
package/src/types.ts CHANGED
@@ -418,10 +418,18 @@ export interface CliNodeBase {
418
418
  options?: CliOption[];
419
419
  }
420
420
 
421
+ /** Leaf input mode: `json` = pure JSON body (no CLI flags). */
422
+ export type CliLeafKind = "json";
423
+
421
424
  /**
422
425
  * A leaf command node with a handler and optional positionals.
423
426
  */
424
427
  export type CliLeaf = CliNodeBase & {
428
+ /**
429
+ * When `"json"`, the leaf accepts a single JSON document (CLI positional or piped stdin;
430
+ * MCP/HTTP tool args = body). Requires `inputSchema`; forbids `options` and `positionals`.
431
+ */
432
+ kind?: CliLeafKind;
425
433
  /** Handler function for leaf commands. */
426
434
  handler: CliHandler;
427
435
  /** Positional argument definitions. */
@@ -482,6 +490,11 @@ export function isCliLeaf(node: CliNode): node is CliLeaf {
482
490
  return "handler" in node && typeof node.handler === "function";
483
491
  }
484
492
 
493
+ /** True when the leaf accepts a pure JSON body (no CLI flags). */
494
+ export function isJsonLeaf(leaf: CliLeaf): boolean {
495
+ return leaf.kind === "json";
496
+ }
497
+
485
498
  /** True when the node is a router (has subcommands). */
486
499
  export function isCliRouter(node: CliNode): node is CliRouter {
487
500
  return "commands" in node && Array.isArray(node.commands);
package/src/validate.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  type InstallTargetSpec,
20
20
  isCliLeaf,
21
21
  isCliRouter,
22
+ isJsonLeaf,
22
23
  } from "./types.ts";
23
24
 
24
25
  /** Validates `docs` configuration on the program root. */
@@ -238,6 +239,17 @@ function walkNode(node: CliNode, program: CliProgram, isRoot: boolean): void {
238
239
  if (isRoot && node.mcpTool !== undefined) {
239
240
  throw new CliSchemaValidationError("mcpTool is only supported on leaf commands");
240
241
  }
242
+ if (isJsonLeaf(node)) {
243
+ if (node.inputSchema === undefined) {
244
+ throw new CliSchemaValidationError(`kind: "json" requires inputSchema on ${node.key}`);
245
+ }
246
+ if ((node.options ?? []).length > 0) {
247
+ throw new CliSchemaValidationError(`kind: "json" forbids options on ${node.key}`);
248
+ }
249
+ if ((node.positionals ?? []).length > 0) {
250
+ throw new CliSchemaValidationError(`kind: "json" forbids positionals on ${node.key}`);
251
+ }
252
+ }
241
253
  const outputSchema = node.outputSchema;
242
254
  const legacyOutputSchema = node.mcpTool?.outputSchema;
243
255
  if (outputSchema !== undefined && legacyOutputSchema !== undefined) {