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 +8 -1
- package/docs/cli-program.md +24 -0
- package/index.d.ts +10 -1
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/help.ts +51 -15
- package/src/index.ts +2 -0
- package/src/json-leaf.test.ts +156 -0
- package/src/leaf-inputs.ts +60 -4
- package/src/mcp/tools.ts +9 -0
- package/src/parse.ts +50 -0
- package/src/types.ts +13 -0
- package/src/validate.ts +12 -0
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.
|
|
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
|
package/docs/cli-program.md
CHANGED
|
@@ -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
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
+
});
|
package/src/leaf-inputs.ts
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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) {
|