argsbarg 7.0.7 → 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 +20 -1
- package/README.md +1 -1
- package/docs/cli-program.md +22 -7
- package/docs/output-schema.md +1 -0
- 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 +284 -1
- package/src/help.ts +412 -57
- 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,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
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."`
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Non-TTY in-band schema discovery in `--help`** — when `--help` is invoked in non-TTY environments (such as pipes, scripts, and AI agent subprocesses), argsbarg automatically outputs full, untruncated YAML `Output Schema` (and `Input Schema` on `kind: "json"` commands) below command options and arguments. Enables zero-drift contract discovery for AI agents in a single turn without reading external documentation.
|
|
25
|
+
- **Unboxed plain-text help in non-TTY** — strips Unicode box borders, vertical bars, and trailing whitespace padding when output is not a TTY, outputting clean, indented plain text that optimizes token usage and prevents parsing artifacts in automated tooling. TTY sessions retain compact, rounded UTF-8 boxes without schema bloat by default.
|
|
26
|
+
- **`schemaToYamlLines` helper** — exported utility converting JSON Schema definitions (with `$ref` resolution, property JSDoc comments, optional `?` markers, and enums) into clean, human- and agent-readable YAML representation.
|
|
27
|
+
|
|
10
28
|
## [7.0.7] - 2026-09-15
|
|
11
29
|
|
|
12
30
|
### Removed
|
|
@@ -984,7 +1002,8 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
|
|
|
984
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`).
|
|
985
1003
|
- Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
|
|
986
1004
|
|
|
987
|
-
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v7.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
|
|
988
1007
|
[7.0.7]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.7
|
|
989
1008
|
[7.0.6]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.6
|
|
990
1009
|
[7.0.5]: https://github.com/bdombro/bun-argsbarg/releases/tag/v7.0.5
|
package/README.md
CHANGED
|
@@ -148,7 +148,7 @@ ArgsBarg automatically integrates several core features into your application. T
|
|
|
148
148
|
|
|
149
149
|
### Core Capabilities (Stable)
|
|
150
150
|
|
|
151
|
-
- `-h` / `--help` — Highly-formatted, terminal-width scoped help at any routing depth.
|
|
151
|
+
- `-h` / `--help` — Highly-formatted, terminal-width scoped help at any routing depth. Rounded UTF-8 boxes in TTY; unboxed plain text with in-band YAML input and output schemas in non-TTY for zero-drift agent discovery.
|
|
152
152
|
- `version` — Print the program's version (e.g., `myapp version`).
|
|
153
153
|
- `http` — Launch the high-performance HTTP REST server (injected when `httpServer.enabled` is `true`).
|
|
154
154
|
- `completion bash` / `zsh` / `fish` — Generate shell completion scripts to stdout for deployment and packaging.
|
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/docs/output-schema.md
CHANGED
|
@@ -23,6 +23,7 @@ export const status = {
|
|
|
23
23
|
| `myapp docs cli` | Markdown per-command **Output** section |
|
|
24
24
|
| MCP `tools/list` | Optional `outputSchema` on each tool |
|
|
25
25
|
| HTTP `GET /openapi.json` | Response schema per tool |
|
|
26
|
+
| CLI `--help` (non-TTY) | YAML output schema for zero-drift in-band agent discovery |
|
|
26
27
|
|
|
27
28
|
**Not validated at runtime** — argsbarg does not parse or reject handler stdout against the schema today. The schema is documentation and MCP/HTTP metadata.
|
|
28
29
|
|
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;
|