argsbarg 7.0.9 → 7.0.11
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 +17 -1
- 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 +17 -0
- package/package.json +1 -1
- package/src/builtins/export.ts +2 -0
- package/src/core/schema.ts +2 -0
- 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 +1 -0
- package/src/help.test.ts +36 -0
- package/src/help.ts +32 -19
- package/src/http/openapi.ts +3 -39
- package/src/index.ts +1 -0
- package/src/mcp/tools.ts +15 -104
|
@@ -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
|
@@ -43,6 +43,7 @@ export {
|
|
|
43
43
|
isDocumentLeaf,
|
|
44
44
|
isJsonLeaf,
|
|
45
45
|
} from "../core/types.ts";
|
|
46
|
+
export { buildLeafInputSchema, leafWireOptions } from "../core/wire-schema.ts";
|
|
46
47
|
export { Cli, type CliInvokeKind, type CliInvokeResult } from "../runtime/cli.ts";
|
|
47
48
|
export { cliErrWithHelp } from "../runtime/cli-errors.ts";
|
|
48
49
|
export { isInteractiveTty } from "../utils.ts";
|
package/src/help.test.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
cliPositionalLabel,
|
|
13
13
|
cliResolveNotes,
|
|
14
14
|
schemaToYamlLines,
|
|
15
|
+
visibleWidth,
|
|
15
16
|
} from "./help.ts";
|
|
16
17
|
import { testProgram } from "./test/fixtures.ts";
|
|
17
18
|
|
|
@@ -214,6 +215,41 @@ describe("cliHelpRender", () => {
|
|
|
214
215
|
expect(help).not.toContain("Output Schema");
|
|
215
216
|
});
|
|
216
217
|
|
|
218
|
+
/** Tests that TTY help table boxes constrain line lengths to terminal width without wrapping border characters. */
|
|
219
|
+
test("TTY help table boxes fit terminal width without overflow", () => {
|
|
220
|
+
const origColumns = process.stdout.columns;
|
|
221
|
+
try {
|
|
222
|
+
process.stdout.columns = 120;
|
|
223
|
+
const root = testProgram({
|
|
224
|
+
key: "doc",
|
|
225
|
+
version: "1.0.0",
|
|
226
|
+
description: "Test application.",
|
|
227
|
+
commands: [
|
|
228
|
+
{
|
|
229
|
+
key: "query",
|
|
230
|
+
description:
|
|
231
|
+
"Query the body tape. Prints apply-shaped YAML `{ documentId, tabs: [{ tabId, ops: [], nodes }] }` (or JSON with --json). Fill ops and pipe to apply.",
|
|
232
|
+
handler: () => {},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
key: "markdown-insert",
|
|
236
|
+
description:
|
|
237
|
+
"Insert markdown into a Google Doc tab as native DOM elements (headings, lists, code, tables).",
|
|
238
|
+
handler: () => {},
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
const help = cliHelpRender(cliPresentationRoot(root), [], false, { isTTY: true });
|
|
243
|
+
const boxLines = help.split("\n").filter((l) => l.includes("╭") || l.includes("│") || l.includes("╰"));
|
|
244
|
+
expect(boxLines.length).toBeGreaterThan(0);
|
|
245
|
+
for (const line of boxLines) {
|
|
246
|
+
expect(visibleWidth(line)).toBe(120);
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
process.stdout.columns = origColumns;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
217
253
|
/** Tests that non-TTY help automatically includes output schema in YAML by default. */
|
|
218
254
|
test("non-TTY help automatically includes output schema in YAML by default", () => {
|
|
219
255
|
const root = testProgram({
|
package/src/help.ts
CHANGED
|
@@ -82,7 +82,7 @@ function isOutputTTY(useStderr: boolean): boolean {
|
|
|
82
82
|
// ── Width Helpers ─────────────────────────────────────────────────────────────
|
|
83
83
|
|
|
84
84
|
/** Counts display columns, skipping ANSI SGR sequences. */
|
|
85
|
-
function visibleWidth(s: string): number {
|
|
85
|
+
export function visibleWidth(s: string): number {
|
|
86
86
|
let w = 0;
|
|
87
87
|
let i = 0;
|
|
88
88
|
while (i < s.length) {
|
|
@@ -230,19 +230,26 @@ interface HelpRow {
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
/** Renders a free-text or notes box with a Unicode border and `title` header. */
|
|
233
|
-
function renderTextBox(
|
|
233
|
+
function renderTextBox(
|
|
234
|
+
/** Section title to render in the top border. */
|
|
235
|
+
title: string,
|
|
236
|
+
/** Lines of text to display inside the box. */
|
|
237
|
+
lines: string[],
|
|
238
|
+
/** Available terminal column width. */
|
|
239
|
+
hw: number,
|
|
240
|
+
/** Whether ANSI color styling is enabled. */
|
|
241
|
+
color: boolean,
|
|
242
|
+
): string[] {
|
|
234
243
|
if (lines.length === 0) return [];
|
|
235
244
|
|
|
236
245
|
const titleLead = color
|
|
237
246
|
? style.gray(`${kBoxH} `) + style.grayBoldTitle(title) + style.gray(" ")
|
|
238
247
|
: `${kBoxH} ${title} `;
|
|
239
248
|
|
|
240
|
-
let contentWidth = visibleWidth(titleLead) + 1;
|
|
249
|
+
let contentWidth = Math.max(visibleWidth(titleLead) + 1, hw - 4);
|
|
241
250
|
for (const line of lines) {
|
|
242
251
|
contentWidth = Math.max(contentWidth, visibleWidth(line));
|
|
243
252
|
}
|
|
244
|
-
contentWidth = Math.max(hw - 2, contentWidth);
|
|
245
|
-
contentWidth = Math.min(contentWidth, hw - 4);
|
|
246
253
|
|
|
247
254
|
const borderWidth = contentWidth + 2;
|
|
248
255
|
const headerFill = Math.max(1, borderWidth - visibleWidth(titleLead));
|
|
@@ -265,7 +272,16 @@ function renderTextBox(title: string, lines: string[], hw: number, color: boolea
|
|
|
265
272
|
}
|
|
266
273
|
|
|
267
274
|
/** Renders a two-column label/description table in a box (options, subcommands, positionals). */
|
|
268
|
-
function renderTableBox(
|
|
275
|
+
function renderTableBox(
|
|
276
|
+
/** Section title to render in the top border. */
|
|
277
|
+
title: string,
|
|
278
|
+
/** Table rows containing labels and descriptions to format. */
|
|
279
|
+
rows: HelpRow[],
|
|
280
|
+
/** Available terminal column width. */
|
|
281
|
+
hw: number,
|
|
282
|
+
/** Whether ANSI color styling is enabled. */
|
|
283
|
+
color: boolean,
|
|
284
|
+
): string[] {
|
|
269
285
|
if (rows.length === 0) return [];
|
|
270
286
|
|
|
271
287
|
let labelWidth = 0;
|
|
@@ -273,10 +289,15 @@ function renderTableBox(title: string, rows: HelpRow[], hw: number, color: boole
|
|
|
273
289
|
labelWidth = Math.max(labelWidth, visibleWidth(row.label));
|
|
274
290
|
}
|
|
275
291
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
292
|
+
let titleLead: string;
|
|
293
|
+
if (color) {
|
|
294
|
+
titleLead = style.gray(`${kBoxH} `) + style.grayBoldTitle(title) + style.gray(" ");
|
|
295
|
+
} else {
|
|
296
|
+
titleLead = `${kBoxH} ${title} `;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const targetContentWidth = Math.max(visibleWidth(titleLead) + 1, hw - 4);
|
|
300
|
+
const descWidth = Math.max(1, targetContentWidth - labelWidth - 2);
|
|
280
301
|
|
|
281
302
|
const bodyLines: string[] = [];
|
|
282
303
|
for (const row of rows) {
|
|
@@ -289,18 +310,10 @@ function renderTableBox(title: string, rows: HelpRow[], hw: number, color: boole
|
|
|
289
310
|
}
|
|
290
311
|
}
|
|
291
312
|
|
|
292
|
-
let
|
|
293
|
-
if (color) {
|
|
294
|
-
titleLead = style.gray(`${kBoxH} `) + style.grayBoldTitle(title) + style.gray(" ");
|
|
295
|
-
} else {
|
|
296
|
-
titleLead = `${kBoxH} ${title} `;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
contentWidth = Math.max(contentWidth, visibleWidth(titleLead) + 1);
|
|
313
|
+
let contentWidth = targetContentWidth;
|
|
300
314
|
for (const line of bodyLines) {
|
|
301
315
|
contentWidth = Math.max(contentWidth, visibleWidth(line));
|
|
302
316
|
}
|
|
303
|
-
contentWidth = Math.min(contentWidth, hw - 4);
|
|
304
317
|
|
|
305
318
|
const borderWidth = contentWidth + 2;
|
|
306
319
|
const headerFill = Math.max(1, borderWidth - visibleWidth(titleLead));
|
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";
|
|
@@ -256,7 +220,7 @@ export function generateOpenApi(program: CliProgram): Record<string, unknown> {
|
|
|
256
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/index.ts
CHANGED