argsbarg 7.0.8 → 7.0.10
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 +22 -3
- package/docs/cli-program.md +22 -7
- package/examples/full-example/docs/cli-schema.json +20 -24
- package/examples/full-example/docs/cli.md +4 -26
- package/examples/full-example/docs/openapi.json +3 -1
- package/examples/full-example-json/docs/cli-schema.json +102 -108
- package/examples/full-example-json/docs/cli.md +18 -117
- package/examples/full-example-json/docs/openapi.json +3 -1
- package/index.d.ts +36 -6
- package/package.json +1 -1
- package/src/builtins/export.ts +2 -0
- 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/schema.ts +2 -0
- package/src/core/types.ts +19 -7
- package/src/core/validate.ts +6 -5
- package/src/core/wire-schema.test.ts +256 -0
- package/src/core/wire-schema.ts +131 -0
- package/src/docs/cli-guide.test.ts +53 -0
- package/src/exports/cli.ts +2 -0
- package/src/help.test.ts +40 -0
- package/src/help.ts +19 -15
- package/src/http/openapi.ts +4 -40
- package/src/http/routes.ts +2 -2
- package/src/http/server.ts +9 -1
- package/src/index.ts +3 -0
- package/src/mcp/tools.ts +17 -106
- package/src/test/integration/http.test.ts +36 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Tests for canonical wire input schema generation and wire option filtering.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { type CliLeaf, CliOptionKind, CliValueFormat } from "./types.ts";
|
|
7
|
+
import { buildLeafInputSchema, leafWireOptions } from "./wire-schema.ts";
|
|
8
|
+
|
|
9
|
+
/** Tests for leafWireOptions. */
|
|
10
|
+
describe("leafWireOptions", () => {
|
|
11
|
+
/** Tests filtering of framework-handled presence flags. */
|
|
12
|
+
test("omits json, yes, and verbose presence flags", () => {
|
|
13
|
+
const leaf: CliLeaf = {
|
|
14
|
+
key: "test",
|
|
15
|
+
description: "Test command",
|
|
16
|
+
options: [
|
|
17
|
+
{ name: "message", description: "Message text", kind: CliOptionKind.String },
|
|
18
|
+
{ name: "json", description: "Output JSON", kind: CliOptionKind.Presence },
|
|
19
|
+
{ name: "yes", description: "Auto-confirm", kind: CliOptionKind.Presence },
|
|
20
|
+
{ name: "verbose", description: "Verbose logging", kind: CliOptionKind.Presence },
|
|
21
|
+
{ name: "dry-run", description: "Dry run mode", kind: CliOptionKind.Presence },
|
|
22
|
+
],
|
|
23
|
+
handler: () => {},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const wire = leafWireOptions(leaf);
|
|
27
|
+
const names = wire.map((o) => o.name);
|
|
28
|
+
expect(names).toEqual(["message", "dry-run"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Tests that hidden options are excluded from wire schemas. */
|
|
32
|
+
test("omits hidden options", () => {
|
|
33
|
+
const leaf: CliLeaf = {
|
|
34
|
+
key: "test",
|
|
35
|
+
description: "Test command",
|
|
36
|
+
options: [
|
|
37
|
+
{ name: "visible", description: "Visible option", kind: CliOptionKind.String },
|
|
38
|
+
{ name: "secret", description: "Secret option", kind: CliOptionKind.String, cli: { hidden: true } },
|
|
39
|
+
],
|
|
40
|
+
handler: () => {},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const wire = leafWireOptions(leaf);
|
|
44
|
+
expect(wire.map((o) => o.name)).toEqual(["visible"]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** Tests for buildLeafInputSchema. */
|
|
49
|
+
describe("buildLeafInputSchema", () => {
|
|
50
|
+
/** Tests that explicitly set inputSchema is returned as-is. */
|
|
51
|
+
test("returns explicit inputSchema unchanged", () => {
|
|
52
|
+
const customSchema = {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: { custom: { type: "integer" } },
|
|
55
|
+
required: ["custom"],
|
|
56
|
+
};
|
|
57
|
+
const leaf: CliLeaf = {
|
|
58
|
+
key: "doc",
|
|
59
|
+
description: "Document command",
|
|
60
|
+
kind: "document",
|
|
61
|
+
inputSchema: customSchema,
|
|
62
|
+
handler: () => {},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
expect(buildLeafInputSchema(leaf)).toBe(customSchema);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** Tests synthesizing inputSchema for flag-based commands. */
|
|
69
|
+
test("synthesizes inputSchema from wire options and positionals", () => {
|
|
70
|
+
const leaf: CliLeaf = {
|
|
71
|
+
key: "create",
|
|
72
|
+
description: "Create resource",
|
|
73
|
+
options: [
|
|
74
|
+
{
|
|
75
|
+
name: "name",
|
|
76
|
+
description: "Resource name",
|
|
77
|
+
kind: CliOptionKind.String,
|
|
78
|
+
required: true,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "count",
|
|
82
|
+
description: "Item count",
|
|
83
|
+
kind: CliOptionKind.Number,
|
|
84
|
+
default: "1",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "force",
|
|
88
|
+
description: "Force creation",
|
|
89
|
+
kind: CliOptionKind.Presence,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "tier",
|
|
93
|
+
description: "Account tier",
|
|
94
|
+
kind: CliOptionKind.Enum,
|
|
95
|
+
choices: ["free", "pro", "enterprise"],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "metadata",
|
|
99
|
+
description: "Raw metadata",
|
|
100
|
+
kind: CliOptionKind.Json,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "json",
|
|
104
|
+
description: "Omitted presence flag",
|
|
105
|
+
kind: CliOptionKind.Presence,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
positionals: [
|
|
109
|
+
{
|
|
110
|
+
name: "target",
|
|
111
|
+
description: "Deployment target",
|
|
112
|
+
kind: CliOptionKind.String,
|
|
113
|
+
argMin: 1,
|
|
114
|
+
argMax: 1,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
handler: () => {},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const schema = buildLeafInputSchema(leaf);
|
|
121
|
+
expect(schema).toEqual({
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
name: {
|
|
125
|
+
type: "string",
|
|
126
|
+
description: "Resource name",
|
|
127
|
+
},
|
|
128
|
+
count: {
|
|
129
|
+
type: "number",
|
|
130
|
+
description: "Item count",
|
|
131
|
+
default: "1",
|
|
132
|
+
},
|
|
133
|
+
force: {
|
|
134
|
+
type: "boolean",
|
|
135
|
+
description: "Force creation",
|
|
136
|
+
},
|
|
137
|
+
tier: {
|
|
138
|
+
type: "string",
|
|
139
|
+
enum: ["free", "pro", "enterprise"],
|
|
140
|
+
description: "Account tier",
|
|
141
|
+
},
|
|
142
|
+
metadata: {
|
|
143
|
+
type: "object",
|
|
144
|
+
description: "Raw metadata",
|
|
145
|
+
},
|
|
146
|
+
target: {
|
|
147
|
+
type: "string",
|
|
148
|
+
description: "Deployment target",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
additionalProperties: false,
|
|
152
|
+
required: ["name", "target"],
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/** Tests string format constraints in synthesized inputSchema. */
|
|
157
|
+
test("synthesizes formatted string options and varargs positionals", () => {
|
|
158
|
+
const leaf: CliLeaf = {
|
|
159
|
+
key: "query",
|
|
160
|
+
description: "Query resources",
|
|
161
|
+
options: [
|
|
162
|
+
{
|
|
163
|
+
name: "tags",
|
|
164
|
+
description: "Comma-separated tag list",
|
|
165
|
+
kind: CliOptionKind.String,
|
|
166
|
+
format: CliValueFormat.CommaList,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "timeout",
|
|
170
|
+
description: "Timeout duration",
|
|
171
|
+
kind: CliOptionKind.String,
|
|
172
|
+
format: CliValueFormat.Duration,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "since",
|
|
176
|
+
description: "Start date",
|
|
177
|
+
kind: CliOptionKind.String,
|
|
178
|
+
format: CliValueFormat.Date,
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "timestamp",
|
|
182
|
+
description: "ISO timestamp",
|
|
183
|
+
kind: CliOptionKind.String,
|
|
184
|
+
format: CliValueFormat.DateTime,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "code",
|
|
188
|
+
description: "Custom code format",
|
|
189
|
+
kind: CliOptionKind.String,
|
|
190
|
+
pattern: "^[A-Z]{3}$",
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
positionals: [
|
|
194
|
+
{
|
|
195
|
+
name: "files",
|
|
196
|
+
description: "Files to process",
|
|
197
|
+
kind: CliOptionKind.String,
|
|
198
|
+
argMin: 0,
|
|
199
|
+
argMax: 0,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
handler: () => {},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const schema = buildLeafInputSchema(leaf) as {
|
|
206
|
+
type: string;
|
|
207
|
+
properties: Record<string, unknown>;
|
|
208
|
+
required?: string[];
|
|
209
|
+
};
|
|
210
|
+
expect(schema.type).toBe("object");
|
|
211
|
+
expect(schema.required).toBeUndefined();
|
|
212
|
+
|
|
213
|
+
// CommaList
|
|
214
|
+
expect(schema.properties.tags).toEqual({
|
|
215
|
+
oneOf: [
|
|
216
|
+
{ type: "string", description: "Comma-separated tag list" },
|
|
217
|
+
{ type: "array", items: { type: "string" }, description: "Comma-separated tag list" },
|
|
218
|
+
],
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Duration
|
|
222
|
+
expect(schema.properties.timeout).toEqual({
|
|
223
|
+
type: "string",
|
|
224
|
+
description: "Timeout duration",
|
|
225
|
+
pattern: "^\\d+[hdms]?$",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Date
|
|
229
|
+
expect(schema.properties.since).toEqual({
|
|
230
|
+
type: "string",
|
|
231
|
+
description: "Start date",
|
|
232
|
+
format: "date",
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// DateTime
|
|
236
|
+
expect(schema.properties.timestamp).toEqual({
|
|
237
|
+
type: "string",
|
|
238
|
+
description: "ISO timestamp",
|
|
239
|
+
format: "date-time",
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Regex pattern
|
|
243
|
+
expect(schema.properties.code).toEqual({
|
|
244
|
+
type: "string",
|
|
245
|
+
description: "Custom code format",
|
|
246
|
+
pattern: "^[A-Z]{3}$",
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Varargs positional
|
|
250
|
+
expect(schema.properties.files).toEqual({
|
|
251
|
+
type: "array",
|
|
252
|
+
items: { type: "string" },
|
|
253
|
+
description: "Files to process",
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Canonical wire input schema generation for MCP tools, OpenAPI parameters, and CLI schema export.
|
|
3
|
+
Synthesizes a JSON Schema object from leaf-local options and positionals when inputSchema is not explicitly set.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { visibleOptions } from "../runtime/exposure.ts";
|
|
7
|
+
import { type CliLeaf, type CliOption, CliOptionKind, type CliPositional, CliValueFormat } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** Regular expression pattern for duration option format (e.g. 5m, 1h, 30s). */
|
|
10
|
+
const DURATION_PATTERN = "^\\d+[hdms]?$";
|
|
11
|
+
|
|
12
|
+
/** Presence flags omitted from wire schemas because they are handled by the framework runtime. */
|
|
13
|
+
const MCP_WIRE_OMIT_PRESENCE = new Set(["json", "yes", "verbose"]);
|
|
14
|
+
|
|
15
|
+
/** JSON Schema property for one leaf option in wire schemas. */
|
|
16
|
+
function optionProperty(
|
|
17
|
+
/** Option definition to format as a schema property. */
|
|
18
|
+
opt: CliOption,
|
|
19
|
+
): Record<string, unknown> {
|
|
20
|
+
const base: Record<string, unknown> = {
|
|
21
|
+
description: opt.description,
|
|
22
|
+
};
|
|
23
|
+
if (opt.default !== undefined) {
|
|
24
|
+
base.default = opt.default;
|
|
25
|
+
}
|
|
26
|
+
switch (opt.kind) {
|
|
27
|
+
case CliOptionKind.Presence:
|
|
28
|
+
return { type: "boolean", ...base };
|
|
29
|
+
case CliOptionKind.String: {
|
|
30
|
+
if (opt.format === CliValueFormat.CommaList) {
|
|
31
|
+
return {
|
|
32
|
+
oneOf: [
|
|
33
|
+
{ type: "string", ...base },
|
|
34
|
+
{ type: "array", items: { type: "string" }, ...base },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const stringBase = { type: "string", ...base };
|
|
39
|
+
if (opt.format === CliValueFormat.Duration) {
|
|
40
|
+
return { ...stringBase, pattern: DURATION_PATTERN };
|
|
41
|
+
}
|
|
42
|
+
if (opt.format === CliValueFormat.Date) {
|
|
43
|
+
return { ...stringBase, format: "date" };
|
|
44
|
+
}
|
|
45
|
+
if (opt.format === CliValueFormat.DateTime) {
|
|
46
|
+
return { ...stringBase, format: "date-time" };
|
|
47
|
+
}
|
|
48
|
+
if (opt.pattern !== undefined) {
|
|
49
|
+
return { ...stringBase, pattern: opt.pattern };
|
|
50
|
+
}
|
|
51
|
+
return stringBase;
|
|
52
|
+
}
|
|
53
|
+
case CliOptionKind.Number:
|
|
54
|
+
return { type: "number", ...base };
|
|
55
|
+
case CliOptionKind.Enum:
|
|
56
|
+
return { type: "string", enum: opt.choices, ...base };
|
|
57
|
+
case CliOptionKind.Json:
|
|
58
|
+
return { type: "object", ...base };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** JSON Schema property for one positional argument slot in wire schemas. */
|
|
63
|
+
function positionalProperty(
|
|
64
|
+
/** Positional argument definition to format as a schema property. */
|
|
65
|
+
p: CliPositional,
|
|
66
|
+
): Record<string, unknown> {
|
|
67
|
+
const base = { description: p.description };
|
|
68
|
+
const { argMax = 1 } = p;
|
|
69
|
+
if (argMax === 0) {
|
|
70
|
+
return { type: "array", items: { type: "string" }, ...base };
|
|
71
|
+
}
|
|
72
|
+
return { type: "string", ...base };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Filters leaf-local options to only those exposed over wire protocols (MCP, OpenAPI, CLI schema export).
|
|
77
|
+
* Omits hidden options and framework-handled presence flags (`--json`, `--yes`, `--verbose`).
|
|
78
|
+
*/
|
|
79
|
+
export function leafWireOptions(
|
|
80
|
+
/** Leaf command node to extract wire options from. */
|
|
81
|
+
leaf: CliLeaf,
|
|
82
|
+
): CliOption[] {
|
|
83
|
+
return visibleOptions(leaf.options).filter((o) => {
|
|
84
|
+
if (o.kind === CliOptionKind.Presence && MCP_WIRE_OMIT_PRESENCE.has(o.name)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Builds the canonical input JSON Schema for a leaf command.
|
|
93
|
+
* Returns `leaf.inputSchema` when explicitly defined (e.g. on document leaves or schemagen leaves);
|
|
94
|
+
* otherwise synthesizes a flat object schema from leaf-local wire options and positionals.
|
|
95
|
+
*/
|
|
96
|
+
export function buildLeafInputSchema(
|
|
97
|
+
/** Leaf command node to build the input schema for. */
|
|
98
|
+
leaf: CliLeaf,
|
|
99
|
+
): Record<string, unknown> {
|
|
100
|
+
if (leaf.inputSchema !== undefined) {
|
|
101
|
+
return leaf.inputSchema;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const properties: Record<string, unknown> = {};
|
|
105
|
+
const required: string[] = [];
|
|
106
|
+
|
|
107
|
+
for (const opt of leafWireOptions(leaf)) {
|
|
108
|
+
properties[opt.name] = optionProperty(opt);
|
|
109
|
+
if (opt.required) {
|
|
110
|
+
required.push(opt.name);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const p of leaf.positionals ?? []) {
|
|
115
|
+
properties[p.name] = positionalProperty(p);
|
|
116
|
+
const { argMin = 1, argMax = 1 } = p;
|
|
117
|
+
if (argMax === 1 && argMin >= 1) {
|
|
118
|
+
required.push(p.name);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const schema: Record<string, unknown> = {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties,
|
|
125
|
+
additionalProperties: false,
|
|
126
|
+
};
|
|
127
|
+
if (required.length > 0) {
|
|
128
|
+
schema.required = required;
|
|
129
|
+
}
|
|
130
|
+
return schema;
|
|
131
|
+
}
|
|
@@ -141,3 +141,56 @@ test("generateCliGuide and cliSchemaExport include leaf outputSchema", () => {
|
|
|
141
141
|
expect(md).toContain('"id"');
|
|
142
142
|
expect(md).toContain('"type": "string"');
|
|
143
143
|
});
|
|
144
|
+
|
|
145
|
+
/** Tests that cliSchemaExport includes synthesized and custom inputSchema on leaf commands. */
|
|
146
|
+
test("cliSchemaExport includes leaf inputSchema", () => {
|
|
147
|
+
const fixture: CliProgram = {
|
|
148
|
+
key: "myapp",
|
|
149
|
+
version: "1.0.0",
|
|
150
|
+
description: "Demo app.",
|
|
151
|
+
commands: [
|
|
152
|
+
{
|
|
153
|
+
key: "greet",
|
|
154
|
+
description: "Greet user.",
|
|
155
|
+
options: [
|
|
156
|
+
{ name: "name", description: "User name", kind: CliOptionKind.String, required: true },
|
|
157
|
+
{ name: "json", description: "JSON flag", kind: CliOptionKind.Presence },
|
|
158
|
+
],
|
|
159
|
+
handler: () => {},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
key: "deploy",
|
|
163
|
+
description: "Deploy resource.",
|
|
164
|
+
kind: "document",
|
|
165
|
+
inputSchema: {
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: { target: { type: "string" } },
|
|
168
|
+
required: ["target"],
|
|
169
|
+
},
|
|
170
|
+
handler: () => {},
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const schema = cliSchemaExport(fixture);
|
|
176
|
+
const greet = schema.commands?.[0];
|
|
177
|
+
const deploy = schema.commands?.[1];
|
|
178
|
+
|
|
179
|
+
expect(greet?.inputSchema).toEqual({
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: {
|
|
182
|
+
name: {
|
|
183
|
+
type: "string",
|
|
184
|
+
description: "User name",
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
additionalProperties: false,
|
|
188
|
+
required: ["name"],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(deploy?.inputSchema).toEqual({
|
|
192
|
+
type: "object",
|
|
193
|
+
properties: { target: { type: "string" } },
|
|
194
|
+
required: ["target"],
|
|
195
|
+
});
|
|
196
|
+
});
|
package/src/exports/cli.ts
CHANGED
|
@@ -40,8 +40,10 @@ export {
|
|
|
40
40
|
CliOptionKind,
|
|
41
41
|
CliSchemaValidationError,
|
|
42
42
|
CliValueFormat,
|
|
43
|
+
isDocumentLeaf,
|
|
43
44
|
isJsonLeaf,
|
|
44
45
|
} from "../core/types.ts";
|
|
46
|
+
export { buildLeafInputSchema, leafWireOptions } from "../core/wire-schema.ts";
|
|
45
47
|
export { Cli, type CliInvokeKind, type CliInvokeResult } from "../runtime/cli.ts";
|
|
46
48
|
export { cliErrWithHelp } from "../runtime/cli-errors.ts";
|
|
47
49
|
export { isInteractiveTty } from "../utils.ts";
|
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,8 +3,8 @@ 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 {
|
|
7
|
-
import { leafWireOptions } from "../
|
|
6
|
+
import { isCliLeaf, isDocumentLeaf } from "../core/types.ts";
|
|
7
|
+
import { buildLeafInputSchema, leafWireOptions } from "../core/wire-schema.ts";
|
|
8
8
|
import { collectHttpRoutes, defaultSuccessStatus } from "./routes.ts";
|
|
9
9
|
import { dereferenceJsonSchema } from "./schema-deref.ts";
|
|
10
10
|
|
|
@@ -34,42 +34,6 @@ function errorResponseEntry(program: CliProgram, description: string): Record<st
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function buildInputSchema(
|
|
38
|
-
_program: CliProgram,
|
|
39
|
-
route: ReturnType<typeof collectHttpRoutes>[number],
|
|
40
|
-
): Record<string, unknown> {
|
|
41
|
-
const leaf = route.leaf;
|
|
42
|
-
if (leaf.inputSchema) {
|
|
43
|
-
return leaf.inputSchema;
|
|
44
|
-
}
|
|
45
|
-
const properties: Record<string, unknown> = {};
|
|
46
|
-
const required: string[] = [];
|
|
47
|
-
for (const p of route.paramNames) {
|
|
48
|
-
properties[p] = { type: "string" };
|
|
49
|
-
required.push(p);
|
|
50
|
-
}
|
|
51
|
-
for (const opt of leafWireOptions(leaf)) {
|
|
52
|
-
if (opt.kind === CliOptionKind.Json) {
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
properties[opt.name] = { type: "string", description: opt.description };
|
|
56
|
-
if (opt.required) {
|
|
57
|
-
required.push(opt.name);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
for (const p of leaf.positionals ?? []) {
|
|
61
|
-
properties[p.name] = { type: "string", description: p.description };
|
|
62
|
-
if ((p.argMin ?? 1) >= 1) {
|
|
63
|
-
required.push(p.name);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return {
|
|
67
|
-
type: "object",
|
|
68
|
-
properties,
|
|
69
|
-
...(required.length > 0 ? { required } : {}),
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
37
|
/** Builds success response entries for OpenAPI (status → response object). */
|
|
74
38
|
function buildSuccessResponses(route: ReturnType<typeof collectHttpRoutes>[number]): Record<string, unknown> {
|
|
75
39
|
const contentType = route.leaf.http?.successContentType ?? "application/json";
|
|
@@ -253,10 +217,10 @@ export function generateOpenApi(program: CliProgram): Record<string, unknown> {
|
|
|
253
217
|
];
|
|
254
218
|
} else {
|
|
255
219
|
op.requestBody = {
|
|
256
|
-
required:
|
|
220
|
+
required: isDocumentLeaf(route.leaf),
|
|
257
221
|
content: {
|
|
258
222
|
[JSON_CONTENT_TYPE]: {
|
|
259
|
-
schema: dereferenceJsonSchema(
|
|
223
|
+
schema: dereferenceJsonSchema(buildLeafInputSchema(route.leaf)),
|
|
260
224
|
},
|
|
261
225
|
},
|
|
262
226
|
};
|
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
|
|