argsbarg 7.0.8 → 7.0.9
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 +13 -3
- package/docs/cli-program.md +22 -7
- package/index.d.ts +19 -6
- package/package.json +1 -1
- package/src/core/document-leaf.test.ts +208 -0
- package/src/core/json-leaf.test.ts +26 -0
- package/src/core/leaf-inputs.ts +70 -15
- package/src/core/parse.ts +7 -6
- package/src/core/types.ts +19 -7
- package/src/core/validate.ts +6 -5
- package/src/exports/cli.ts +1 -0
- package/src/help.test.ts +40 -0
- package/src/help.ts +19 -15
- package/src/http/openapi.ts +2 -2
- package/src/http/routes.ts +2 -2
- package/src/http/server.ts +9 -1
- package/src/index.ts +2 -0
- package/src/mcp/tools.ts +3 -3
- package/src/test/integration/http.test.ts +36 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [7.0.
|
|
10
|
+
## [7.0.9] - 2026-09-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`kind: "document"` leaf commands with YAML and JSON support** — introduced `kind: "document"` as the primary naming for structured payload leaves, while retaining `kind: "json"` and `isJsonLeaf` as fully backward-compatible aliases. Both `"document"` and `"json"` leaves now accept YAML input in addition to JSON via command positional arguments and piped stdin.
|
|
15
|
+
- **YAML request body support in HTTP server** — the HTTP API server now accepts YAML request bodies in addition to JSON for structured document endpoints.
|
|
16
|
+
- **`isDocumentLeaf` and `parseDocumentText` exports** — exported `isDocumentLeaf` type guard and `parseDocumentText` utility from framework root and CLI exports.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Help rendering for document leaves** — usage lines for `kind: "document"` leaves render `[DOCUMENT]` (retaining `[JSON]` for legacy `kind: "json"` leaves) and describe inputs as `"Pass a JSON or YAML document as an argument or pipe to stdin."`
|
|
11
21
|
|
|
12
22
|
### Added
|
|
13
23
|
|
|
@@ -992,8 +1002,8 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
|
|
|
992
1002
|
- 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`).
|
|
993
1003
|
- Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
|
|
994
1004
|
|
|
995
|
-
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v7.0.
|
|
996
|
-
[7.0.
|
|
1005
|
+
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v7.0.9...HEAD
|
|
1006
|
+
[7.0.9]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.9
|
|
997
1007
|
[7.0.7]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.7
|
|
998
1008
|
[7.0.6]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.6
|
|
999
1009
|
[7.0.5]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.5
|
package/docs/cli-program.md
CHANGED
|
@@ -108,7 +108,7 @@ Use root **`notes`** for cross-cutting hints shown in help (install commands, do
|
|
|
108
108
|
Descriptions and schemas are copied into MCP tools and HTTP OpenAPI — optimize for smaller, clearer agent payloads:
|
|
109
109
|
|
|
110
110
|
- **Declare options on the leaf command** that uses them — routing groups cannot declare options (program root may). Wire schemas (MCP, OpenAPI) expose leaf-local options only.
|
|
111
|
-
- Prefer **`kind: "
|
|
111
|
+
- Prefer **`kind: "document"`** (or legacy `kind: "json"`) leaves with schemagen `inputSchema` for complex tool bodies (one nested object beats many flat flags).
|
|
112
112
|
- Keep **`description`** strings short and action-oriented; put examples in **`notes`**, not duplicated in every option.
|
|
113
113
|
- Use **`hidden: true`** or **`mcpTool.enabled: false`** for debug/internal commands.
|
|
114
114
|
- For shape discovery: HTTP agents load **`docs openapi`** or `GET /openapi.json`; MCP agents use **`docs cli-schema`**; load full **`docs cli`** only when prose is needed.
|
|
@@ -300,15 +300,15 @@ Use **`ctx.jsonOpt("invoice")`**, **`ctx.inputs`**, or **`ctx.inputsAs<MyInput>(
|
|
|
300
300
|
|
|
301
301
|
At most one `pipable` Json option per leaf. Json option names must appear in `inputSchema.properties` when a custom `inputSchema` is set.
|
|
302
302
|
|
|
303
|
-
###
|
|
303
|
+
### Structured document leaves (`kind: "document"`)
|
|
304
304
|
|
|
305
|
-
When the entire tool body is JSON
|
|
305
|
+
When the entire tool body is a structured document (JSON or YAML, no CLI flags), set **`kind: "document"`** (or legacy `"json"`) on the leaf with **`inputSchema`** and **no `options` or `positionals`**:
|
|
306
306
|
|
|
307
307
|
```typescript
|
|
308
308
|
{
|
|
309
309
|
key: "render-invoice",
|
|
310
310
|
description: "Render an invoice from template data",
|
|
311
|
-
kind: "
|
|
311
|
+
kind: "document",
|
|
312
312
|
inputSchema,
|
|
313
313
|
handler: (ctx) => {
|
|
314
314
|
const { format, invoice } = ctx.inputsAs<RenderInvoiceInput>();
|
|
@@ -319,10 +319,25 @@ When the entire tool body is JSON (no CLI flags), set **`kind: "json"`** on the
|
|
|
319
319
|
|
|
320
320
|
| Surface | How input is supplied |
|
|
321
321
|
| --- | --- |
|
|
322
|
-
| CLI | One JSON positional **or** pipe a JSON document to stdin |
|
|
323
|
-
| MCP / HTTP | Full tool args object (`ctx.toolArgs` /
|
|
322
|
+
| CLI | One JSON or YAML positional **or** pipe a JSON/YAML document to stdin |
|
|
323
|
+
| MCP / HTTP | Full tool args object (`ctx.toolArgs` / JSON or YAML request body) |
|
|
324
324
|
|
|
325
|
-
Example CLI:
|
|
325
|
+
Example CLI:
|
|
326
|
+
```bash
|
|
327
|
+
# JSON positional or pipe
|
|
328
|
+
jq '{format:"pdf", invoice:.}' data.json | myapp render-invoice
|
|
329
|
+
myapp render-invoice '{"format":"pdf","invoice":{"id":"INV-1"}}'
|
|
330
|
+
|
|
331
|
+
# YAML positional or pipe
|
|
332
|
+
myapp render-invoice 'format: pdf
|
|
333
|
+
invoice:
|
|
334
|
+
id: INV-1'
|
|
335
|
+
cat << 'EOF' | myapp render-invoice
|
|
336
|
+
format: pdf
|
|
337
|
+
invoice:
|
|
338
|
+
id: INV-1
|
|
339
|
+
EOF
|
|
340
|
+
```
|
|
326
341
|
|
|
327
342
|
See [output-schema.md](output-schema.md) for schemagen `inputType`, [http-server.md](http-server.md) for HTTP tool bodies, and [json-schema-subset.md](json-schema-subset.md) for validation drafts, Zod interop, and keyword notes.
|
|
328
343
|
|
package/index.d.ts
CHANGED
|
@@ -618,15 +618,16 @@ export interface CliNodeBase {
|
|
|
618
618
|
/** Global or command-level flags/options. */
|
|
619
619
|
options?: CliOption[];
|
|
620
620
|
}
|
|
621
|
-
/** Leaf input mode: `json` =
|
|
622
|
-
export type CliLeafKind = "json";
|
|
621
|
+
/** Leaf input mode: `document` (or legacy `json`) = structured JSON or YAML document body (no CLI flags). */
|
|
622
|
+
export type CliLeafKind = "document" | "json";
|
|
623
623
|
/**
|
|
624
624
|
* A leaf command node with a handler and optional positionals.
|
|
625
625
|
*/
|
|
626
626
|
export type CliLeaf = CliNodeBase & {
|
|
627
627
|
/**
|
|
628
|
-
* When `"json"
|
|
629
|
-
* MCP/HTTP tool args = body). Requires `inputSchema`;
|
|
628
|
+
* When `"document"` (or legacy `"json"`), the leaf accepts a single JSON or YAML document
|
|
629
|
+
* (CLI positional or piped stdin; MCP/HTTP tool args = body). Requires `inputSchema`;
|
|
630
|
+
* forbids `options` and `positionals`.
|
|
630
631
|
*/
|
|
631
632
|
kind?: CliLeafKind;
|
|
632
633
|
/** Handler function for leaf commands. */
|
|
@@ -814,8 +815,14 @@ export type CliProgram = CliNode & {
|
|
|
814
815
|
/** Program version (printed by the `version` built-in and MCP serverInfo). */
|
|
815
816
|
version: string;
|
|
816
817
|
};
|
|
817
|
-
/** True when the leaf accepts a
|
|
818
|
-
export declare function
|
|
818
|
+
/** True when the leaf accepts a structured JSON or YAML document body (no CLI flags). */
|
|
819
|
+
export declare function isDocumentLeaf(
|
|
820
|
+
/** Leaf command node to inspect. */
|
|
821
|
+
leaf: CliLeaf): boolean;
|
|
822
|
+
/** True when the leaf accepts a structured document body (backward-compatible alias for `isDocumentLeaf`). */
|
|
823
|
+
export declare function isJsonLeaf(
|
|
824
|
+
/** Leaf command node to inspect. */
|
|
825
|
+
leaf: CliLeaf): boolean;
|
|
819
826
|
/**
|
|
820
827
|
* Handler closure type for leaf commands.
|
|
821
828
|
* Supports sync and async handlers; non-undefined return values become implicit JSON responses for headless invocations.
|
|
@@ -844,6 +851,12 @@ export declare function parseDateTime(s: string): string;
|
|
|
844
851
|
export declare class LeafInputError extends Error {
|
|
845
852
|
constructor(message: string);
|
|
846
853
|
}
|
|
854
|
+
/** Parses a JSON or YAML string from a command argument or document body. */
|
|
855
|
+
export declare function parseDocumentText(
|
|
856
|
+
/** Raw text containing a JSON or YAML document. */
|
|
857
|
+
raw: string,
|
|
858
|
+
/** Field or argument label for error reporting. */
|
|
859
|
+
label: string): unknown;
|
|
847
860
|
/** Resolves a Json option from argv, preloaded stdin, or toolArgs (flag wins). */
|
|
848
861
|
export declare function readJsonOptionValue(ctx: CliContext, name: string): unknown | undefined;
|
|
849
862
|
/**
|
package/package.json
CHANGED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Tests for structured document leaves (kind: "document") supporting JSON and YAML document input.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { Cli } from "../index.ts";
|
|
7
|
+
import { LeafInputError, parseDocumentText } from "./leaf-inputs.ts";
|
|
8
|
+
import { ParseKind, parse } from "./parse.ts";
|
|
9
|
+
import { CliOptionKind, type CliProgram, CliSchemaValidationError } from "./types.ts";
|
|
10
|
+
import { cliValidateProgram } from "./validate.ts";
|
|
11
|
+
|
|
12
|
+
/** JSON Schema for the deployment test document body. */
|
|
13
|
+
const deploySchema = {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
target: { type: "string", enum: ["staging", "production"] },
|
|
17
|
+
config: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: { replicas: { type: "integer" } },
|
|
20
|
+
required: ["replicas"],
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: ["target", "config"],
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/** Creates a test program with a single `kind: "document"` leaf command. */
|
|
29
|
+
function documentLeafProgram() {
|
|
30
|
+
return {
|
|
31
|
+
key: "document-leaf-test",
|
|
32
|
+
version: "1.0.0",
|
|
33
|
+
description: "document leaf tests",
|
|
34
|
+
commands: [
|
|
35
|
+
{
|
|
36
|
+
key: "deploy",
|
|
37
|
+
description: "Deploy from document body",
|
|
38
|
+
kind: "document",
|
|
39
|
+
inputSchema: deploySchema,
|
|
40
|
+
handler: (ctx) => ctx.inputsAs<{ target: string; config: { replicas: number } }>(),
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
} satisfies CliProgram;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Tests for `kind: "document"` leaf commands. */
|
|
47
|
+
describe("kind: document leaf", () => {
|
|
48
|
+
/** Tests validation rules for document leaves. */
|
|
49
|
+
test("validate requires inputSchema and forbids options/positionals", () => {
|
|
50
|
+
expect(() =>
|
|
51
|
+
cliValidateProgram({
|
|
52
|
+
key: "bad",
|
|
53
|
+
version: "1",
|
|
54
|
+
description: "bad",
|
|
55
|
+
commands: [{ key: "x", description: "x", kind: "document", handler: () => {} }],
|
|
56
|
+
}),
|
|
57
|
+
).toThrow(CliSchemaValidationError);
|
|
58
|
+
|
|
59
|
+
expect(() =>
|
|
60
|
+
cliValidateProgram({
|
|
61
|
+
key: "bad",
|
|
62
|
+
version: "1",
|
|
63
|
+
description: "bad",
|
|
64
|
+
commands: [
|
|
65
|
+
{
|
|
66
|
+
key: "x",
|
|
67
|
+
description: "x",
|
|
68
|
+
kind: "document",
|
|
69
|
+
inputSchema: deploySchema,
|
|
70
|
+
options: [{ name: "f", description: "f", kind: CliOptionKind.String }],
|
|
71
|
+
handler: () => {},
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
}),
|
|
75
|
+
).toThrow(CliSchemaValidationError);
|
|
76
|
+
|
|
77
|
+
expect(() =>
|
|
78
|
+
cliValidateProgram({
|
|
79
|
+
key: "bad",
|
|
80
|
+
version: "1",
|
|
81
|
+
description: "bad",
|
|
82
|
+
commands: [
|
|
83
|
+
{
|
|
84
|
+
key: "x",
|
|
85
|
+
description: "x",
|
|
86
|
+
kind: "document",
|
|
87
|
+
inputSchema: deploySchema,
|
|
88
|
+
positionals: [{ name: "file", description: "file", kind: CliOptionKind.String }],
|
|
89
|
+
handler: () => {},
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
}),
|
|
93
|
+
).toThrow(CliSchemaValidationError);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** Tests that flags on document leaves are rejected. */
|
|
97
|
+
test("parse rejects CLI flags on document leaf", () => {
|
|
98
|
+
const root = documentLeafProgram();
|
|
99
|
+
const pr = parse(root, ["deploy", "--target", "staging"]);
|
|
100
|
+
expect(pr.kind).toBe(ParseKind.Error);
|
|
101
|
+
expect(pr.errorMsg).toContain("Document commands do not accept options");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** Tests that document leaf accepts positional argument tokens. */
|
|
105
|
+
test("parse accepts document positional token", () => {
|
|
106
|
+
const root = documentLeafProgram();
|
|
107
|
+
const pr = parse(root, ["deploy", "target: staging"]);
|
|
108
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
109
|
+
expect(pr.args).toEqual(["target: staging"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** Tests reading body from YAML positional argv. */
|
|
113
|
+
test("invoke reads body from YAML positional argv", async () => {
|
|
114
|
+
const cli = new Cli(documentLeafProgram());
|
|
115
|
+
const yaml = "target: staging\nconfig:\n replicas: 3";
|
|
116
|
+
const result = await cli.invoke(["deploy", yaml], { invocation: "mcp" });
|
|
117
|
+
expect(result.kind).toBe("ok");
|
|
118
|
+
expect(result.response?.body).toEqual({ target: "staging", config: { replicas: 3 } });
|
|
119
|
+
|
|
120
|
+
const cliResult = await cli.invoke(["deploy", yaml], { invocation: "cli" });
|
|
121
|
+
expect(cliResult.kind).toBe("ok");
|
|
122
|
+
expect(JSON.parse(cliResult.stdout)).toEqual({ target: "staging", config: { replicas: 3 } });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/** Tests reading body from JSON positional argv. */
|
|
126
|
+
test("invoke reads body from JSON positional argv", async () => {
|
|
127
|
+
const cli = new Cli(documentLeafProgram());
|
|
128
|
+
const json = '{"target":"production","config":{"replicas":5}}';
|
|
129
|
+
const result = await cli.invoke(["deploy", json], { invocation: "mcp" });
|
|
130
|
+
expect(result.kind).toBe("ok");
|
|
131
|
+
expect(result.response?.body).toEqual({ target: "production", config: { replicas: 5 } });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/** Tests reading body from toolArgs. */
|
|
135
|
+
test("invoke reads body from toolArgs", async () => {
|
|
136
|
+
const cli = new Cli(documentLeafProgram());
|
|
137
|
+
const result = await cli.invoke(["deploy"], {
|
|
138
|
+
invocation: "http",
|
|
139
|
+
toolArgs: { target: "production", config: { replicas: 10 } },
|
|
140
|
+
});
|
|
141
|
+
expect(result.kind).toBe("ok");
|
|
142
|
+
expect(result.response?.body).toEqual({ target: "production", config: { replicas: 10 } });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/** Tests error message when document body is missing. */
|
|
146
|
+
test("invoke errors when body is missing", async () => {
|
|
147
|
+
const cli = new Cli(documentLeafProgram());
|
|
148
|
+
const result = await cli.invoke(["deploy"], { invocation: "cli" });
|
|
149
|
+
expect(result.kind).toBe("error");
|
|
150
|
+
expect(result.errorMsg).toContain("Missing document input");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/** Tests inputSchema validation error handling. */
|
|
154
|
+
test("invoke validates inputSchema before handler runs", async () => {
|
|
155
|
+
let handlerRan = false;
|
|
156
|
+
const base = documentLeafProgram();
|
|
157
|
+
const program = {
|
|
158
|
+
...base,
|
|
159
|
+
commands: [
|
|
160
|
+
{
|
|
161
|
+
...base.commands[0],
|
|
162
|
+
handler: () => {
|
|
163
|
+
handlerRan = true;
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
} satisfies CliProgram;
|
|
168
|
+
const cli = new Cli(program);
|
|
169
|
+
const result = await cli.invoke(["deploy", "target: invalid-target\nconfig:\n replicas: 1"], {
|
|
170
|
+
invocation: "cli",
|
|
171
|
+
});
|
|
172
|
+
expect(result.kind).toBe("error");
|
|
173
|
+
expect(handlerRan).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/** Tests non-object document body returns error. */
|
|
177
|
+
test("non-object document body returns error", async () => {
|
|
178
|
+
const cli = new Cli(documentLeafProgram());
|
|
179
|
+
const result = await cli.invoke(["deploy", '"just-a-string"'], { invocation: "cli" });
|
|
180
|
+
expect(result.kind).toBe("error");
|
|
181
|
+
expect(result.errorMsg).toContain("Document input must be a JSON or YAML object");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/** Tests for parseDocumentText helper function. */
|
|
186
|
+
describe("parseDocumentText", () => {
|
|
187
|
+
/** Tests parsing valid JSON. */
|
|
188
|
+
test("parses valid JSON object", () => {
|
|
189
|
+
const parsed = parseDocumentText('{"key":"value"}', "test");
|
|
190
|
+
expect(parsed).toEqual({ key: "value" });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
/** Tests parsing valid YAML. */
|
|
194
|
+
test("parses valid YAML object", () => {
|
|
195
|
+
const parsed = parseDocumentText("key: value\nnested:\n count: 2", "test");
|
|
196
|
+
expect(parsed).toEqual({ key: "value", nested: { count: 2 } });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/** Tests empty string throws error. */
|
|
200
|
+
test("throws on empty string", () => {
|
|
201
|
+
expect(() => parseDocumentText(" ", "test")).toThrow(LeafInputError);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/** Tests invalid syntax throws error. */
|
|
205
|
+
test("throws on invalid syntax", () => {
|
|
206
|
+
expect(() => parseDocumentText("{bad json", "test")).toThrow(LeafInputError);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Tests for pure JSON leaves (kind: "json") including backward-compatibility and YAML document input.
|
|
3
|
+
*/
|
|
4
|
+
|
|
1
5
|
import { describe, expect, test } from "bun:test";
|
|
2
6
|
import { Cli } from "../index.ts";
|
|
3
7
|
import { ParseKind, parse } from "./parse.ts";
|
|
4
8
|
import { CliOptionKind, type CliProgram, CliSchemaValidationError } from "./types.ts";
|
|
5
9
|
import { cliValidateProgram } from "./validate.ts";
|
|
6
10
|
|
|
11
|
+
/** JSON Schema for the invoice render test body. */
|
|
7
12
|
const bodySchema = {
|
|
8
13
|
type: "object",
|
|
9
14
|
properties: {
|
|
@@ -19,6 +24,7 @@ const bodySchema = {
|
|
|
19
24
|
additionalProperties: false,
|
|
20
25
|
} as const;
|
|
21
26
|
|
|
27
|
+
/** Creates a test program with a single `kind: "json"` leaf command. */
|
|
22
28
|
function jsonLeafProgram() {
|
|
23
29
|
return {
|
|
24
30
|
key: "json-leaf-test",
|
|
@@ -36,7 +42,9 @@ function jsonLeafProgram() {
|
|
|
36
42
|
} satisfies CliProgram;
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/** Tests for `kind: "json"` leaf commands. */
|
|
39
46
|
describe("kind: json leaf", () => {
|
|
47
|
+
/** Tests validation rules for json leaves. */
|
|
40
48
|
test("validate requires inputSchema and forbids options/positionals", () => {
|
|
41
49
|
expect(() =>
|
|
42
50
|
cliValidateProgram({
|
|
@@ -84,6 +92,7 @@ describe("kind: json leaf", () => {
|
|
|
84
92
|
).toThrow(CliSchemaValidationError);
|
|
85
93
|
});
|
|
86
94
|
|
|
95
|
+
/** Tests that flags on json leaves are rejected. */
|
|
87
96
|
test("parse rejects CLI flags on json leaf", () => {
|
|
88
97
|
const root = jsonLeafProgram();
|
|
89
98
|
const pr = parse(root, ["render", "--format", "pdf"]);
|
|
@@ -91,6 +100,7 @@ describe("kind: json leaf", () => {
|
|
|
91
100
|
expect(pr.errorMsg).toContain("JSON commands do not accept options");
|
|
92
101
|
});
|
|
93
102
|
|
|
103
|
+
/** Tests that json leaf accepts JSON positional arguments. */
|
|
94
104
|
test("parse accepts JSON positional", () => {
|
|
95
105
|
const root = jsonLeafProgram();
|
|
96
106
|
const pr = parse(root, ["render", '{"format":"pdf","invoice":{"id":"1"}}']);
|
|
@@ -98,6 +108,7 @@ describe("kind: json leaf", () => {
|
|
|
98
108
|
expect(pr.args).toEqual(['{"format":"pdf","invoice":{"id":"1"}}']);
|
|
99
109
|
});
|
|
100
110
|
|
|
111
|
+
/** Tests reading body from toolArgs. */
|
|
101
112
|
test("invoke reads body from toolArgs", async () => {
|
|
102
113
|
const cli = new Cli(jsonLeafProgram());
|
|
103
114
|
const result = await cli.invoke(["render"], {
|
|
@@ -108,6 +119,7 @@ describe("kind: json leaf", () => {
|
|
|
108
119
|
expect(result.response?.body).toEqual({ format: "pdf", invoice: { id: "INV-1" } });
|
|
109
120
|
});
|
|
110
121
|
|
|
122
|
+
/** Tests reading body from JSON positional argv. */
|
|
111
123
|
test("invoke reads body from JSON positional argv", async () => {
|
|
112
124
|
const cli = new Cli(jsonLeafProgram());
|
|
113
125
|
const result = await cli.invoke(["render", '{"format":"html","invoice":{"id":"2"}}'], {
|
|
@@ -117,6 +129,18 @@ describe("kind: json leaf", () => {
|
|
|
117
129
|
expect(result.response?.body).toEqual({ format: "html", invoice: { id: "2" } });
|
|
118
130
|
});
|
|
119
131
|
|
|
132
|
+
/** Tests reading body from YAML positional argv for kind: "json" leaf. */
|
|
133
|
+
test("invoke reads body from YAML positional argv", async () => {
|
|
134
|
+
const cli = new Cli(jsonLeafProgram());
|
|
135
|
+
const yamlBody = "format: pdf\ninvoice:\n id: 'INV-YAML-1'";
|
|
136
|
+
const result = await cli.invoke(["render", yamlBody], {
|
|
137
|
+
invocation: "mcp",
|
|
138
|
+
});
|
|
139
|
+
expect(result.kind).toBe("ok");
|
|
140
|
+
expect(result.response?.body).toEqual({ format: "pdf", invoice: { id: "INV-YAML-1" } });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** Tests error when body is missing. */
|
|
120
144
|
test("invoke errors when body missing", async () => {
|
|
121
145
|
const cli = new Cli(jsonLeafProgram());
|
|
122
146
|
const result = await cli.invoke(["render"], { invocation: "http" });
|
|
@@ -124,6 +148,7 @@ describe("kind: json leaf", () => {
|
|
|
124
148
|
expect(result.errorMsg).toContain("Missing JSON input");
|
|
125
149
|
});
|
|
126
150
|
|
|
151
|
+
/** Tests inputSchema validation before handler runs. */
|
|
127
152
|
test("invoke validates inputSchema before handler", async () => {
|
|
128
153
|
let called = false;
|
|
129
154
|
const base = jsonLeafProgram();
|
|
@@ -147,6 +172,7 @@ describe("kind: json leaf", () => {
|
|
|
147
172
|
expect(called).toBe(false);
|
|
148
173
|
});
|
|
149
174
|
|
|
175
|
+
/** Tests error on non-object JSON body. */
|
|
150
176
|
test("non-object JSON body returns error", async () => {
|
|
151
177
|
const cli = new Cli(jsonLeafProgram());
|
|
152
178
|
const result = await cli.invoke(["render", '"not-an-object"'], { invocation: "cli" });
|
package/src/core/leaf-inputs.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { isInteractiveTty } from "../utils.ts";
|
|
|
7
7
|
import type { CliContext, CliLeafInputs } from "./context.ts";
|
|
8
8
|
import { collectOptionDefs } from "./parse.ts";
|
|
9
9
|
import type { CliInvocation, CliLeaf, CliNode, CliOption, CliProgram } from "./types.ts";
|
|
10
|
-
import { CliOptionKind, CliValueFormat, isCliLeaf, isCliRouter,
|
|
10
|
+
import { type CliLeafKind, CliOptionKind, CliValueFormat, isCliLeaf, isCliRouter, isDocumentLeaf } from "./types.ts";
|
|
11
11
|
|
|
12
12
|
/** Thrown when leaf input resolution or validation fails. */
|
|
13
13
|
export class LeafInputError extends Error {
|
|
@@ -17,8 +17,11 @@ export class LeafInputError extends Error {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/** Internal key for piped stdin on `kind: "json"` leaves. */
|
|
21
|
-
export const
|
|
20
|
+
/** Internal key for piped stdin on `kind: "document"` or `kind: "json"` leaves. */
|
|
21
|
+
export const DOCUMENT_LEAF_BODY_KEY = "__documentLeafBody";
|
|
22
|
+
|
|
23
|
+
/** Internal key for piped stdin on `kind: "json"` leaves (backward-compatible alias). */
|
|
24
|
+
export const JSON_LEAF_BODY_KEY = DOCUMENT_LEAF_BODY_KEY;
|
|
22
25
|
|
|
23
26
|
function resolveLeaf(program: CliProgram, commandPath: string[]): CliLeaf | undefined {
|
|
24
27
|
let node: CliNode = program;
|
|
@@ -36,7 +39,12 @@ function leafNode(ctx: CliContext): CliLeaf | undefined {
|
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/** Parses a JSON string from a `--name` flag value. */
|
|
39
|
-
export function parseJsonText(
|
|
42
|
+
export function parseJsonText(
|
|
43
|
+
/** Raw text to parse as JSON. */
|
|
44
|
+
raw: string,
|
|
45
|
+
/** Field or argument label for error reporting. */
|
|
46
|
+
label: string,
|
|
47
|
+
): unknown {
|
|
40
48
|
const trimmed = raw.trim();
|
|
41
49
|
if (trimmed.length === 0) {
|
|
42
50
|
throw new LeafInputError(`${label}: JSON value is empty`);
|
|
@@ -48,6 +56,31 @@ export function parseJsonText(raw: string, label: string): unknown {
|
|
|
48
56
|
}
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
/** Parses a JSON or YAML string from a command argument or document body. */
|
|
60
|
+
export function parseDocumentText(
|
|
61
|
+
/** Raw text containing a JSON or YAML document. */
|
|
62
|
+
raw: string,
|
|
63
|
+
/** Field or argument label for error reporting. */
|
|
64
|
+
label: string,
|
|
65
|
+
): unknown {
|
|
66
|
+
const trimmed = raw.trim();
|
|
67
|
+
if (trimmed.length === 0) {
|
|
68
|
+
throw new LeafInputError(`${label}: value is empty`);
|
|
69
|
+
}
|
|
70
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(trimmed);
|
|
73
|
+
} catch {
|
|
74
|
+
// Fall through to YAML if JSON parse fails
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return Bun.YAML.parse(trimmed);
|
|
79
|
+
} catch {
|
|
80
|
+
throw new LeafInputError(`${label}: invalid JSON or YAML`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
51
84
|
async function readPipedJsonStdin(): Promise<unknown> {
|
|
52
85
|
const raw = await new Response(Bun.stdin).text();
|
|
53
86
|
const trimmed = raw.trim();
|
|
@@ -61,20 +94,38 @@ async function readPipedJsonStdin(): Promise<unknown> {
|
|
|
61
94
|
}
|
|
62
95
|
}
|
|
63
96
|
|
|
64
|
-
|
|
97
|
+
/** Returns the error message when document input is missing. */
|
|
98
|
+
function jsonLeafBodyHelp(
|
|
99
|
+
/** Leaf kind: document or json. */
|
|
100
|
+
kind: CliLeafKind = "json",
|
|
101
|
+
): string {
|
|
102
|
+
if (kind === "document") {
|
|
103
|
+
return "Missing document input: pass a JSON or YAML document as an argument or pipe to stdin";
|
|
104
|
+
}
|
|
65
105
|
return "Missing JSON input: pass a JSON document as an argument or pipe to stdin";
|
|
66
106
|
}
|
|
67
107
|
|
|
68
|
-
|
|
108
|
+
/** Reads piped stdin for a document or json leaf command. */
|
|
109
|
+
async function readPipedJsonStdinForJsonLeaf(
|
|
110
|
+
/** Leaf kind: document or json. */
|
|
111
|
+
kind: CliLeafKind = "json",
|
|
112
|
+
): Promise<unknown> {
|
|
69
113
|
const raw = await new Response(Bun.stdin).text();
|
|
70
114
|
const trimmed = raw.trim();
|
|
71
115
|
if (trimmed.length === 0) {
|
|
72
|
-
throw new LeafInputError(jsonLeafBodyHelp());
|
|
116
|
+
throw new LeafInputError(jsonLeafBodyHelp(kind));
|
|
117
|
+
}
|
|
118
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(trimmed);
|
|
121
|
+
} catch {
|
|
122
|
+
// Fall through to YAML
|
|
123
|
+
}
|
|
73
124
|
}
|
|
74
125
|
try {
|
|
75
|
-
return
|
|
126
|
+
return Bun.YAML.parse(trimmed);
|
|
76
127
|
} catch {
|
|
77
|
-
throw new LeafInputError("stdin is not valid JSON");
|
|
128
|
+
throw new LeafInputError("stdin is not valid JSON or YAML");
|
|
78
129
|
}
|
|
79
130
|
}
|
|
80
131
|
|
|
@@ -130,8 +181,8 @@ export async function preloadPipableJson(
|
|
|
130
181
|
}
|
|
131
182
|
|
|
132
183
|
const leaf = resolveLeaf(program, commandPath);
|
|
133
|
-
if (leaf &&
|
|
134
|
-
return { [JSON_LEAF_BODY_KEY]: await readPipedJsonStdinForJsonLeaf() };
|
|
184
|
+
if (leaf && isDocumentLeaf(leaf) && args.length === 0) {
|
|
185
|
+
return { [JSON_LEAF_BODY_KEY]: await readPipedJsonStdinForJsonLeaf(leaf.kind) };
|
|
135
186
|
}
|
|
136
187
|
|
|
137
188
|
for (const opt of collectOptionDefs(program, commandPath)) {
|
|
@@ -181,22 +232,26 @@ export function loadLeafInputs(ctx: CliContext): CliLeafInputs {
|
|
|
181
232
|
const leaf = leafNode(ctx);
|
|
182
233
|
if (!leaf) return {};
|
|
183
234
|
|
|
184
|
-
if (
|
|
235
|
+
if (isDocumentLeaf(leaf)) {
|
|
185
236
|
let body: unknown;
|
|
186
237
|
if (ctx.toolArgs !== undefined) {
|
|
187
238
|
body = ctx.toolArgs;
|
|
188
239
|
} else if (ctx.args.length > 0) {
|
|
189
240
|
const [arg0] = ctx.args;
|
|
190
241
|
if (arg0 === undefined) {
|
|
191
|
-
throw new LeafInputError(jsonLeafBodyHelp());
|
|
242
|
+
throw new LeafInputError(jsonLeafBodyHelp(leaf.kind));
|
|
192
243
|
}
|
|
193
|
-
|
|
244
|
+
const label = leaf.kind === "document" ? "Document argument" : "JSON argument";
|
|
245
|
+
body = parseDocumentText(arg0, label);
|
|
194
246
|
} else if (JSON_LEAF_BODY_KEY in ctx.preloadedJson) {
|
|
195
247
|
body = ctx.preloadedJson[JSON_LEAF_BODY_KEY];
|
|
196
248
|
} else {
|
|
197
|
-
throw new LeafInputError(jsonLeafBodyHelp());
|
|
249
|
+
throw new LeafInputError(jsonLeafBodyHelp(leaf.kind));
|
|
198
250
|
}
|
|
199
251
|
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
252
|
+
if (leaf.kind === "document") {
|
|
253
|
+
throw new LeafInputError("Document input must be a JSON or YAML object");
|
|
254
|
+
}
|
|
200
255
|
throw new LeafInputError("JSON input must be a JSON object");
|
|
201
256
|
}
|
|
202
257
|
const out = body as CliLeafInputs;
|
package/src/core/parse.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type CliRouter,
|
|
20
20
|
isCliLeaf,
|
|
21
21
|
isCliRouter,
|
|
22
|
-
|
|
22
|
+
isDocumentLeaf,
|
|
23
23
|
} from "./types.ts";
|
|
24
24
|
|
|
25
25
|
// ── Parse Result ──────────────────────────────────────────────────────────────
|
|
@@ -292,9 +292,9 @@ export function collectOptionDefs(root: CliNode, path: string[]): CliOption[] {
|
|
|
292
292
|
return [...(node.options ?? [])];
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
/** Fills `args` for a json leaf from `startIdx` (0 or 1 JSON string positional). */
|
|
295
|
+
/** Fills `args` for a document / json leaf from `startIdx` (0 or 1 JSON or YAML string positional). */
|
|
296
296
|
function finishJsonLeaf(
|
|
297
|
-
|
|
297
|
+
node: CliLeaf,
|
|
298
298
|
startIdx: number,
|
|
299
299
|
argv: string[],
|
|
300
300
|
path: string[],
|
|
@@ -313,7 +313,8 @@ function finishJsonLeaf(
|
|
|
313
313
|
return errorResult("Unexpected extra arguments", path, [], pathParams);
|
|
314
314
|
}
|
|
315
315
|
if (tok.startsWith("-")) {
|
|
316
|
-
|
|
316
|
+
const kindLabel = node.kind === "document" ? "Document" : "JSON";
|
|
317
|
+
return errorResult(`${kindLabel} commands do not accept options: ${tok}`, path, [], pathParams);
|
|
317
318
|
}
|
|
318
319
|
args.push(tok);
|
|
319
320
|
idx += 1;
|
|
@@ -552,7 +553,7 @@ export function parse(root: CliNode, argv: string[]): ParseResult {
|
|
|
552
553
|
let node: CliNode | undefined;
|
|
553
554
|
|
|
554
555
|
if (isCliLeaf(root)) {
|
|
555
|
-
if (
|
|
556
|
+
if (isDocumentLeaf(root)) {
|
|
556
557
|
return finishJsonLeaf(root, i, argv, path, opts, pathParams);
|
|
557
558
|
}
|
|
558
559
|
return finishLeaf(root, i, argv, path, opts, root.options ?? [], forcePositionals, pathParams);
|
|
@@ -645,7 +646,7 @@ export function parse(root: CliNode, argv: string[]): ParseResult {
|
|
|
645
646
|
|
|
646
647
|
// Walk the command tree
|
|
647
648
|
while (true) {
|
|
648
|
-
if (isCliLeaf(current) &&
|
|
649
|
+
if (isCliLeaf(current) && isDocumentLeaf(current)) {
|
|
649
650
|
return finishJsonLeaf(current, i, argv, path, opts, pathParams);
|
|
650
651
|
}
|
|
651
652
|
|
package/src/core/types.ts
CHANGED
|
@@ -500,16 +500,17 @@ export interface CliNodeBase {
|
|
|
500
500
|
options?: CliOption[];
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
-
/** Leaf input mode: `json` =
|
|
504
|
-
export type CliLeafKind = "json";
|
|
503
|
+
/** Leaf input mode: `document` (or legacy `json`) = structured JSON or YAML document body (no CLI flags). */
|
|
504
|
+
export type CliLeafKind = "document" | "json";
|
|
505
505
|
|
|
506
506
|
/**
|
|
507
507
|
* A leaf command node with a handler and optional positionals.
|
|
508
508
|
*/
|
|
509
509
|
export type CliLeaf = CliNodeBase & {
|
|
510
510
|
/**
|
|
511
|
-
* When `"json"
|
|
512
|
-
* MCP/HTTP tool args = body). Requires `inputSchema`;
|
|
511
|
+
* When `"document"` (or legacy `"json"`), the leaf accepts a single JSON or YAML document
|
|
512
|
+
* (CLI positional or piped stdin; MCP/HTTP tool args = body). Requires `inputSchema`;
|
|
513
|
+
* forbids `options` and `positionals`.
|
|
513
514
|
*/
|
|
514
515
|
kind?: CliLeafKind;
|
|
515
516
|
/** Handler function for leaf commands. */
|
|
@@ -691,9 +692,20 @@ export function isCliLeaf(node: CliNode): node is CliLeaf {
|
|
|
691
692
|
return "handler" in node && typeof node.handler === "function";
|
|
692
693
|
}
|
|
693
694
|
|
|
694
|
-
/** True when the leaf accepts a
|
|
695
|
-
export function
|
|
696
|
-
|
|
695
|
+
/** True when the leaf accepts a structured JSON or YAML document body (no CLI flags). */
|
|
696
|
+
export function isDocumentLeaf(
|
|
697
|
+
/** Leaf command node to inspect. */
|
|
698
|
+
leaf: CliLeaf,
|
|
699
|
+
): boolean {
|
|
700
|
+
return leaf.kind === "document" || leaf.kind === "json";
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** True when the leaf accepts a structured document body (backward-compatible alias for `isDocumentLeaf`). */
|
|
704
|
+
export function isJsonLeaf(
|
|
705
|
+
/** Leaf command node to inspect. */
|
|
706
|
+
leaf: CliLeaf,
|
|
707
|
+
): boolean {
|
|
708
|
+
return isDocumentLeaf(leaf);
|
|
697
709
|
}
|
|
698
710
|
|
|
699
711
|
/** True when the node is a router (has subcommands). */
|
package/src/core/validate.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
CliValueFormat,
|
|
18
18
|
isCliLeaf,
|
|
19
19
|
isCliRouter,
|
|
20
|
-
|
|
20
|
+
isDocumentLeaf,
|
|
21
21
|
} from "./types.ts";
|
|
22
22
|
|
|
23
23
|
/** Validates `docs` configuration on the program root. */
|
|
@@ -264,15 +264,16 @@ function walkNode(node: CliNode, program: CliProgram, isRoot: boolean): void {
|
|
|
264
264
|
if (isRoot && node.mcpTool !== undefined) {
|
|
265
265
|
throw new CliSchemaValidationError("mcpTool is only supported on leaf commands");
|
|
266
266
|
}
|
|
267
|
-
if (
|
|
267
|
+
if (isDocumentLeaf(node)) {
|
|
268
|
+
const kindStr = `kind: "${node.kind ?? "document"}"`;
|
|
268
269
|
if (node.inputSchema === undefined) {
|
|
269
|
-
throw new CliSchemaValidationError(
|
|
270
|
+
throw new CliSchemaValidationError(`${kindStr} requires inputSchema on ${node.key}`);
|
|
270
271
|
}
|
|
271
272
|
if ((node.options ?? []).length > 0) {
|
|
272
|
-
throw new CliSchemaValidationError(
|
|
273
|
+
throw new CliSchemaValidationError(`${kindStr} forbids options on ${node.key}`);
|
|
273
274
|
}
|
|
274
275
|
if ((node.positionals ?? []).length > 0) {
|
|
275
|
-
throw new CliSchemaValidationError(
|
|
276
|
+
throw new CliSchemaValidationError(`${kindStr} forbids positionals on ${node.key}`);
|
|
276
277
|
}
|
|
277
278
|
}
|
|
278
279
|
const outputSchema = node.outputSchema;
|
package/src/exports/cli.ts
CHANGED
package/src/help.test.ts
CHANGED
|
@@ -278,6 +278,46 @@ describe("cliHelpRender", () => {
|
|
|
278
278
|
expect(help).toContain("# Generated ID.");
|
|
279
279
|
expect(help).toContain("id: string");
|
|
280
280
|
});
|
|
281
|
+
|
|
282
|
+
/** Tests that document leaf commands render [DOCUMENT] usage and schema sections. */
|
|
283
|
+
test("document leaf renders [DOCUMENT] usage and schemas in non-TTY mode", () => {
|
|
284
|
+
const root = testProgram({
|
|
285
|
+
key: "myapp",
|
|
286
|
+
version: "1.0.0",
|
|
287
|
+
description: "Test application.",
|
|
288
|
+
commands: [
|
|
289
|
+
{
|
|
290
|
+
key: "deploy",
|
|
291
|
+
kind: "document",
|
|
292
|
+
description: "Deploy from document.",
|
|
293
|
+
inputSchema: {
|
|
294
|
+
type: "object",
|
|
295
|
+
properties: {
|
|
296
|
+
target: { type: "string", description: "Deployment target." },
|
|
297
|
+
},
|
|
298
|
+
required: ["target"],
|
|
299
|
+
},
|
|
300
|
+
outputSchema: {
|
|
301
|
+
type: "object",
|
|
302
|
+
properties: {
|
|
303
|
+
url: { type: "string", description: "Deployment URL." },
|
|
304
|
+
},
|
|
305
|
+
required: ["url"],
|
|
306
|
+
},
|
|
307
|
+
handler: () => {},
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
});
|
|
311
|
+
const help = cliHelpRender(cliPresentationRoot(root), ["deploy"], false, { isTTY: false });
|
|
312
|
+
expect(help).toContain("myapp deploy [DOCUMENT]");
|
|
313
|
+
expect(help).toContain("Pass a JSON or YAML document as an argument or pipe to stdin.");
|
|
314
|
+
expect(help).toContain("Input Schema:");
|
|
315
|
+
expect(help).toContain("# Deployment target.");
|
|
316
|
+
expect(help).toContain("target: string");
|
|
317
|
+
expect(help).toContain("Output Schema (JSON):");
|
|
318
|
+
expect(help).toContain("# Deployment URL.");
|
|
319
|
+
expect(help).toContain("url: string");
|
|
320
|
+
});
|
|
281
321
|
});
|
|
282
322
|
|
|
283
323
|
/** Tests for converting JSON Schema to human- and agent-friendly YAML lines. */
|
package/src/help.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type CliRouter,
|
|
17
17
|
isCliLeaf,
|
|
18
18
|
isCliRouter,
|
|
19
|
-
|
|
19
|
+
isDocumentLeaf,
|
|
20
20
|
} from "./core/types.ts";
|
|
21
21
|
import { visibleOptions, visibleSubcommands } from "./runtime/exposure.ts";
|
|
22
22
|
|
|
@@ -376,8 +376,9 @@ function usageLines(
|
|
|
376
376
|
helpPath: string[],
|
|
377
377
|
hasCommands: boolean,
|
|
378
378
|
hasArgs: boolean,
|
|
379
|
-
|
|
379
|
+
documentLeaf: boolean,
|
|
380
380
|
color: boolean,
|
|
381
|
+
leafKind?: string,
|
|
381
382
|
): string[] {
|
|
382
383
|
let fullPath = appName;
|
|
383
384
|
for (const seg of helpPath) {
|
|
@@ -386,7 +387,8 @@ function usageLines(
|
|
|
386
387
|
const usageOpts = color ? style.aquaBold("[OPTIONS]") : "[OPTIONS]";
|
|
387
388
|
const usageCmd = color ? style.aquaBold("COMMAND") : "COMMAND";
|
|
388
389
|
const usageArgs = color ? style.aquaBold("[ARGS]...") : "[ARGS]...";
|
|
389
|
-
const
|
|
390
|
+
const docTag = leafKind === "document" ? "[DOCUMENT]" : "[JSON]";
|
|
391
|
+
const usageDoc = color ? style.aquaBold(docTag) : docTag;
|
|
390
392
|
|
|
391
393
|
const out: string[] = [];
|
|
392
394
|
if (helpPath.length === 0) {
|
|
@@ -397,8 +399,8 @@ function usageLines(
|
|
|
397
399
|
}
|
|
398
400
|
return out;
|
|
399
401
|
}
|
|
400
|
-
if (
|
|
401
|
-
out.push(`${fullPath} ${
|
|
402
|
+
if (documentLeaf) {
|
|
403
|
+
out.push(`${fullPath} ${usageDoc}`);
|
|
402
404
|
return out;
|
|
403
405
|
}
|
|
404
406
|
out.push(`${fullPath} ${usageOpts}${hasArgs ? ` ${usageArgs}` : ""}`);
|
|
@@ -408,10 +410,11 @@ function usageLines(
|
|
|
408
410
|
return out;
|
|
409
411
|
}
|
|
410
412
|
|
|
411
|
-
/** Table rows for `kind: "json"` leaf input (schema properties + stdin hint). */
|
|
412
|
-
function rowsForJsonInput(inputSchema: Record<string, unknown> | undefined): HelpRow[] {
|
|
413
|
-
const hint = "Pass a JSON document as an argument or pipe to stdin.";
|
|
414
|
-
const
|
|
413
|
+
/** Table rows for `kind: "document"` / `kind: "json"` leaf input (schema properties + stdin hint). */
|
|
414
|
+
function rowsForJsonInput(inputSchema: Record<string, unknown> | undefined, kind?: string): HelpRow[] {
|
|
415
|
+
const hint = "Pass a JSON or YAML document as an argument or pipe to stdin.";
|
|
416
|
+
const label = kind === "document" ? "DOCUMENT" : "JSON";
|
|
417
|
+
const rows: HelpRow[] = [{ label, description: hint }];
|
|
415
418
|
const props = inputSchema?.properties;
|
|
416
419
|
if (!props || typeof props !== "object" || Array.isArray(props)) {
|
|
417
420
|
return rows;
|
|
@@ -763,7 +766,7 @@ export function cliHelpRender(
|
|
|
763
766
|
if (isCliLeaf(schema as unknown as CliNode) && showSchema) {
|
|
764
767
|
const leaf = schema as unknown as CliLeaf;
|
|
765
768
|
if (leaf.outputSchema !== undefined) {
|
|
766
|
-
const title =
|
|
769
|
+
const title = isDocumentLeaf(leaf) ? "Output Schema (JSON)" : "Output Schema (with --json)";
|
|
767
770
|
const yamlLines = schemaToYamlLines(leaf.outputSchema, 0);
|
|
768
771
|
if (yamlLines.length > 0) {
|
|
769
772
|
lines.push("");
|
|
@@ -800,14 +803,15 @@ export function cliHelpRender(
|
|
|
800
803
|
lines.push(color ? style.white(node.description) : node.description);
|
|
801
804
|
lines.push("");
|
|
802
805
|
}
|
|
803
|
-
const
|
|
806
|
+
const nodeIsDocumentLeaf = isCliLeaf(node) && isDocumentLeaf(node);
|
|
804
807
|
const usage = usageLines(
|
|
805
808
|
schema.key,
|
|
806
809
|
helpPath,
|
|
807
810
|
isCliRouter(node) && node.commands.length > 0,
|
|
808
811
|
isCliLeaf(node) && (node.positionals ?? []).length > 0,
|
|
809
|
-
|
|
812
|
+
nodeIsDocumentLeaf,
|
|
810
813
|
color,
|
|
814
|
+
isCliLeaf(node) ? node.kind : undefined,
|
|
811
815
|
);
|
|
812
816
|
if (isTTY) {
|
|
813
817
|
lines.push(renderTextBox("Usage", usage, hw, color).join("\n"));
|
|
@@ -815,8 +819,8 @@ export function cliHelpRender(
|
|
|
815
819
|
lines.push(renderPlainSection("Usage", usage).join("\n"));
|
|
816
820
|
}
|
|
817
821
|
|
|
818
|
-
if (
|
|
819
|
-
const inputRows = rowsForJsonInput(node.inputSchema);
|
|
822
|
+
if (nodeIsDocumentLeaf && isCliLeaf(node)) {
|
|
823
|
+
const inputRows = rowsForJsonInput(node.inputSchema, node.kind);
|
|
820
824
|
const inputBox = isTTY ? renderTableBox("Input", inputRows, hw, color) : renderPlainTable("Input", inputRows, hw);
|
|
821
825
|
if (inputBox.length > 0) {
|
|
822
826
|
lines.push("");
|
|
@@ -860,7 +864,7 @@ export function cliHelpRender(
|
|
|
860
864
|
}
|
|
861
865
|
|
|
862
866
|
if (isCliLeaf(node) && node.outputSchema !== undefined && showSchema) {
|
|
863
|
-
const title =
|
|
867
|
+
const title = nodeIsDocumentLeaf ? "Output Schema (JSON)" : "Output Schema (with --json)";
|
|
864
868
|
const yamlLines = schemaToYamlLines(node.outputSchema, 0);
|
|
865
869
|
if (yamlLines.length > 0) {
|
|
866
870
|
lines.push("");
|
package/src/http/openapi.ts
CHANGED
|
@@ -3,7 +3,7 @@ Hand-built OpenAPI 3.1 document from exposed HTTP REST routes.
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { CliHttpMethod, CliNode, CliProgram } from "../core/types.ts";
|
|
6
|
-
import { CliOptionKind, isCliLeaf,
|
|
6
|
+
import { CliOptionKind, isCliLeaf, isDocumentLeaf } from "../core/types.ts";
|
|
7
7
|
import { leafWireOptions } from "../mcp/tools.ts";
|
|
8
8
|
import { collectHttpRoutes, defaultSuccessStatus } from "./routes.ts";
|
|
9
9
|
import { dereferenceJsonSchema } from "./schema-deref.ts";
|
|
@@ -253,7 +253,7 @@ export function generateOpenApi(program: CliProgram): Record<string, unknown> {
|
|
|
253
253
|
];
|
|
254
254
|
} else {
|
|
255
255
|
op.requestBody = {
|
|
256
|
-
required:
|
|
256
|
+
required: isDocumentLeaf(route.leaf),
|
|
257
257
|
content: {
|
|
258
258
|
[JSON_CONTENT_TYPE]: {
|
|
259
259
|
schema: dereferenceJsonSchema(buildInputSchema(program, route)),
|
package/src/http/routes.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type CliNode,
|
|
9
9
|
type CliProgram,
|
|
10
10
|
isCliLeaf,
|
|
11
|
-
|
|
11
|
+
isDocumentLeaf,
|
|
12
12
|
CliOptionKind as OptKind,
|
|
13
13
|
} from "../core/types.ts";
|
|
14
14
|
import { formatMcpOptionValue, leafHasYesOption, leafWireOptions } from "../mcp/tools.ts";
|
|
@@ -252,7 +252,7 @@ export function httpRequestToArgv(
|
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
const leaf = route.leaf;
|
|
255
|
-
if (
|
|
255
|
+
if (isDocumentLeaf(leaf)) {
|
|
256
256
|
return argv;
|
|
257
257
|
}
|
|
258
258
|
|
package/src/http/server.ts
CHANGED
|
@@ -175,7 +175,15 @@ export async function handleApiRequest(
|
|
|
175
175
|
}
|
|
176
176
|
body = parsed as Record<string, unknown>;
|
|
177
177
|
} catch {
|
|
178
|
-
|
|
178
|
+
try {
|
|
179
|
+
const parsed = Bun.YAML.parse(rawBody);
|
|
180
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
181
|
+
return finish(apiErrorResponse(400, { error: "Request body must be a JSON object" }));
|
|
182
|
+
}
|
|
183
|
+
body = parsed as Record<string, unknown>;
|
|
184
|
+
} catch {
|
|
185
|
+
return finish(apiErrorResponse(400, { error: "Invalid JSON body" }));
|
|
186
|
+
}
|
|
179
187
|
}
|
|
180
188
|
}
|
|
181
189
|
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export {
|
|
|
18
18
|
} from "./core/formats.ts";
|
|
19
19
|
export {
|
|
20
20
|
LeafInputError,
|
|
21
|
+
parseDocumentText,
|
|
21
22
|
preloadPipableJson,
|
|
22
23
|
readJsonOptionValue,
|
|
23
24
|
} from "./core/leaf-inputs.ts";
|
|
@@ -68,6 +69,7 @@ export {
|
|
|
68
69
|
CliOptionKind,
|
|
69
70
|
CliSchemaValidationError,
|
|
70
71
|
CliValueFormat,
|
|
72
|
+
isDocumentLeaf,
|
|
71
73
|
isJsonLeaf,
|
|
72
74
|
} from "./core/types.ts";
|
|
73
75
|
export type { HeadlessContext } from "./headless/routing.ts";
|
package/src/mcp/tools.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type CliProgram,
|
|
14
14
|
CliValueFormat,
|
|
15
15
|
isCliLeaf,
|
|
16
|
-
|
|
16
|
+
isDocumentLeaf,
|
|
17
17
|
leafOutputSchema,
|
|
18
18
|
} from "../core/types.ts";
|
|
19
19
|
import { docsMcpResources } from "../docs/mcp-resources.ts";
|
|
@@ -155,7 +155,7 @@ function positionalProperty(p: CliPositional): Record<string, unknown> {
|
|
|
155
155
|
|
|
156
156
|
/** Builds inputSchema for a leaf command. */
|
|
157
157
|
function buildInputSchema(leaf: CliLeaf): Record<string, unknown> {
|
|
158
|
-
if (
|
|
158
|
+
if (isDocumentLeaf(leaf) && leaf.inputSchema !== undefined) {
|
|
159
159
|
return leaf.inputSchema;
|
|
160
160
|
}
|
|
161
161
|
|
|
@@ -294,7 +294,7 @@ export function mcpToolCallToArgv(
|
|
|
294
294
|
tool: McpToolDef,
|
|
295
295
|
args: Record<string, unknown>,
|
|
296
296
|
): string[] | { error: string } {
|
|
297
|
-
if (
|
|
297
|
+
if (isDocumentLeaf(tool.leaf)) {
|
|
298
298
|
return [...tool.path];
|
|
299
299
|
}
|
|
300
300
|
|
|
@@ -448,6 +448,42 @@ describe("HTTP API routes", () => {
|
|
|
448
448
|
expect(body).toEqual({ error: "bad input" });
|
|
449
449
|
});
|
|
450
450
|
|
|
451
|
+
/** Tests that POST endpoints accept YAML request bodies. */
|
|
452
|
+
test("POST accepts YAML request body", async () => {
|
|
453
|
+
const yamlProgram = testProgram({
|
|
454
|
+
key: "app",
|
|
455
|
+
description: "Test app",
|
|
456
|
+
httpServer: { enabled: true },
|
|
457
|
+
commands: [
|
|
458
|
+
{
|
|
459
|
+
key: "create",
|
|
460
|
+
kind: "document",
|
|
461
|
+
description: "Create resource",
|
|
462
|
+
inputSchema: {
|
|
463
|
+
type: "object",
|
|
464
|
+
properties: { name: { type: "string" } },
|
|
465
|
+
required: ["name"],
|
|
466
|
+
},
|
|
467
|
+
handler: (ctx: CliContextType) => {
|
|
468
|
+
return { created: ctx.inputsAs<{ name: string }>().name };
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
});
|
|
473
|
+
cliValidateProgram(yamlProgram);
|
|
474
|
+
const res = await apiRequest(
|
|
475
|
+
yamlProgram,
|
|
476
|
+
new Request("http://127.0.0.1/create", {
|
|
477
|
+
method: "POST",
|
|
478
|
+
headers: { "content-type": "application/yaml" },
|
|
479
|
+
body: "name: test-resource",
|
|
480
|
+
}),
|
|
481
|
+
);
|
|
482
|
+
expect(res.status).toBe(201);
|
|
483
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
484
|
+
expect(body).toEqual({ created: "test-resource" });
|
|
485
|
+
});
|
|
486
|
+
|
|
451
487
|
test("GET /openapi.json lists REST paths", async () => {
|
|
452
488
|
const res = await apiRequest(program, new Request("http://127.0.0.1/openapi.json"));
|
|
453
489
|
expect(res.status).toBe(200);
|