argsbarg 6.0.2 → 6.1.0
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 +14 -2
- package/README.md +3 -2
- package/docs/api-server.md +1 -1
- package/docs/cli-program.md +28 -0
- package/docs/config-schema.md +3 -3
- package/docs/output-schema.md +26 -24
- package/examples/full-example/README.md +6 -6
- package/examples/full-example/src/commands/status/types.ts +19 -1
- package/examples/full-example/src/program.ts +1 -1
- package/index.d.ts +24 -3
- package/package.json +1 -1
- package/src/cli-tool/program.ts +2 -1
- package/src/cli-tool/schemagen/cleanup.ts +3 -3
- package/src/cli-tool/schemagen/discover-schema-roots.ts +85 -15
- package/src/cli-tool/schemagen/index.ts +1 -1
- package/src/cli-tool/schemagen/names.ts +3 -3
- package/src/cli-tool/schemagen/run.ts +2 -1
- package/src/cli-tool/schemagen/schemagen.test.ts +35 -17
- package/src/context.ts +20 -2
- package/src/help.ts +2 -0
- package/src/index.ts +1 -0
- package/src/leaf-inputs.test.ts +170 -0
- package/src/leaf-inputs.ts +178 -0
- package/src/mcp/tools.ts +5 -0
- package/src/parse.ts +11 -0
- package/src/types.ts +8 -1
- package/src/validate.ts +43 -0
- package/examples/full-example/src/commands/status/schema.ts +0 -14
- /package/examples/full-example/src/config/{schema.ts → types.ts} +0 -0
|
@@ -39,10 +39,10 @@ function makeTempProject(): string {
|
|
|
39
39
|
return root;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function
|
|
42
|
+
function writeTypes(root: string, relDir: string, body: string): void {
|
|
43
43
|
const dir = join(root, "src", relDir);
|
|
44
44
|
mkdirSync(dir, { recursive: true });
|
|
45
|
-
writeFileSync(join(dir, "
|
|
45
|
+
writeFileSync(join(dir, "types.ts"), body);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
describe("schemagen", () => {
|
|
@@ -62,14 +62,38 @@ describe("schemagen", () => {
|
|
|
62
62
|
expect(counts).toEqual({ configRoots: 1, inputRoots: 0, outputRoots: 1 });
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
test("discovers roots from types.ts with interface and role export", () => {
|
|
66
|
+
const root = makeTempProject();
|
|
67
|
+
const dir = join(root, "src/commands/demo");
|
|
68
|
+
writeTypes(
|
|
69
|
+
root,
|
|
70
|
+
"commands/demo",
|
|
71
|
+
`export interface DemoOutput { ok: boolean; }
|
|
72
|
+
|
|
73
|
+
/** Schemagen root for leaf outputSchema. */
|
|
74
|
+
export type outputType = DemoOutput;
|
|
75
|
+
`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const roots = discoverSchemaRoots(root);
|
|
79
|
+
expect(roots).toEqual([
|
|
80
|
+
{
|
|
81
|
+
kind: "output",
|
|
82
|
+
typeName: "DemoOutput",
|
|
83
|
+
path: "src/commands/demo/types.ts",
|
|
84
|
+
sourcePath: "src/commands/demo/types.ts",
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
runSchemagen({ projectRoot: root });
|
|
88
|
+
expect(existsSync(join(dir, "__generated__/outputSchema.json"))).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
65
91
|
test("removes stale JSON files when a schema kind is dropped", () => {
|
|
66
92
|
const root = makeTempProject();
|
|
67
|
-
|
|
93
|
+
writeTypes(
|
|
68
94
|
root,
|
|
69
95
|
"commands/demo",
|
|
70
|
-
|
|
71
|
-
export interface DemoInput { id: string; }
|
|
72
|
-
/** Tool output. */
|
|
96
|
+
`export interface DemoInput { id: string; }
|
|
73
97
|
export interface DemoOutput { ok: boolean; }
|
|
74
98
|
export type inputType = DemoInput;
|
|
75
99
|
export type outputType = DemoOutput;
|
|
@@ -81,11 +105,10 @@ export type outputType = DemoOutput;
|
|
|
81
105
|
expect(existsSync(join(generatedDir, "inputSchema.json"))).toBe(true);
|
|
82
106
|
expect(existsSync(join(generatedDir, "outputSchema.json"))).toBe(true);
|
|
83
107
|
|
|
84
|
-
|
|
108
|
+
writeTypes(
|
|
85
109
|
root,
|
|
86
110
|
"commands/demo",
|
|
87
|
-
|
|
88
|
-
export interface DemoOutput { ok: boolean; }
|
|
111
|
+
`export interface DemoOutput { ok: boolean; }
|
|
89
112
|
export type outputType = DemoOutput;
|
|
90
113
|
`,
|
|
91
114
|
);
|
|
@@ -96,7 +119,7 @@ export type outputType = DemoOutput;
|
|
|
96
119
|
expect(existsSync(join(generatedDir, "index.ts"))).toBe(true);
|
|
97
120
|
});
|
|
98
121
|
|
|
99
|
-
test("removes orphan __generated__ when
|
|
122
|
+
test("removes orphan __generated__ when types.ts manifest is gone", () => {
|
|
100
123
|
const root = makeTempProject();
|
|
101
124
|
const generatedDir = join(root, "src/orphan/__generated__");
|
|
102
125
|
mkdirSync(generatedDir, { recursive: true });
|
|
@@ -107,14 +130,9 @@ export type outputType = DemoOutput;
|
|
|
107
130
|
expect(existsSync(generatedDir)).toBe(false);
|
|
108
131
|
});
|
|
109
132
|
|
|
110
|
-
test("removes __generated__ when
|
|
133
|
+
test("removes __generated__ when types.ts has no discoverable roots", () => {
|
|
111
134
|
const root = makeTempProject();
|
|
112
|
-
|
|
113
|
-
root,
|
|
114
|
-
"commands/empty",
|
|
115
|
-
`export type outputType = never;
|
|
116
|
-
`,
|
|
117
|
-
);
|
|
135
|
+
writeTypes(root, "commands/empty", `export type outputType = never;\n`);
|
|
118
136
|
const generatedDir = join(root, "src/commands/empty/__generated__");
|
|
119
137
|
mkdirSync(generatedDir, { recursive: true });
|
|
120
138
|
writeFileSync(join(generatedDir, "outputSchema.json"), "{}\n");
|
package/src/context.ts
CHANGED
|
@@ -10,6 +10,7 @@ parsed values.
|
|
|
10
10
|
import type { AnyAppConfigSnapshot } from "./config/context.ts";
|
|
11
11
|
import { EmptyAppConfigSnapshot } from "./config/context.ts";
|
|
12
12
|
import { parseCommaList, parseDate, parseDateTime, parseDurationMs } from "./formats.ts";
|
|
13
|
+
import { readLeafInputsAsync as loadLeafInputsAsync } from "./leaf-inputs.ts";
|
|
13
14
|
import { collectOptionDefs } from "./parse.ts";
|
|
14
15
|
import { normalizeRespondOptions, writeRespondBodyToStdout } from "./respond.ts";
|
|
15
16
|
import type { CliInvocation, CliLeaf, CliNode, CliOption, CliProgram, CliRespondOptions } from "./types.ts";
|
|
@@ -17,7 +18,7 @@ import { CliOptionKind, CliValueFormat, isCliLeaf, isCliRouter } from "./types.t
|
|
|
17
18
|
import { strictParseDouble } from "./utils.ts";
|
|
18
19
|
|
|
19
20
|
/** Coerced leaf inputs keyed by option and positional names. */
|
|
20
|
-
export type CliLeafInputs = Record<string, boolean | number | string | string[] | undefined>;
|
|
21
|
+
export type CliLeafInputs = Record<string, boolean | number | string | string[] | unknown | undefined>;
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Values passed to a leaf command handler after parsing: app name, routed path, args, and merged options.
|
|
@@ -163,7 +164,15 @@ export class CliContext {
|
|
|
163
164
|
return out;
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Reads coerced leaf inputs, resolving Json options from flags, piped stdin (when `pipable`),
|
|
169
|
+
* or MCP/API toolArgs, and validates against `leaf.inputSchema` when set.
|
|
170
|
+
*/
|
|
171
|
+
async readLeafInputsAsync(): Promise<CliLeafInputs> {
|
|
172
|
+
return loadLeafInputsAsync(this);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private _readOptionValue(opt: CliOption): boolean | number | string | string[] | unknown | undefined {
|
|
167
176
|
if (opt.kind === CliOptionKind.Presence) {
|
|
168
177
|
return this.hasFlag(opt.name);
|
|
169
178
|
}
|
|
@@ -171,6 +180,15 @@ export class CliContext {
|
|
|
171
180
|
const n = this.numberOpt(opt.name);
|
|
172
181
|
return n === null ? undefined : n;
|
|
173
182
|
}
|
|
183
|
+
if (opt.kind === CliOptionKind.Json) {
|
|
184
|
+
const raw = this.stringOpt(opt.name);
|
|
185
|
+
if (raw === undefined) return undefined;
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(raw) as unknown;
|
|
188
|
+
} catch {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
174
192
|
if (opt.format === CliValueFormat.Duration) {
|
|
175
193
|
return this.durationOpt(opt.name);
|
|
176
194
|
}
|
package/src/help.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
shouldRunHeadlessWithYes,
|
|
29
29
|
wantsExplicitJson,
|
|
30
30
|
} from "./headless.ts";
|
|
31
|
+
export { LeafInputError, readLeafInputsAsync } from "./leaf-inputs.ts";
|
|
31
32
|
export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
|
|
32
33
|
export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
|
|
33
34
|
export type {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Cli, CliContext, CliOptionKind } from "./index.ts";
|
|
3
|
+
import { LeafInputError, readLeafInputsAsync } from "./leaf-inputs.ts";
|
|
4
|
+
import type { CliProgram } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
const invoiceSchema = {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
format: { type: "string", enum: ["pdf", "html"] },
|
|
10
|
+
invoice: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
id: { type: "string" },
|
|
14
|
+
},
|
|
15
|
+
required: ["id"],
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
required: ["format", "invoice"],
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
function renderProgram(): CliProgram {
|
|
24
|
+
return {
|
|
25
|
+
key: "json-pipe-test",
|
|
26
|
+
version: "1.0.0",
|
|
27
|
+
description: "Json pipable option tests",
|
|
28
|
+
commands: [
|
|
29
|
+
{
|
|
30
|
+
key: "render",
|
|
31
|
+
description: "Render with pipable invoice JSON",
|
|
32
|
+
inputSchema: invoiceSchema,
|
|
33
|
+
options: [
|
|
34
|
+
{
|
|
35
|
+
name: "format",
|
|
36
|
+
description: "Output format",
|
|
37
|
+
kind: CliOptionKind.Enum,
|
|
38
|
+
choices: ["pdf", "html"],
|
|
39
|
+
required: true,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "invoice",
|
|
43
|
+
description: "Invoice JSON (flag or stdin)",
|
|
44
|
+
kind: CliOptionKind.Json,
|
|
45
|
+
pipable: true,
|
|
46
|
+
required: true,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
handler: async (ctx) => {
|
|
50
|
+
const inputs = await ctx.readLeafInputsAsync();
|
|
51
|
+
return inputs;
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
} satisfies CliProgram;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("readLeafInputsAsync", () => {
|
|
59
|
+
test("reads Json option from MCP toolArgs", async () => {
|
|
60
|
+
const cli = new Cli(renderProgram());
|
|
61
|
+
const result = await cli.invoke(["render", "--format", "pdf"], {
|
|
62
|
+
invocation: "mcp",
|
|
63
|
+
toolArgs: { format: "pdf", invoice: { id: "INV-1" } },
|
|
64
|
+
});
|
|
65
|
+
expect(result.kind).toBe("ok");
|
|
66
|
+
expect(result.exitCode).toBe(0);
|
|
67
|
+
expect(result.response?.body).toEqual({
|
|
68
|
+
format: "pdf",
|
|
69
|
+
invoice: { id: "INV-1" },
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("flag wins over toolArgs for Json option", async () => {
|
|
74
|
+
const cli = new Cli(renderProgram());
|
|
75
|
+
const result = await cli.invoke(["render", "--format", "pdf", "--invoice", '{"id":"from-flag"}'], {
|
|
76
|
+
invocation: "mcp",
|
|
77
|
+
toolArgs: { format: "pdf", invoice: { id: "from-tool-args" } },
|
|
78
|
+
});
|
|
79
|
+
expect(result.kind).toBe("ok");
|
|
80
|
+
expect(result.response?.body).toEqual({
|
|
81
|
+
format: "pdf",
|
|
82
|
+
invoice: { id: "from-flag" },
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("rejects invalid Json flag at parse time", async () => {
|
|
87
|
+
const cli = new Cli(renderProgram());
|
|
88
|
+
const result = await cli.invoke(["render", "--format", "pdf", "--invoice", "not-json"], {
|
|
89
|
+
invocation: "mcp",
|
|
90
|
+
toolArgs: {},
|
|
91
|
+
});
|
|
92
|
+
expect(result.kind).toBe("error");
|
|
93
|
+
expect(result.errorMsg).toContain("Invalid JSON");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("validates merged inputs against inputSchema", async () => {
|
|
97
|
+
const cli = new Cli(renderProgram());
|
|
98
|
+
const result = await cli.invoke(["render", "--format", "pdf"], {
|
|
99
|
+
invocation: "mcp",
|
|
100
|
+
toolArgs: { format: "pdf", invoice: { id: 123 } },
|
|
101
|
+
});
|
|
102
|
+
expect(result.kind).toBe("error");
|
|
103
|
+
expect(result.stderr).toContain("invoice.id");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("readLeafInputsAsync throws LeafInputError when required Json is missing", async () => {
|
|
107
|
+
const program = renderProgram();
|
|
108
|
+
const ctx = new CliContext("json-pipe-test", ["render"], [], { format: "pdf" }, program, "mcp", undefined, {});
|
|
109
|
+
await expect(readLeafInputsAsync(ctx)).rejects.toThrow(LeafInputError);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("omits undefined optional properties before inputSchema validation", async () => {
|
|
113
|
+
const schemaWithOptional = {
|
|
114
|
+
type: "object",
|
|
115
|
+
properties: {
|
|
116
|
+
format: { type: "string", enum: ["pdf", "html"] },
|
|
117
|
+
template: { type: "string" },
|
|
118
|
+
invoice: {
|
|
119
|
+
type: "object",
|
|
120
|
+
properties: { id: { type: "string" } },
|
|
121
|
+
required: ["id"],
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
required: ["format", "invoice"],
|
|
126
|
+
additionalProperties: false,
|
|
127
|
+
};
|
|
128
|
+
const program = {
|
|
129
|
+
key: "json-pipe-test",
|
|
130
|
+
version: "1.0.0",
|
|
131
|
+
description: "optional template",
|
|
132
|
+
commands: [
|
|
133
|
+
{
|
|
134
|
+
key: "render",
|
|
135
|
+
description: "Render",
|
|
136
|
+
inputSchema: schemaWithOptional,
|
|
137
|
+
options: [
|
|
138
|
+
{
|
|
139
|
+
name: "format",
|
|
140
|
+
description: "Output format",
|
|
141
|
+
kind: CliOptionKind.Enum,
|
|
142
|
+
choices: ["pdf", "html"],
|
|
143
|
+
required: true,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "template",
|
|
147
|
+
description: "Template name",
|
|
148
|
+
kind: CliOptionKind.String,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "invoice",
|
|
152
|
+
description: "Invoice JSON",
|
|
153
|
+
kind: CliOptionKind.Json,
|
|
154
|
+
pipable: true,
|
|
155
|
+
required: true,
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
handler: async (ctx) => ctx.readLeafInputsAsync(),
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
} satisfies CliProgram;
|
|
162
|
+
const cli = new Cli(program);
|
|
163
|
+
const result = await cli.invoke(["render", "--format", "pdf"], {
|
|
164
|
+
invocation: "mcp",
|
|
165
|
+
toolArgs: { format: "pdf", invoice: { id: "INV-1" } },
|
|
166
|
+
});
|
|
167
|
+
expect(result.kind).toBe("ok");
|
|
168
|
+
expect(result.response?.body).toEqual({ format: "pdf", invoice: { id: "INV-1" } });
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Async leaf input reads: Json options (flag, piped stdin, or toolArgs), optional inputSchema validation.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { validateConfigDocument } from "./config/validate.ts";
|
|
6
|
+
import type { CliContext, CliLeafInputs } from "./context.ts";
|
|
7
|
+
import { collectOptionDefs } from "./parse.ts";
|
|
8
|
+
import type { CliLeaf, CliNode, CliOption } from "./types.ts";
|
|
9
|
+
import { CliOptionKind, CliValueFormat, isCliLeaf, isCliRouter } from "./types.ts";
|
|
10
|
+
import { isInteractiveTty } from "./utils.ts";
|
|
11
|
+
|
|
12
|
+
/** Thrown when {@link CliContext.readLeafInputsAsync} cannot resolve or validate inputs. */
|
|
13
|
+
export class LeafInputError extends Error {
|
|
14
|
+
constructor(message: string) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "LeafInputError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function leafNode(ctx: CliContext): CliLeaf | undefined {
|
|
21
|
+
let node: CliNode = ctx.program;
|
|
22
|
+
for (const seg of ctx.commandPath) {
|
|
23
|
+
if (!isCliRouter(node)) return undefined;
|
|
24
|
+
const child = node.commands.find((c) => c.key === seg);
|
|
25
|
+
if (!child) return undefined;
|
|
26
|
+
node = child;
|
|
27
|
+
}
|
|
28
|
+
return isCliLeaf(node) ? node : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readSyncOptionValue(
|
|
32
|
+
ctx: CliContext,
|
|
33
|
+
opt: CliOption,
|
|
34
|
+
): boolean | number | string | string[] | unknown | undefined {
|
|
35
|
+
if (opt.kind === CliOptionKind.Presence) {
|
|
36
|
+
return ctx.hasFlag(opt.name);
|
|
37
|
+
}
|
|
38
|
+
if (opt.kind === CliOptionKind.Number) {
|
|
39
|
+
const n = ctx.numberOpt(opt.name);
|
|
40
|
+
return n === null ? undefined : n;
|
|
41
|
+
}
|
|
42
|
+
if (opt.kind === CliOptionKind.Json) {
|
|
43
|
+
const raw = ctx.stringOpt(opt.name);
|
|
44
|
+
if (raw === undefined) return undefined;
|
|
45
|
+
return parseJsonText(raw, `--${opt.name}`);
|
|
46
|
+
}
|
|
47
|
+
if (opt.format !== undefined) {
|
|
48
|
+
if (opt.format === CliValueFormat.Duration) {
|
|
49
|
+
return ctx.durationOpt(opt.name);
|
|
50
|
+
}
|
|
51
|
+
if (opt.format === CliValueFormat.CommaList) {
|
|
52
|
+
return ctx.commaListOpt(opt.name);
|
|
53
|
+
}
|
|
54
|
+
if (opt.format === CliValueFormat.Date) {
|
|
55
|
+
return ctx.dateOpt(opt.name);
|
|
56
|
+
}
|
|
57
|
+
if (opt.format === CliValueFormat.DateTime) {
|
|
58
|
+
return ctx.dateTimeOpt(opt.name);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return ctx.stringOpt(opt.name);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseJsonText(raw: string, label: string): unknown {
|
|
65
|
+
const trimmed = raw.trim();
|
|
66
|
+
if (trimmed.length === 0) {
|
|
67
|
+
throw new LeafInputError(`${label}: JSON value is empty`);
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(trimmed);
|
|
71
|
+
} catch {
|
|
72
|
+
throw new LeafInputError(`${label}: invalid JSON`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readPipedJsonStdin(): Promise<unknown> {
|
|
77
|
+
const raw = await new Response(Bun.stdin).text();
|
|
78
|
+
const trimmed = raw.trim();
|
|
79
|
+
if (trimmed.length === 0) {
|
|
80
|
+
throw new LeafInputError("stdin is empty; pass JSON via the option flag or pipe a JSON document to stdin");
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(trimmed);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new LeafInputError("stdin is not valid JSON");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function pipableJsonHelp(opt: CliOption): string {
|
|
90
|
+
return `Missing required option --${opt.name}: pass JSON via --${opt.name} '<json>' or pipe a JSON document to stdin`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function omitUndefinedInputs(out: CliLeafInputs): Record<string, unknown> {
|
|
94
|
+
const stripped: Record<string, unknown> = {};
|
|
95
|
+
for (const [key, value] of Object.entries(out)) {
|
|
96
|
+
if (value !== undefined) {
|
|
97
|
+
stripped[key] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return stripped;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateAgainstInputSchema(out: CliLeafInputs, inputSchema: Record<string, unknown>): void {
|
|
104
|
+
const result = validateConfigDocument(omitUndefinedInputs(out), inputSchema);
|
|
105
|
+
if (!result.valid) {
|
|
106
|
+
throw new LeafInputError(result.errors.join("; "));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reads coerced leaf inputs, resolving Json options from flags, piped stdin, or toolArgs,
|
|
112
|
+
* and validates against `leaf.inputSchema` when set.
|
|
113
|
+
*/
|
|
114
|
+
export async function readLeafInputsAsync(ctx: CliContext): Promise<CliLeafInputs> {
|
|
115
|
+
const leaf = leafNode(ctx);
|
|
116
|
+
if (!leaf) return {};
|
|
117
|
+
|
|
118
|
+
const out: CliLeafInputs = {};
|
|
119
|
+
const options = collectOptionDefs(ctx.program, ctx.commandPath);
|
|
120
|
+
let pipedJson: unknown | undefined;
|
|
121
|
+
let pipedJsonRead = false;
|
|
122
|
+
|
|
123
|
+
for (const opt of options) {
|
|
124
|
+
if (opt.kind === CliOptionKind.Json) {
|
|
125
|
+
const flagValue = ctx.stringOpt(opt.name);
|
|
126
|
+
if (flagValue !== undefined) {
|
|
127
|
+
out[opt.name] = parseJsonText(flagValue, `--${opt.name}`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (ctx.toolArgs !== undefined && opt.name in ctx.toolArgs) {
|
|
131
|
+
out[opt.name] = ctx.toolArgs[opt.name];
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (opt.pipable && ctx.invocation === "cli") {
|
|
135
|
+
if (isInteractiveTty) {
|
|
136
|
+
out[opt.name] = undefined;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!pipedJsonRead) {
|
|
140
|
+
pipedJson = await readPipedJsonStdin();
|
|
141
|
+
pipedJsonRead = true;
|
|
142
|
+
}
|
|
143
|
+
out[opt.name] = pipedJson;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
out[opt.name] = undefined;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
out[opt.name] = readSyncOptionValue(ctx, opt);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const p of leaf.positionals ?? []) {
|
|
154
|
+
const val = ctx.positional(p.name);
|
|
155
|
+
if (val === undefined) {
|
|
156
|
+
out[p.name] = undefined;
|
|
157
|
+
} else if (Array.isArray(val)) {
|
|
158
|
+
out[p.name] = val;
|
|
159
|
+
} else {
|
|
160
|
+
out[p.name] = val;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const opt of options) {
|
|
165
|
+
if (opt.required && out[opt.name] === undefined) {
|
|
166
|
+
if (opt.kind === CliOptionKind.Json && opt.pipable && ctx.invocation === "cli" && isInteractiveTty) {
|
|
167
|
+
throw new LeafInputError(pipableJsonHelp(opt));
|
|
168
|
+
}
|
|
169
|
+
throw new LeafInputError(`Missing required option: --${opt.name}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (leaf.inputSchema !== undefined) {
|
|
174
|
+
validateAgainstInputSchema(out, leaf.inputSchema);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return omitUndefinedInputs(out);
|
|
178
|
+
}
|
package/src/mcp/tools.ts
CHANGED
|
@@ -116,6 +116,8 @@ function optionProperty(opt: CliOption): Record<string, unknown> {
|
|
|
116
116
|
return { type: "number", ...base };
|
|
117
117
|
case CliOptionKind.Enum:
|
|
118
118
|
return { type: "string", enum: opt.choices, ...base };
|
|
119
|
+
case CliOptionKind.Json:
|
|
120
|
+
return { type: "object", ...base };
|
|
119
121
|
}
|
|
120
122
|
}
|
|
121
123
|
|
|
@@ -287,6 +289,9 @@ export function mcpToolCallToArgv(
|
|
|
287
289
|
const argv = [...tool.path];
|
|
288
290
|
|
|
289
291
|
for (const opt of collectOptionDefs(root, tool.path)) {
|
|
292
|
+
if (opt.kind === CliOptionKind.Json) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
290
295
|
const val = args[opt.name];
|
|
291
296
|
if (val === undefined) {
|
|
292
297
|
continue;
|
package/src/parse.ts
CHANGED
|
@@ -583,6 +583,9 @@ export function postParseValidate(root: CliNode, pr: ParseResult): ParseResult {
|
|
|
583
583
|
|
|
584
584
|
for (const d of defs) {
|
|
585
585
|
if (d.required && !(d.name in opts)) {
|
|
586
|
+
if (d.kind === CliOptionKind.Json) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
586
589
|
return errorResult(`Missing required option: --${d.name}`, pr.path);
|
|
587
590
|
}
|
|
588
591
|
}
|
|
@@ -592,6 +595,14 @@ export function postParseValidate(root: CliNode, pr: ParseResult): ParseResult {
|
|
|
592
595
|
if (!d) {
|
|
593
596
|
return errorResult(`Unknown option key: ${k}`, pr.path);
|
|
594
597
|
}
|
|
598
|
+
if (d.kind === CliOptionKind.Json) {
|
|
599
|
+
try {
|
|
600
|
+
JSON.parse(v);
|
|
601
|
+
} catch {
|
|
602
|
+
return errorResult(`Invalid JSON for option --${k}`, pr.path);
|
|
603
|
+
}
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
595
606
|
if (d.kind === CliOptionKind.Number) {
|
|
596
607
|
if (!fullStringIsDouble(v)) {
|
|
597
608
|
return errorResult(`Invalid number for option --${k}: ${v}`, pr.path);
|
package/src/types.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { CliContext } from "./context.ts";
|
|
|
12
12
|
export type CliInvocation = "cli" | "mcp" | "api";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Option kinds: presence (boolean flag), string (free-form text), number (strict double),
|
|
15
|
+
* Option kinds: presence (boolean flag), string (free-form text), number (strict double), enum (fixed choices), or json (parsed JSON object/array).
|
|
16
16
|
*/
|
|
17
17
|
export enum CliOptionKind {
|
|
18
18
|
/** Boolean flag: no value token (may be implicit `"1"` when set). */
|
|
@@ -23,6 +23,8 @@ export enum CliOptionKind {
|
|
|
23
23
|
Number = "number",
|
|
24
24
|
/** Fixed set of allowed string values. Requires non-empty `choices` on the option. */
|
|
25
25
|
Enum = "enum",
|
|
26
|
+
/** JSON object or array (parsed from `--name '<json>'`, piped stdin when `pipable`, or MCP/API tool body). */
|
|
27
|
+
Json = "json",
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -89,6 +91,11 @@ export interface CliOption {
|
|
|
89
91
|
default?: string;
|
|
90
92
|
/** Regex pattern for string options. Mutually exclusive with `format`. */
|
|
91
93
|
pattern?: string;
|
|
94
|
+
/**
|
|
95
|
+
* When `true` on a `Json` option, CLI may omit `--name` and supply JSON via stdin instead.
|
|
96
|
+
* If `--name` is set, the flag value wins and stdin is not read.
|
|
97
|
+
*/
|
|
98
|
+
pipable?: boolean;
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
/**
|
package/src/validate.ts
CHANGED
|
@@ -247,6 +247,31 @@ function walkNode(node: CliNode, program: CliProgram, isRoot: boolean): void {
|
|
|
247
247
|
if (resolved !== undefined && (typeof resolved !== "object" || resolved === null || Array.isArray(resolved))) {
|
|
248
248
|
throw new CliSchemaValidationError("outputSchema must be a JSON Schema object (not null or an array)");
|
|
249
249
|
}
|
|
250
|
+
const inputSchema = node.inputSchema;
|
|
251
|
+
if (
|
|
252
|
+
inputSchema !== undefined &&
|
|
253
|
+
(typeof inputSchema !== "object" || inputSchema === null || Array.isArray(inputSchema))
|
|
254
|
+
) {
|
|
255
|
+
throw new CliSchemaValidationError("inputSchema must be a JSON Schema object (not null or an array)");
|
|
256
|
+
}
|
|
257
|
+
if (inputSchema !== undefined) {
|
|
258
|
+
const properties = inputSchema.properties;
|
|
259
|
+
if (
|
|
260
|
+
properties !== undefined &&
|
|
261
|
+
(typeof properties !== "object" || properties === null || Array.isArray(properties))
|
|
262
|
+
) {
|
|
263
|
+
throw new CliSchemaValidationError(`inputSchema.properties must be an object on ${node.key}`);
|
|
264
|
+
}
|
|
265
|
+
if (properties) {
|
|
266
|
+
for (const opt of node.options ?? []) {
|
|
267
|
+
if (opt.kind === CliOptionKind.Json && !(opt.name in properties)) {
|
|
268
|
+
throw new CliSchemaValidationError(
|
|
269
|
+
`Json option '${opt.name}' is missing from inputSchema.properties on ${node.key}`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
250
275
|
} else {
|
|
251
276
|
const rogue = node as unknown as CliLeaf;
|
|
252
277
|
if (rogue.mcpTool !== undefined) {
|
|
@@ -301,7 +326,22 @@ function walkNode(node: CliNode, program: CliProgram, isRoot: boolean): void {
|
|
|
301
326
|
|
|
302
327
|
function validateOptions(scopeKey: string, options: import("./types.ts").CliOption[]): void {
|
|
303
328
|
const seenShorts = new Set<string>();
|
|
329
|
+
let pipableCount = 0;
|
|
304
330
|
for (const opt of options) {
|
|
331
|
+
if (opt.pipable) {
|
|
332
|
+
pipableCount++;
|
|
333
|
+
if (opt.kind !== CliOptionKind.Json) {
|
|
334
|
+
throw new CliSchemaValidationError(`pipable is only valid on Json kind: ${scopeKey}/${opt.name}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (opt.kind === CliOptionKind.Json) {
|
|
338
|
+
if (opt.format !== undefined || opt.pattern !== undefined || opt.default !== undefined) {
|
|
339
|
+
throw new CliSchemaValidationError(
|
|
340
|
+
`Json option cannot use format, pattern, or default: ${scopeKey}/${opt.name}`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
305
345
|
if (opt.required && opt.kind === CliOptionKind.Presence) {
|
|
306
346
|
throw new CliSchemaValidationError(`Presence option cannot be required: ${scopeKey}/${opt.name}`);
|
|
307
347
|
}
|
|
@@ -340,6 +380,9 @@ function validateOptions(scopeKey: string, options: import("./types.ts").CliOpti
|
|
|
340
380
|
validateOptionValueMetadata(scopeKey, opt);
|
|
341
381
|
}
|
|
342
382
|
}
|
|
383
|
+
if (pipableCount > 1) {
|
|
384
|
+
throw new CliSchemaValidationError(`At most one pipable Json option per command: ${scopeKey}`);
|
|
385
|
+
}
|
|
343
386
|
}
|
|
344
387
|
|
|
345
388
|
function validateOptionValueMetadata(scopeKey: string, opt: import("./types.ts").CliOption): void {
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/** JSON stdout for `full-example status --json`. */
|
|
2
|
-
export interface StatusJsonOutput {
|
|
3
|
-
/** Resolved AWS region. */
|
|
4
|
-
defaultRegion?: string;
|
|
5
|
-
/** Resolved retry count. */
|
|
6
|
-
maxRetries?: number;
|
|
7
|
-
/** Whether apiToken is set (value never included). */
|
|
8
|
-
apiTokenSet: boolean;
|
|
9
|
-
/** App version from program root. */
|
|
10
|
-
version: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** Schemagen root for leaf outputSchema. */
|
|
14
|
-
export type outputType = StatusJsonOutput;
|
|
File without changes
|