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/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 coerces comma-separated string for varargs", () => {
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(["read", "a", "b"]);
1967
+ expect(argv).toEqual({ error: expect.stringContaining("JSON array") });
1968
1968
  });
1969
1969
 
1970
- test("mcpToolCallToArgv coerces single string for varargs", () => {
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(["read", "a"]);
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 string varargs appends nothing", () => {
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(["read"]);
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 { CliFallbackMode, CliOptionKind, CliSchemaValidationError } from "./types.ts";
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
- return { type: "string", ...base };
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
- argv.push(`--${opt.name}`, String(val));
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
- let items: string[];
264
- if (Array.isArray(raw)) {
265
- items = raw.map(String);
266
- } else if (typeof raw === "string") {
267
- items = raw.includes(",")
268
- ? raw
269
- .split(",")
270
- .map((s) => s.trim())
271
- .filter(Boolean)
272
- : raw.trim()
273
- ? [raw.trim()]
274
- : [];
275
- } else {
276
- items = [];
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.required && !(d.name in pr.opts)) {
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(pr.opts)) {
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