argsbarg 0.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/.cursor/rules/code.mdc +9 -0
- package/README.md +188 -0
- package/biome.json +17 -0
- package/bun.lock +21 -0
- package/docs/completions-preview.png +0 -0
- package/docs/help-l2-preview.png +0 -0
- package/docs/help-preview.png +0 -0
- package/examples/minimal.ts +41 -0
- package/examples/nested.ts +87 -0
- package/logo.png +0 -0
- package/package.json +25 -0
- package/plan.md +194 -0
- package/src/completion.ts +523 -0
- package/src/context.ts +67 -0
- package/src/help.ts +429 -0
- package/src/index.test.ts +255 -0
- package/src/index.ts +24 -0
- package/src/parse.ts +487 -0
- package/src/runtime.ts +113 -0
- package/src/types.ts +114 -0
- package/src/utils.ts +24 -0
- package/src/validate.ts +136 -0
- package/tsconfig.json +12 -0
package/src/parse.ts
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/*
|
|
2
|
+
This module parses argv into commands, options, and positional tails.
|
|
3
|
+
It resolves fallback routing, tracks help requests, and shapes the parse result that
|
|
4
|
+
later validation and runtime dispatch both consume.
|
|
5
|
+
|
|
6
|
+
It keeps handler dispatch and help on one parser so the CLI behavior stays consistent
|
|
7
|
+
across every entry path.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { CliContext } from "./context.ts";
|
|
11
|
+
import {
|
|
12
|
+
CliCommand,
|
|
13
|
+
CliOptionDef,
|
|
14
|
+
CliOptionKind,
|
|
15
|
+
CliFallbackMode,
|
|
16
|
+
} from "./types.ts";
|
|
17
|
+
import { fullStringIsDouble } from "./utils.ts";
|
|
18
|
+
|
|
19
|
+
// ── Parse Result ──────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** Outcome of a parse: success, help request, or fatal user error. */
|
|
22
|
+
export enum ParseKind {
|
|
23
|
+
Ok = "ok",
|
|
24
|
+
Help = "help",
|
|
25
|
+
Error = "error",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Structured parse output: routed path, merged options, positional args, and help/error metadata. */
|
|
29
|
+
export interface ParseResult {
|
|
30
|
+
kind: ParseKind;
|
|
31
|
+
path: string[];
|
|
32
|
+
opts: Record<string, string>;
|
|
33
|
+
args: string[];
|
|
34
|
+
helpExplicit: boolean;
|
|
35
|
+
helpPath: string[];
|
|
36
|
+
errorMsg: string;
|
|
37
|
+
errorHelpPath: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const helpShort = "-h";
|
|
43
|
+
const helpLong = "--help";
|
|
44
|
+
|
|
45
|
+
function isHelpTok(tok: string): boolean {
|
|
46
|
+
return tok === helpShort || tok === helpLong;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findChild(cmds: CliCommand[], name: string): CliCommand | undefined {
|
|
50
|
+
return cmds.find((c) => c.key === name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findOptionByName(defs: CliOptionDef[], name: string): CliOptionDef | undefined {
|
|
54
|
+
return defs.find((o) => o.name === name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function findOptionDefByShort(defs: CliOptionDef[], short: string): CliOptionDef | undefined {
|
|
58
|
+
return defs.find((o) => !o.positional && o.shortName === short);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Option Consumption ────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
interface ConsumeReport {
|
|
64
|
+
err: string | null;
|
|
65
|
+
stoppedOnUnknown: boolean;
|
|
66
|
+
sawDoubleDash: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function consumeOptions(
|
|
70
|
+
defs: CliOptionDef[],
|
|
71
|
+
lenientUnknown: boolean,
|
|
72
|
+
argv: string[],
|
|
73
|
+
i: number,
|
|
74
|
+
opts: Record<string, string>,
|
|
75
|
+
): { report: ConsumeReport; nextIndex: number } {
|
|
76
|
+
let idx = i;
|
|
77
|
+
|
|
78
|
+
function consumeLong(tok: string): string | null {
|
|
79
|
+
const body = tok.slice(2);
|
|
80
|
+
let optName: string;
|
|
81
|
+
let inlineVal: string | undefined;
|
|
82
|
+
|
|
83
|
+
const eqIdx = body.indexOf("=");
|
|
84
|
+
if (eqIdx !== -1) {
|
|
85
|
+
optName = body.slice(0, eqIdx);
|
|
86
|
+
inlineVal = body.slice(eqIdx + 1);
|
|
87
|
+
} else {
|
|
88
|
+
optName = body;
|
|
89
|
+
inlineVal = undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const def = findOptionByName(defs, optName);
|
|
93
|
+
if (!def) {
|
|
94
|
+
if (lenientUnknown) return "";
|
|
95
|
+
return `Unknown option: --${optName}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (inlineVal !== undefined) {
|
|
99
|
+
if (def.kind === CliOptionKind.Presence) {
|
|
100
|
+
opts[def.name] = "1";
|
|
101
|
+
} else {
|
|
102
|
+
opts[def.name] = inlineVal;
|
|
103
|
+
}
|
|
104
|
+
idx += 1;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (def.kind === CliOptionKind.Presence) {
|
|
109
|
+
opts[def.name] = "1";
|
|
110
|
+
} else {
|
|
111
|
+
idx += 1;
|
|
112
|
+
if (idx >= argv.length) {
|
|
113
|
+
return `Missing value for option: --${optName}`;
|
|
114
|
+
}
|
|
115
|
+
opts[def.name] = argv[idx];
|
|
116
|
+
}
|
|
117
|
+
idx += 1;
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function consumeShort(tok: string): string | null {
|
|
122
|
+
if (tok.length < 2) return `Unexpected option token: ${tok}`;
|
|
123
|
+
const shorts = tok.slice(1);
|
|
124
|
+
let j = 0;
|
|
125
|
+
|
|
126
|
+
while (j < shorts.length) {
|
|
127
|
+
const shortChar = shorts[j];
|
|
128
|
+
const def = findOptionDefByShort(defs, shortChar);
|
|
129
|
+
|
|
130
|
+
if (!def) {
|
|
131
|
+
if (lenientUnknown) return "";
|
|
132
|
+
return `Unknown option: -${shortChar}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (def.kind === CliOptionKind.Presence) {
|
|
136
|
+
opts[def.name] = "1";
|
|
137
|
+
j += 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Non-presence short option: cannot be bundled
|
|
142
|
+
if (j !== 0 || j + 1 < shorts.length) {
|
|
143
|
+
return `Short option -${shortChar} requires a value and cannot be bundled: ${tok}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
idx += 1;
|
|
147
|
+
if (idx >= argv.length) {
|
|
148
|
+
return `Missing value for option: -${shortChar}`;
|
|
149
|
+
}
|
|
150
|
+
opts[def.name] = argv[idx];
|
|
151
|
+
idx += 1;
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
idx += 1;
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
while (idx < argv.length) {
|
|
160
|
+
const tok = argv[idx];
|
|
161
|
+
|
|
162
|
+
if (isHelpTok(tok)) break;
|
|
163
|
+
if (!tok.startsWith("-")) break;
|
|
164
|
+
|
|
165
|
+
if (tok === "--") {
|
|
166
|
+
idx += 1;
|
|
167
|
+
return { report: { err: null, stoppedOnUnknown: false, sawDoubleDash: true }, nextIndex: idx };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (tok.startsWith("--")) {
|
|
171
|
+
const err = consumeLong(tok);
|
|
172
|
+
if (err === "") return { report: { err: null, stoppedOnUnknown: true, sawDoubleDash: false }, nextIndex: idx };
|
|
173
|
+
if (err) return { report: { err, stoppedOnUnknown: false, sawDoubleDash: false }, nextIndex: idx };
|
|
174
|
+
} else {
|
|
175
|
+
const err = consumeShort(tok);
|
|
176
|
+
if (err === "") return { report: { err: null, stoppedOnUnknown: true, sawDoubleDash: false }, nextIndex: idx };
|
|
177
|
+
if (err) return { report: { err, stoppedOnUnknown: false, sawDoubleDash: false }, nextIndex: idx };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { report: { err: null, stoppedOnUnknown: false, sawDoubleDash: false }, nextIndex: idx };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Positional Collection ─────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
function finishLeaf(
|
|
187
|
+
node: CliCommand,
|
|
188
|
+
startIdx: number,
|
|
189
|
+
argv: string[],
|
|
190
|
+
path: string[],
|
|
191
|
+
opts: Record<string, string>,
|
|
192
|
+
): ParseResult {
|
|
193
|
+
function errorResult(msg: string): ParseResult {
|
|
194
|
+
const pr: ParseResult = {
|
|
195
|
+
kind: ParseKind.Error,
|
|
196
|
+
path: [],
|
|
197
|
+
opts: {},
|
|
198
|
+
args: [],
|
|
199
|
+
helpExplicit: false,
|
|
200
|
+
helpPath: [],
|
|
201
|
+
errorMsg: msg,
|
|
202
|
+
errorHelpPath: path,
|
|
203
|
+
};
|
|
204
|
+
return pr;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let idx = startIdx;
|
|
208
|
+
const args: string[] = [];
|
|
209
|
+
|
|
210
|
+
for (const p of node.positionals ?? []) {
|
|
211
|
+
if (!p.positional) continue;
|
|
212
|
+
|
|
213
|
+
if (p.argMax === 1) {
|
|
214
|
+
if (p.argMin >= 1) {
|
|
215
|
+
if (idx >= argv.length) {
|
|
216
|
+
return errorResult(`Missing positional argument: ${p.name}`);
|
|
217
|
+
}
|
|
218
|
+
args.push(argv[idx]);
|
|
219
|
+
idx += 1;
|
|
220
|
+
} else if (idx < argv.length) {
|
|
221
|
+
args.push(argv[idx]);
|
|
222
|
+
idx += 1;
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let count = 0;
|
|
228
|
+
if (p.argMax === 0) {
|
|
229
|
+
while (idx < argv.length) {
|
|
230
|
+
args.push(argv[idx]);
|
|
231
|
+
idx += 1;
|
|
232
|
+
count += 1;
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
while (count < p.argMax && idx < argv.length) {
|
|
236
|
+
args.push(argv[idx]);
|
|
237
|
+
idx += 1;
|
|
238
|
+
count += 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (count < p.argMin) {
|
|
242
|
+
return errorResult(`Expected at least ${p.argMin} argument(s) for ${p.name}, got ${count}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (idx < argv.length) {
|
|
247
|
+
return errorResult("Unexpected extra arguments");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { kind: ParseKind.Ok, path, opts, args, helpExplicit: false, helpPath: [], errorMsg: "", errorHelpPath: [] };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Main Parser ───────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
function helpResult(p: string[], explicit: boolean): ParseResult {
|
|
256
|
+
return {
|
|
257
|
+
kind: ParseKind.Help,
|
|
258
|
+
path: [],
|
|
259
|
+
opts: {},
|
|
260
|
+
args: [],
|
|
261
|
+
helpExplicit: explicit,
|
|
262
|
+
helpPath: p,
|
|
263
|
+
errorMsg: "",
|
|
264
|
+
errorHelpPath: [],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function parse(root: CliCommand, argv: string[]): ParseResult {
|
|
269
|
+
let i = 0;
|
|
270
|
+
const path: string[] = [];
|
|
271
|
+
const opts: Record<string, string> = {};
|
|
272
|
+
|
|
273
|
+
const rootLenient =
|
|
274
|
+
root.fallbackCommand !== undefined &&
|
|
275
|
+
((root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOrUnknown || (root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.UnknownOnly);
|
|
276
|
+
|
|
277
|
+
// Consume root-level options first
|
|
278
|
+
const rootRep = consumeOptions(root.options ?? [], rootLenient, argv, i, opts);
|
|
279
|
+
if (rootRep.report.err) {
|
|
280
|
+
return {
|
|
281
|
+
kind: ParseKind.Error,
|
|
282
|
+
path: [],
|
|
283
|
+
opts: {},
|
|
284
|
+
args: [],
|
|
285
|
+
helpExplicit: false,
|
|
286
|
+
helpPath: [],
|
|
287
|
+
errorMsg: rootRep.report.err,
|
|
288
|
+
errorHelpPath: [],
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
i = rootRep.nextIndex;
|
|
292
|
+
let forcePositionals = rootRep.report.sawDoubleDash;
|
|
293
|
+
|
|
294
|
+
if (i < argv.length && !forcePositionals && isHelpTok(argv[i])) {
|
|
295
|
+
return helpResult([], true);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Determine which subcommand to route to
|
|
299
|
+
let cmdName: string;
|
|
300
|
+
let node: CliCommand | undefined;
|
|
301
|
+
|
|
302
|
+
if (i >= argv.length) {
|
|
303
|
+
if (root.fallbackCommand !== undefined && ((root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOnly || (root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOrUnknown)) {
|
|
304
|
+
cmdName = root.fallbackCommand;
|
|
305
|
+
node = findChild(root.children ?? [], cmdName);
|
|
306
|
+
if (!node) {
|
|
307
|
+
return { kind: ParseKind.Error, path: [], opts: {}, args: [], helpExplicit: false, helpPath: [], errorMsg: `Unknown command: ${cmdName}`, errorHelpPath: path };
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
return helpResult([], false);
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
const peek = argv[i];
|
|
314
|
+
const childPick = !forcePositionals ? findChild(root.children ?? [], peek) : undefined;
|
|
315
|
+
|
|
316
|
+
if (childPick !== undefined) {
|
|
317
|
+
cmdName = peek;
|
|
318
|
+
i += 1;
|
|
319
|
+
node = childPick;
|
|
320
|
+
} else {
|
|
321
|
+
const canRouteUnknown =
|
|
322
|
+
root.fallbackCommand !== undefined &&
|
|
323
|
+
((root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOrUnknown ||
|
|
324
|
+
(root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.UnknownOnly);
|
|
325
|
+
|
|
326
|
+
if (canRouteUnknown) {
|
|
327
|
+
cmdName = root.fallbackCommand!;
|
|
328
|
+
node = findChild(root.children ?? [], cmdName);
|
|
329
|
+
if (!node) {
|
|
330
|
+
return { kind: ParseKind.Error, path: [], opts: {}, args: [], helpExplicit: false, helpPath: [], errorMsg: `Unknown command: ${cmdName}`, errorHelpPath: path };
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
cmdName = peek;
|
|
334
|
+
if (!forcePositionals) i += 1;
|
|
335
|
+
node = findChild(root.children ?? [], cmdName);
|
|
336
|
+
if (!node) {
|
|
337
|
+
return {
|
|
338
|
+
kind: ParseKind.Error,
|
|
339
|
+
path: [],
|
|
340
|
+
opts: {},
|
|
341
|
+
args: [],
|
|
342
|
+
helpExplicit: false,
|
|
343
|
+
helpPath: [],
|
|
344
|
+
errorMsg: forcePositionals ? `Expected subcommand but got positional: ${cmdName}` : `Unknown command: ${cmdName}`,
|
|
345
|
+
errorHelpPath: path,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
path.push(cmdName);
|
|
353
|
+
let current = node!;
|
|
354
|
+
|
|
355
|
+
// Walk the command tree
|
|
356
|
+
while (true) {
|
|
357
|
+
if (!forcePositionals) {
|
|
358
|
+
const orep = consumeOptions(current.options ?? [], false, argv, i, opts);
|
|
359
|
+
if (orep.report.err) {
|
|
360
|
+
return {
|
|
361
|
+
kind: ParseKind.Error,
|
|
362
|
+
path,
|
|
363
|
+
opts: {},
|
|
364
|
+
args: [],
|
|
365
|
+
helpExplicit: false,
|
|
366
|
+
helpPath: [],
|
|
367
|
+
errorMsg: orep.report.err,
|
|
368
|
+
errorHelpPath: path,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
i = orep.nextIndex;
|
|
372
|
+
if (orep.report.sawDoubleDash) {
|
|
373
|
+
forcePositionals = true;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (i < argv.length && !forcePositionals && isHelpTok(argv[i])) {
|
|
378
|
+
return helpResult(path, true);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (i >= argv.length) {
|
|
382
|
+
if ((current.children ?? []).length > 0) {
|
|
383
|
+
return helpResult(path, false);
|
|
384
|
+
}
|
|
385
|
+
return finishLeaf(current, i, argv, path, opts);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const tok = argv[i];
|
|
389
|
+
if (!forcePositionals && tok.startsWith("-")) {
|
|
390
|
+
return {
|
|
391
|
+
kind: ParseKind.Error,
|
|
392
|
+
path,
|
|
393
|
+
opts: {},
|
|
394
|
+
args: [],
|
|
395
|
+
helpExplicit: false,
|
|
396
|
+
helpPath: [],
|
|
397
|
+
errorMsg: `Unexpected option token: ${tok}`,
|
|
398
|
+
errorHelpPath: path,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!forcePositionals) {
|
|
403
|
+
const childOpt = findChild(current.children ?? [], tok);
|
|
404
|
+
if (childOpt !== undefined) {
|
|
405
|
+
i += 1;
|
|
406
|
+
path.push(tok);
|
|
407
|
+
current = childOpt;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if ((current.children ?? []).length > 0) {
|
|
413
|
+
return {
|
|
414
|
+
kind: ParseKind.Error,
|
|
415
|
+
path,
|
|
416
|
+
opts: {},
|
|
417
|
+
args: [],
|
|
418
|
+
helpExplicit: false,
|
|
419
|
+
helpPath: [],
|
|
420
|
+
errorMsg: forcePositionals ? `Expected subcommand but got positional: ${tok}` : `Unknown subcommand: ${tok}`,
|
|
421
|
+
errorHelpPath: path,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return finishLeaf(current, i, argv, path, opts);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Post-Parse Validation ─────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
export function postParseValidate(root: CliCommand, pr: ParseResult): ParseResult {
|
|
432
|
+
if (pr.kind !== ParseKind.Ok) return pr;
|
|
433
|
+
|
|
434
|
+
let defs = [...(root.options ?? [])];
|
|
435
|
+
let cmds = root.children ?? [];
|
|
436
|
+
|
|
437
|
+
for (const seg of pr.path) {
|
|
438
|
+
const ch = findChild(cmds, seg);
|
|
439
|
+
if (!ch) {
|
|
440
|
+
return {
|
|
441
|
+
kind: ParseKind.Error,
|
|
442
|
+
path: pr.path,
|
|
443
|
+
opts: {},
|
|
444
|
+
args: [],
|
|
445
|
+
helpExplicit: false,
|
|
446
|
+
helpPath: [],
|
|
447
|
+
errorMsg: "Internal path error",
|
|
448
|
+
errorHelpPath: pr.path,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
defs.push(...(ch.options ?? []));
|
|
452
|
+
defs.push(...(ch.positionals ?? []));
|
|
453
|
+
cmds = ch.children ?? [];
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
for (const [k, v] of Object.entries(pr.opts)) {
|
|
457
|
+
const d = findOptionByName(defs, k);
|
|
458
|
+
if (!d) {
|
|
459
|
+
return {
|
|
460
|
+
kind: ParseKind.Error,
|
|
461
|
+
path: pr.path,
|
|
462
|
+
opts: {},
|
|
463
|
+
args: [],
|
|
464
|
+
helpExplicit: false,
|
|
465
|
+
helpPath: [],
|
|
466
|
+
errorMsg: `Unknown option key: ${k}`,
|
|
467
|
+
errorHelpPath: pr.path,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
if (d.kind === CliOptionKind.Number) {
|
|
471
|
+
if (!fullStringIsDouble(v)) {
|
|
472
|
+
return {
|
|
473
|
+
kind: ParseKind.Error,
|
|
474
|
+
path: pr.path,
|
|
475
|
+
opts: {},
|
|
476
|
+
args: [],
|
|
477
|
+
helpExplicit: false,
|
|
478
|
+
helpPath: [],
|
|
479
|
+
errorMsg: `Invalid number for option --${k}: ${v}`,
|
|
480
|
+
errorHelpPath: pr.path,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return pr;
|
|
487
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/*
|
|
2
|
+
This module runs parsed commands, help, errors, completion, and leaf handlers.
|
|
3
|
+
It owns the top-level control flow after parsing, including validation failures,
|
|
4
|
+
shell completion dispatch, and leaf handler invocation.
|
|
5
|
+
|
|
6
|
+
It keeps execution flow out of the public barrel so the exported API stays small and
|
|
7
|
+
the runtime responsibilities remain easy to reason about.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { cliBuiltinCompletionGroup, completionBashScript, completionZshScript } from "./completion.ts";
|
|
11
|
+
import { CliContext } from "./context.ts";
|
|
12
|
+
import { cliHelpRender } from "./help.ts";
|
|
13
|
+
import { parse, postParseValidate } from "./parse.ts";
|
|
14
|
+
import { CliCommand } from "./types.ts";
|
|
15
|
+
import { cliValidateRoot } from "./validate.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Merges the caller's program root with the reserved `completion` subtree.
|
|
19
|
+
*/
|
|
20
|
+
function cliRootMergedWithBuiltins(root: CliCommand): CliCommand {
|
|
21
|
+
const merged = { ...root };
|
|
22
|
+
merged.children = [...(root.children ?? []), cliBuiltinCompletionGroup(root.key)];
|
|
23
|
+
return merged;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validates the schema, parses argv, prints help or errors, runs completion or the leaf handler, then exits.
|
|
28
|
+
*
|
|
29
|
+
* @param root The root CliCommand.
|
|
30
|
+
* @param argv Override the default argv (process.argv.slice(2)).
|
|
31
|
+
*/
|
|
32
|
+
export async function cliRun(root: CliCommand, argv: string[] = process.argv.slice(2)): Promise<never> {
|
|
33
|
+
try {
|
|
34
|
+
cliValidateRoot(root);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err instanceof Error) {
|
|
37
|
+
process.stderr.write(err.message + "\n");
|
|
38
|
+
} else {
|
|
39
|
+
process.stderr.write("Invalid CLI definition.\n");
|
|
40
|
+
}
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const merged = cliRootMergedWithBuiltins(root);
|
|
45
|
+
let pr = parse(merged, argv);
|
|
46
|
+
pr = postParseValidate(merged, pr);
|
|
47
|
+
|
|
48
|
+
if (pr.kind === "help") {
|
|
49
|
+
process.stdout.write(cliHelpRender(merged, pr.helpPath, false));
|
|
50
|
+
process.exit(pr.helpExplicit ? 0 : 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (pr.kind === "error") {
|
|
54
|
+
const color = process.stderr.isTTY;
|
|
55
|
+
const msg = color ? `\u001B[31m${pr.errorMsg}\u001B[0m` : pr.errorMsg;
|
|
56
|
+
process.stderr.write(msg + "\n");
|
|
57
|
+
process.stderr.write(cliHelpRender(merged, pr.errorHelpPath, true));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (pr.path.length === 0) {
|
|
62
|
+
process.stderr.write("Internal error: empty path.\n");
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (pr.path[0] === "completion") {
|
|
67
|
+
if (pr.path[1] === "bash") {
|
|
68
|
+
process.stdout.write(completionBashScript(merged));
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
if (pr.path[1] === "zsh") {
|
|
72
|
+
process.stdout.write(completionZshScript(merged));
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let current = merged;
|
|
78
|
+
for (const seg of pr.path) {
|
|
79
|
+
const ch = (current.children ?? []).find((candidate: CliCommand) => candidate.key === seg);
|
|
80
|
+
if (!ch) {
|
|
81
|
+
process.stderr.write("Internal error: missing handler for path.\n");
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
current = ch;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!current.handler) {
|
|
88
|
+
process.stderr.write("Internal error: missing handler for path.\n");
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const ctx = new CliContext(merged.key, pr.path, pr.args, pr.opts, merged);
|
|
93
|
+
try {
|
|
94
|
+
await Promise.resolve(current.handler(ctx));
|
|
95
|
+
process.exit(0);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err instanceof Error) {
|
|
98
|
+
process.stderr.write(err.message + "\n");
|
|
99
|
+
}
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Prints a red error line and contextual help on stderr, then exits with status 1.
|
|
106
|
+
*/
|
|
107
|
+
export function cliErrWithHelp(ctx: CliContext, msg: string): never {
|
|
108
|
+
const color = process.stderr.isTTY;
|
|
109
|
+
const line = color ? `\u001B[31m${msg}\u001B[0m` : msg;
|
|
110
|
+
process.stderr.write(line + "\n");
|
|
111
|
+
process.stderr.write(cliHelpRender(ctx.schema, ctx.commandPath, true));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/*
|
|
2
|
+
This module defines the CLI schema, option kinds, fallback modes, and helpers.
|
|
3
|
+
It is the shared declarative model that parsing, validation, help, and completion all
|
|
4
|
+
read from, so the package has one source of truth.
|
|
5
|
+
|
|
6
|
+
It gives the package one shared model for both library users and internal modules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CliContext } from "./context.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Option kinds: presence (boolean flag), string (free-form text), or number (strict double).
|
|
13
|
+
*/
|
|
14
|
+
export enum CliOptionKind {
|
|
15
|
+
Presence = "presence",
|
|
16
|
+
String = "string",
|
|
17
|
+
Number = "number",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* When fallbackCommand is used for missing or unknown top-level tokens.
|
|
22
|
+
* Only the program root may set a non-default mode or a non-nil fallbackCommand.
|
|
23
|
+
*/
|
|
24
|
+
export enum CliFallbackMode {
|
|
25
|
+
/** Use the fallback only when argv has no first subcommand token. */
|
|
26
|
+
MissingOnly = "missingOnly",
|
|
27
|
+
/** Use the fallback when the first token is missing or not a known child name. */
|
|
28
|
+
MissingOrUnknown = "missingOrUnknown",
|
|
29
|
+
/** Use the fallback only when the first token is not a known child name. */
|
|
30
|
+
UnknownOnly = "unknownOnly",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One CLI flag, option, or positional definition.
|
|
35
|
+
*/
|
|
36
|
+
export interface CliOptionDef {
|
|
37
|
+
/** Option name (e.g., "name", "verbose"). */
|
|
38
|
+
name: string;
|
|
39
|
+
/** Description shown in help. */
|
|
40
|
+
description: string;
|
|
41
|
+
/** Option kind: presence flag, string value, or number value. */
|
|
42
|
+
kind: CliOptionKind;
|
|
43
|
+
/** Short option character (e.g., 'n' for -n). */
|
|
44
|
+
shortName?: string;
|
|
45
|
+
/** Whether this is a positional argument (true) or a flag/option (false). */
|
|
46
|
+
positional: boolean;
|
|
47
|
+
/** Minimum number of values required (for positionals). */
|
|
48
|
+
argMin: number;
|
|
49
|
+
/** Maximum number of values allowed (0 = unlimited, for positionals). */
|
|
50
|
+
argMax: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A command node: routing group (has children) or leaf (has handler).
|
|
55
|
+
*
|
|
56
|
+
* The value passed to cliRun is the program root: name is the app/binary name,
|
|
57
|
+
* children are top-level subcommands, options are global flags.
|
|
58
|
+
* The root must not set handler or declare positionals (validated at startup).
|
|
59
|
+
*/
|
|
60
|
+
export interface CliCommand {
|
|
61
|
+
/** Program or command key (e.g., "myapp", "stat", "owner"). */
|
|
62
|
+
key: string;
|
|
63
|
+
/** Short description shown in help. */
|
|
64
|
+
description: string;
|
|
65
|
+
/** Additional notes shown in help (supports {app} placeholder). */
|
|
66
|
+
notes?: string;
|
|
67
|
+
/** Global or command-level flags/options. */
|
|
68
|
+
options?: CliOptionDef[];
|
|
69
|
+
/** Positional argument definitions. */
|
|
70
|
+
positionals?: CliOptionDef[];
|
|
71
|
+
/** Child subcommands (empty for leaf commands). */
|
|
72
|
+
children?: CliCommand[];
|
|
73
|
+
/** Handler function for leaf commands. */
|
|
74
|
+
handler?: CliHandler;
|
|
75
|
+
/** Default top-level subcommand when argv omits a command or uses an unknown first token (root only). */
|
|
76
|
+
fallbackCommand?: string;
|
|
77
|
+
/** How fallbackCommand is applied (root only). */
|
|
78
|
+
fallbackMode?: CliFallbackMode;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handler closure type for leaf commands.
|
|
83
|
+
* Supports both sync and async handlers.
|
|
84
|
+
*/
|
|
85
|
+
export type CliHandler = (ctx: CliContext) => void | Promise<void>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Error thrown when the static CliCommand tree violates ArgsBarg rules.
|
|
89
|
+
*/
|
|
90
|
+
export class CliSchemaValidationError extends Error {
|
|
91
|
+
constructor(message: string) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.name = "CliSchemaValidationError";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Creates a new CliOptionDef with sensible defaults.
|
|
99
|
+
*/
|
|
100
|
+
export function createOption(
|
|
101
|
+
name: string,
|
|
102
|
+
description: string,
|
|
103
|
+
options?: Partial<CliOptionDef>,
|
|
104
|
+
): CliOptionDef {
|
|
105
|
+
return {
|
|
106
|
+
name,
|
|
107
|
+
description,
|
|
108
|
+
kind: options?.kind ?? CliOptionKind.Presence,
|
|
109
|
+
shortName: options?.shortName,
|
|
110
|
+
positional: options?.positional ?? false,
|
|
111
|
+
argMin: options?.argMin ?? 1,
|
|
112
|
+
argMax: options?.argMax ?? 1,
|
|
113
|
+
};
|
|
114
|
+
}
|