argsbarg 3.5.0 → 3.6.1
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 +25 -1
- package/README.md +24 -8
- package/docs/cli-program.md +75 -1
- package/docs/templates/cursor/rules/cli-program.mdc +15 -21
- package/index.d.ts +45 -0
- package/package.json +10 -1
- package/src/context.ts +91 -15
- package/src/docs/api-guide.ts +17 -3
- package/src/docs/mcp-guide.ts +3 -1
- package/src/formats.test.ts +35 -0
- package/src/formats.ts +135 -0
- package/src/index.test.ts +7 -7
- package/src/index.ts +13 -1
- package/src/mcp/tools.ts +67 -18
- package/src/parse.ts +33 -3
- package/src/types.ts +24 -0
- package/src/validate.ts +54 -0
- package/.cursor/plans/cliprogram_capabilities_refactor_081e1737.plan.md +0 -224
- package/.cursor/plans/mcp_v1.1_polish_e9656029.plan.md +0 -260
- package/.cursor/plans/mcp_v1.2_invocation_and_extensions_a4f82c1e.plan.md +0 -647
- package/.cursor/plans/v1.3_parser_ergonomics_b3e91f02.plan.md +0 -455
- package/.cursor/rules/code.mdc +0 -9
- package/.github/copilot-instructions.md.md +0 -8
- package/.github/pull_request_template.md +0 -13
- package/.private/scratch.md +0 -4
- package/CLAUDE.md +0 -8
- package/biome.json +0 -40
- package/bun.lock +0 -43
- package/justfile +0 -57
- package/logo.png +0 -0
- package/plan.md +0 -19
- package/scripts/release.ts +0 -266
- package/tsconfig.json +0 -12
package/src/formats.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Named string format validation and parsing for CLI options.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CliValueFormat } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const DURATION_RE = /^\d+[hdms]?$/i;
|
|
8
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
9
|
+
|
|
10
|
+
/** Parses a duration string (e.g. 20m, 1h, 30s) into milliseconds. */
|
|
11
|
+
export function parseDurationMs(durationStr: string): number {
|
|
12
|
+
const match = durationStr.trim().match(/^(\d+)([hdms]?)$/i);
|
|
13
|
+
if (!match) {
|
|
14
|
+
throw new Error("Invalid duration format. Use e.g. 30s, 20m, 1h, 2d");
|
|
15
|
+
}
|
|
16
|
+
const amountText = match[1];
|
|
17
|
+
if (!amountText) {
|
|
18
|
+
throw new Error("Invalid duration format. Use e.g. 30s, 20m, 1h, 2d");
|
|
19
|
+
}
|
|
20
|
+
const amount = Number.parseInt(amountText, 10);
|
|
21
|
+
const unit = (match[2] || "m").toLowerCase();
|
|
22
|
+
if (unit === "s") return amount * 1000;
|
|
23
|
+
if (unit === "m") return amount * 60 * 1000;
|
|
24
|
+
if (unit === "h") return amount * 60 * 60 * 1000;
|
|
25
|
+
if (unit === "d") return amount * 24 * 60 * 60 * 1000;
|
|
26
|
+
return amount * 60 * 1000;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validateDuration(s: string): void {
|
|
30
|
+
if (!DURATION_RE.test(s.trim())) {
|
|
31
|
+
throw new Error("Invalid duration format. Use e.g. 30s, 20m, 1h, 2d");
|
|
32
|
+
}
|
|
33
|
+
parseDurationMs(s);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Splits a comma-separated string into trimmed non-empty tokens. */
|
|
37
|
+
export function parseCommaList(s: string): string[] {
|
|
38
|
+
return s
|
|
39
|
+
.split(",")
|
|
40
|
+
.map((part) => part.trim())
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateCommaList(s: string): void {
|
|
45
|
+
if (parseCommaList(s).length === 0) {
|
|
46
|
+
throw new Error("Comma-separated list must contain at least one value");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Returns canonical YYYY-MM-DD after validation. */
|
|
51
|
+
export function parseDate(s: string): string {
|
|
52
|
+
const trimmed = s.trim();
|
|
53
|
+
if (!DATE_RE.test(trimmed)) {
|
|
54
|
+
throw new Error("Invalid date. Use YYYY-MM-DD");
|
|
55
|
+
}
|
|
56
|
+
const [y, m, d] = trimmed.split("-").map((part) => Number.parseInt(part, 10));
|
|
57
|
+
const year = y ?? 0;
|
|
58
|
+
const month = m ?? 0;
|
|
59
|
+
const day = d ?? 0;
|
|
60
|
+
const dt = new Date(Date.UTC(year, month - 1, day));
|
|
61
|
+
if (dt.getUTCFullYear() !== year || dt.getUTCMonth() !== month - 1 || dt.getUTCDate() !== day) {
|
|
62
|
+
throw new Error("Invalid date. Use YYYY-MM-DD");
|
|
63
|
+
}
|
|
64
|
+
return trimmed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validateDate(s: string): void {
|
|
68
|
+
parseDate(s);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
72
|
+
|
|
73
|
+
/** Returns normalized ISO 8601 UTC after validation. */
|
|
74
|
+
export function parseDateTime(s: string): string {
|
|
75
|
+
const trimmed = s.trim();
|
|
76
|
+
if (!DATE_TIME_RE.test(trimmed)) {
|
|
77
|
+
throw new Error("Invalid date-time. Use RFC 3339, e.g. 2026-06-22T15:00:00Z");
|
|
78
|
+
}
|
|
79
|
+
const ms = Date.parse(trimmed);
|
|
80
|
+
if (Number.isNaN(ms)) {
|
|
81
|
+
throw new Error("Invalid date-time. Use RFC 3339, e.g. 2026-06-22T15:00:00Z");
|
|
82
|
+
}
|
|
83
|
+
return new Date(ms).toISOString();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function validateDateTime(s: string): void {
|
|
87
|
+
parseDateTime(s);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function validatePattern(s: string, pattern: string): void {
|
|
91
|
+
const re = new RegExp(pattern);
|
|
92
|
+
if (!re.test(s)) {
|
|
93
|
+
throw new Error(`Value does not match required pattern: ${pattern}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatValidationError(format: CliValueFormat, value: string): string {
|
|
98
|
+
switch (format) {
|
|
99
|
+
case CliValueFormat.Duration:
|
|
100
|
+
return `Invalid duration: ${value} (use e.g. 30s, 20m, 1h, 2d)`;
|
|
101
|
+
case CliValueFormat.CommaList:
|
|
102
|
+
return `Invalid comma-separated list: ${value}`;
|
|
103
|
+
case CliValueFormat.Date:
|
|
104
|
+
return `Invalid date: ${value} (use YYYY-MM-DD)`;
|
|
105
|
+
case CliValueFormat.DateTime:
|
|
106
|
+
return `Invalid date-time: ${value} (use RFC 3339, e.g. 2026-06-22T15:00:00Z)`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Validates a string value against format and/or pattern metadata. */
|
|
111
|
+
export function validateFormatValue(
|
|
112
|
+
value: string,
|
|
113
|
+
format?: CliValueFormat,
|
|
114
|
+
pattern?: string,
|
|
115
|
+
): void {
|
|
116
|
+
if (format !== undefined) {
|
|
117
|
+
switch (format) {
|
|
118
|
+
case CliValueFormat.Duration:
|
|
119
|
+
validateDuration(value);
|
|
120
|
+
return;
|
|
121
|
+
case CliValueFormat.CommaList:
|
|
122
|
+
validateCommaList(value);
|
|
123
|
+
return;
|
|
124
|
+
case CliValueFormat.Date:
|
|
125
|
+
validateDate(value);
|
|
126
|
+
return;
|
|
127
|
+
case CliValueFormat.DateTime:
|
|
128
|
+
validateDateTime(value);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (pattern !== undefined) {
|
|
133
|
+
validatePattern(value, pattern);
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/index.test.ts
CHANGED
|
@@ -1960,18 +1960,18 @@ test("ctx.positional varargs matches ctx.args", async () => {
|
|
|
1960
1960
|
expect(positional).toEqual(args);
|
|
1961
1961
|
});
|
|
1962
1962
|
|
|
1963
|
-
test("mcpToolCallToArgv
|
|
1963
|
+
test("mcpToolCallToArgv rejects comma-separated string for varargs", () => {
|
|
1964
1964
|
const tools = collectMcpTools(nestedMcpFixture);
|
|
1965
1965
|
const read = tools.find((t) => t.name === "read")!;
|
|
1966
1966
|
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "a,b" });
|
|
1967
|
-
expect(argv).toEqual(
|
|
1967
|
+
expect(argv).toEqual({ error: expect.stringContaining("JSON array") });
|
|
1968
1968
|
});
|
|
1969
1969
|
|
|
1970
|
-
test("mcpToolCallToArgv
|
|
1970
|
+
test("mcpToolCallToArgv rejects bare string for varargs", () => {
|
|
1971
1971
|
const tools = collectMcpTools(nestedMcpFixture);
|
|
1972
1972
|
const read = tools.find((t) => t.name === "read")!;
|
|
1973
1973
|
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "a" });
|
|
1974
|
-
expect(argv).toEqual(
|
|
1974
|
+
expect(argv).toEqual({ error: expect.stringContaining("JSON array") });
|
|
1975
1975
|
});
|
|
1976
1976
|
|
|
1977
1977
|
test("mcpToolCallToArgv array varargs unchanged", () => {
|
|
@@ -1981,11 +1981,11 @@ test("mcpToolCallToArgv array varargs unchanged", () => {
|
|
|
1981
1981
|
expect(argv).toEqual(["read", "a", "b"]);
|
|
1982
1982
|
});
|
|
1983
1983
|
|
|
1984
|
-
test("mcpToolCallToArgv empty
|
|
1984
|
+
test("mcpToolCallToArgv empty array varargs errors when required", () => {
|
|
1985
1985
|
const tools = collectMcpTools(nestedMcpFixture);
|
|
1986
1986
|
const read = tools.find((t) => t.name === "read")!;
|
|
1987
|
-
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files:
|
|
1988
|
-
expect(argv).toEqual(
|
|
1987
|
+
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: [] });
|
|
1988
|
+
expect(argv).toEqual({ error: "Missing argument: files" });
|
|
1989
1989
|
});
|
|
1990
1990
|
|
|
1991
1991
|
// ── Skills ────────────────────────────────────────────────────────────────────
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,14 @@ It gives consumers one stable import path without forcing them to know the inter
|
|
|
7
7
|
module layout.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
export type { CliLeafInputs } from "./context.ts";
|
|
10
11
|
export { CliContext } from "./context.ts";
|
|
12
|
+
export {
|
|
13
|
+
parseCommaList,
|
|
14
|
+
parseDate,
|
|
15
|
+
parseDateTime,
|
|
16
|
+
parseDurationMs,
|
|
17
|
+
} from "./formats.ts";
|
|
11
18
|
export type { HeadlessContext } from "./headless.ts";
|
|
12
19
|
export {
|
|
13
20
|
formatDryRunMessage,
|
|
@@ -46,5 +53,10 @@ export type {
|
|
|
46
53
|
CliUpdateArtifact,
|
|
47
54
|
CliUpdateGetLatest,
|
|
48
55
|
} from "./types.ts";
|
|
49
|
-
export {
|
|
56
|
+
export {
|
|
57
|
+
CliFallbackMode,
|
|
58
|
+
CliOptionKind,
|
|
59
|
+
CliSchemaValidationError,
|
|
60
|
+
CliValueFormat,
|
|
61
|
+
} from "./types.ts";
|
|
50
62
|
export { isInteractiveTty } from "./utils.ts";
|
package/src/mcp/tools.ts
CHANGED
|
@@ -14,10 +14,13 @@ import {
|
|
|
14
14
|
CliOptionKind,
|
|
15
15
|
type CliPositional,
|
|
16
16
|
type CliProgram,
|
|
17
|
+
CliValueFormat,
|
|
17
18
|
isCliLeaf,
|
|
18
19
|
leafOutputSchema,
|
|
19
20
|
} from "../types.ts";
|
|
20
21
|
|
|
22
|
+
const DURATION_PATTERN = "^\\d+[hdms]?$";
|
|
23
|
+
|
|
21
24
|
/** Default URI pattern for the CLI schema MCP resource (`<mcpId>://schema`). */
|
|
22
25
|
export function defaultMcpSchemaUri(mcpId: string): string {
|
|
23
26
|
return `${mcpId}://schema`;
|
|
@@ -65,12 +68,37 @@ export function mcpToolName(root: CliProgram, path: string[]): string {
|
|
|
65
68
|
|
|
66
69
|
/** JSON Schema property for one option. */
|
|
67
70
|
function optionProperty(opt: CliOption): Record<string, unknown> {
|
|
68
|
-
const base = { description: opt.description };
|
|
71
|
+
const base: Record<string, unknown> = { description: opt.description };
|
|
72
|
+
if (opt.default !== undefined) {
|
|
73
|
+
base.default = opt.default;
|
|
74
|
+
}
|
|
69
75
|
switch (opt.kind) {
|
|
70
76
|
case CliOptionKind.Presence:
|
|
71
77
|
return { type: "boolean", ...base };
|
|
72
|
-
case CliOptionKind.String:
|
|
73
|
-
|
|
78
|
+
case CliOptionKind.String: {
|
|
79
|
+
if (opt.format === CliValueFormat.CommaList) {
|
|
80
|
+
return {
|
|
81
|
+
oneOf: [
|
|
82
|
+
{ type: "string", ...base },
|
|
83
|
+
{ type: "array", items: { type: "string" }, ...base },
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const stringBase = { type: "string", ...base };
|
|
88
|
+
if (opt.format === CliValueFormat.Duration) {
|
|
89
|
+
return { ...stringBase, pattern: DURATION_PATTERN };
|
|
90
|
+
}
|
|
91
|
+
if (opt.format === CliValueFormat.Date) {
|
|
92
|
+
return { ...stringBase, format: "date" };
|
|
93
|
+
}
|
|
94
|
+
if (opt.format === CliValueFormat.DateTime) {
|
|
95
|
+
return { ...stringBase, format: "date-time" };
|
|
96
|
+
}
|
|
97
|
+
if (opt.pattern !== undefined) {
|
|
98
|
+
return { ...stringBase, pattern: opt.pattern };
|
|
99
|
+
}
|
|
100
|
+
return stringBase;
|
|
101
|
+
}
|
|
74
102
|
case CliOptionKind.Number:
|
|
75
103
|
return { type: "number", ...base };
|
|
76
104
|
case CliOptionKind.Enum:
|
|
@@ -78,6 +106,23 @@ function optionProperty(opt: CliOption): Record<string, unknown> {
|
|
|
78
106
|
}
|
|
79
107
|
}
|
|
80
108
|
|
|
109
|
+
function formatMcpOptionValue(opt: CliOption, val: unknown): string | { error: string } {
|
|
110
|
+
if (opt.format === CliValueFormat.CommaList) {
|
|
111
|
+
if (Array.isArray(val)) {
|
|
112
|
+
const items = val.map(String).filter(Boolean);
|
|
113
|
+
if (items.length === 0) {
|
|
114
|
+
return { error: `Option --${opt.name} requires at least one value` };
|
|
115
|
+
}
|
|
116
|
+
return items.join(",");
|
|
117
|
+
}
|
|
118
|
+
if (typeof val === "string") {
|
|
119
|
+
return val;
|
|
120
|
+
}
|
|
121
|
+
return { error: `Option --${opt.name} must be a string or array of strings` };
|
|
122
|
+
}
|
|
123
|
+
return String(val);
|
|
124
|
+
}
|
|
125
|
+
|
|
81
126
|
/** JSON Schema property for one positional slot. */
|
|
82
127
|
function positionalProperty(p: CliPositional): Record<string, unknown> {
|
|
83
128
|
const base = { description: p.description };
|
|
@@ -251,7 +296,11 @@ export function mcpToolCallToArgv(
|
|
|
251
296
|
}
|
|
252
297
|
continue;
|
|
253
298
|
}
|
|
254
|
-
|
|
299
|
+
const formatted = formatMcpOptionValue(opt, val);
|
|
300
|
+
if (typeof formatted !== "string") {
|
|
301
|
+
return formatted;
|
|
302
|
+
}
|
|
303
|
+
argv.push(`--${opt.name}`, formatted);
|
|
255
304
|
}
|
|
256
305
|
|
|
257
306
|
for (const p of tool.leaf.positionals ?? []) {
|
|
@@ -260,20 +309,20 @@ export function mcpToolCallToArgv(
|
|
|
260
309
|
|
|
261
310
|
if (argMax === 0) {
|
|
262
311
|
const raw = args[p.name];
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
312
|
+
if (raw === undefined) {
|
|
313
|
+
if (argMin >= 1) {
|
|
314
|
+
return { error: `Missing argument: ${p.name} (use a JSON array)` };
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!Array.isArray(raw)) {
|
|
319
|
+
return {
|
|
320
|
+
error: `Argument ${p.name} must be a JSON array of strings (not a comma-separated string)`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const items = raw.map(String).filter(Boolean);
|
|
324
|
+
if (items.length === 0 && argMin >= 1) {
|
|
325
|
+
return { error: `Missing argument: ${p.name}` };
|
|
277
326
|
}
|
|
278
327
|
argv.push(...items);
|
|
279
328
|
continue;
|
package/src/parse.ts
CHANGED
|
@@ -7,6 +7,7 @@ It keeps handler dispatch and help on one parser so the CLI behavior stays consi
|
|
|
7
7
|
across every entry path.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { formatValidationError, validateFormatValue } from "./formats.ts";
|
|
10
11
|
import {
|
|
11
12
|
CliFallbackMode,
|
|
12
13
|
type CliLeaf,
|
|
@@ -683,8 +684,15 @@ export function postParseValidate(root: CliNode, pr: ParseResult): ParseResult {
|
|
|
683
684
|
node = ch;
|
|
684
685
|
}
|
|
685
686
|
|
|
687
|
+
const opts = { ...pr.opts };
|
|
686
688
|
for (const d of defs) {
|
|
687
|
-
if (d.
|
|
689
|
+
if (d.default !== undefined && !(d.name in opts)) {
|
|
690
|
+
opts[d.name] = d.default;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
for (const d of defs) {
|
|
695
|
+
if (d.required && !(d.name in opts)) {
|
|
688
696
|
return {
|
|
689
697
|
kind: ParseKind.Error,
|
|
690
698
|
path: pr.path,
|
|
@@ -698,7 +706,7 @@ export function postParseValidate(root: CliNode, pr: ParseResult): ParseResult {
|
|
|
698
706
|
}
|
|
699
707
|
}
|
|
700
708
|
|
|
701
|
-
for (const [k, v] of Object.entries(
|
|
709
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
702
710
|
const d = findOptionByName(defs, k);
|
|
703
711
|
if (!d) {
|
|
704
712
|
return {
|
|
@@ -741,7 +749,29 @@ export function postParseValidate(root: CliNode, pr: ParseResult): ParseResult {
|
|
|
741
749
|
};
|
|
742
750
|
}
|
|
743
751
|
}
|
|
752
|
+
if (d.kind === CliOptionKind.String && (d.format !== undefined || d.pattern !== undefined)) {
|
|
753
|
+
try {
|
|
754
|
+
validateFormatValue(v, d.format, d.pattern);
|
|
755
|
+
} catch (err) {
|
|
756
|
+
const msg =
|
|
757
|
+
d.format !== undefined
|
|
758
|
+
? formatValidationError(d.format, v)
|
|
759
|
+
: err instanceof Error
|
|
760
|
+
? err.message
|
|
761
|
+
: String(err);
|
|
762
|
+
return {
|
|
763
|
+
kind: ParseKind.Error,
|
|
764
|
+
path: pr.path,
|
|
765
|
+
opts: {},
|
|
766
|
+
args: [],
|
|
767
|
+
helpExplicit: false,
|
|
768
|
+
helpPath: [],
|
|
769
|
+
errorMsg: `Invalid value for option --${k}: ${msg}`,
|
|
770
|
+
errorHelpPath: pr.path,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
}
|
|
744
774
|
}
|
|
745
775
|
|
|
746
|
-
return pr;
|
|
776
|
+
return { ...pr, opts };
|
|
747
777
|
}
|
package/src/types.ts
CHANGED
|
@@ -25,6 +25,21 @@ export enum CliOptionKind {
|
|
|
25
25
|
Enum = "enum",
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Named validation/coercion for string options (`format` on `CliOption`).
|
|
30
|
+
* Positionals do not use `format`; varargs use space-separated CLI tokens and JSON arrays over MCP.
|
|
31
|
+
*/
|
|
32
|
+
export enum CliValueFormat {
|
|
33
|
+
/** Duration text such as `30s`, `20m`, `1h`, `2d` (default unit minutes when omitted). */
|
|
34
|
+
Duration = "duration",
|
|
35
|
+
/** Comma-separated list on a single option value (`--services a,b`). */
|
|
36
|
+
CommaList = "comma-list",
|
|
37
|
+
/** Calendar date `YYYY-MM-DD`. */
|
|
38
|
+
Date = "date",
|
|
39
|
+
/** RFC 3339 instant with `Z` or numeric offset. */
|
|
40
|
+
DateTime = "date-time",
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
/**
|
|
29
44
|
* When `fallbackCommand` is used for missing or unknown subcommand tokens at a routing node.
|
|
30
45
|
*/
|
|
@@ -65,6 +80,15 @@ export interface CliOption {
|
|
|
65
80
|
* Must be a non-empty array of distinct non-empty strings.
|
|
66
81
|
*/
|
|
67
82
|
choices?: string[];
|
|
83
|
+
/**
|
|
84
|
+
* Named string validation for `kind: String` options. Mutually exclusive with `pattern`.
|
|
85
|
+
* Not supported on positionals.
|
|
86
|
+
*/
|
|
87
|
+
format?: CliValueFormat;
|
|
88
|
+
/** Default value applied in post-parse when the option is omitted. */
|
|
89
|
+
default?: string;
|
|
90
|
+
/** Regex pattern for string options. Mutually exclusive with `format`. */
|
|
91
|
+
pattern?: string;
|
|
68
92
|
}
|
|
69
93
|
|
|
70
94
|
/**
|
package/src/validate.ts
CHANGED
|
@@ -4,6 +4,7 @@ This module validates CLI schemas before execution.
|
|
|
4
4
|
|
|
5
5
|
import { reservedCommandNames, resolveCapabilities } from "./capabilities.ts";
|
|
6
6
|
import { DOCS_BUILTIN_TOPIC_KEYS } from "./docs/resolve.ts";
|
|
7
|
+
import { validateFormatValue } from "./formats.ts";
|
|
7
8
|
import { resolveMcpSchemaUri } from "./mcp/tools.ts";
|
|
8
9
|
import {
|
|
9
10
|
type CliLeaf,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
CliOptionKind,
|
|
12
13
|
type CliProgram,
|
|
13
14
|
CliSchemaValidationError,
|
|
15
|
+
CliValueFormat,
|
|
14
16
|
isCliLeaf,
|
|
15
17
|
isCliRouter,
|
|
16
18
|
} from "./types.ts";
|
|
@@ -226,6 +228,58 @@ function validateOptions(scopeKey: string, options: import("./types.ts").CliOpti
|
|
|
226
228
|
`Option '${opt.name}' on '${scopeKey}': choices is only valid for Enum kind`,
|
|
227
229
|
);
|
|
228
230
|
}
|
|
231
|
+
|
|
232
|
+
if (opt.format !== undefined || opt.pattern !== undefined || opt.default !== undefined) {
|
|
233
|
+
validateOptionValueMetadata(scopeKey, opt);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function validateOptionValueMetadata(scopeKey: string, opt: import("./types.ts").CliOption): void {
|
|
239
|
+
const label = `${scopeKey}/${opt.name}`;
|
|
240
|
+
|
|
241
|
+
if (opt.default !== undefined) {
|
|
242
|
+
if (opt.kind === CliOptionKind.Presence) {
|
|
243
|
+
throw new CliSchemaValidationError(`default is not valid on presence option ${label}`);
|
|
244
|
+
}
|
|
245
|
+
if (opt.required) {
|
|
246
|
+
throw new CliSchemaValidationError(`default cannot be set on required option ${label}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (opt.format !== undefined && opt.pattern !== undefined) {
|
|
251
|
+
throw new CliSchemaValidationError(
|
|
252
|
+
`Option ${label}: format and pattern are mutually exclusive`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (opt.format !== undefined) {
|
|
257
|
+
if (opt.kind !== CliOptionKind.String) {
|
|
258
|
+
throw new CliSchemaValidationError(`Option ${label}: format is only valid on String kind`);
|
|
259
|
+
}
|
|
260
|
+
if (!Object.values(CliValueFormat).includes(opt.format)) {
|
|
261
|
+
throw new CliSchemaValidationError(`Option ${label}: unknown format '${opt.format}'`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (opt.pattern !== undefined) {
|
|
266
|
+
if (opt.kind !== CliOptionKind.String) {
|
|
267
|
+
throw new CliSchemaValidationError(`Option ${label}: pattern is only valid on String kind`);
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
new RegExp(opt.pattern);
|
|
271
|
+
} catch {
|
|
272
|
+
throw new CliSchemaValidationError(`Option ${label}: invalid pattern regex`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (opt.default !== undefined) {
|
|
277
|
+
try {
|
|
278
|
+
validateFormatValue(opt.default, opt.format, opt.pattern);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
281
|
+
throw new CliSchemaValidationError(`Option ${label}: invalid default: ${msg}`);
|
|
282
|
+
}
|
|
229
283
|
}
|
|
230
284
|
}
|
|
231
285
|
|