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/src/help.ts ADDED
@@ -0,0 +1,429 @@
1
+ /*
2
+ This module renders CLI help with wrapping, boxes, tables, and TTY color.
3
+ It formats both explicit help and error output, using the terminal width to keep the
4
+ layout readable and aligned with the current display.
5
+
6
+ It keeps help formatting shared across help and error paths so users see one consistent
7
+ style no matter how help is reached.
8
+ */
9
+
10
+ import { CliCommand, CliOptionDef, CliOptionKind } from "./types.ts";
11
+
12
+ // ── ANSI Style Helpers ────────────────────────────────────────────────────────
13
+
14
+ const style = {
15
+ wrap(prefix: string, body: string, suffix: string): string {
16
+ return prefix + body + suffix;
17
+ },
18
+ red(msg: string): string {
19
+ return this.wrap("\u001B[31m", msg, "\u001B[0m");
20
+ },
21
+ gray(msg: string): string {
22
+ return this.wrap("\u001B[90m", msg, "\u001B[0m");
23
+ },
24
+ bold(msg: string): string {
25
+ return this.wrap("\u001B[1m", msg, "\u001B[0m");
26
+ },
27
+ white(msg: string): string {
28
+ return this.wrap("\u001B[37m", msg, "\u001B[0m");
29
+ },
30
+ aquaBold(msg: string): string {
31
+ return this.wrap("\u001B[96m\u001B[1m", msg, "\u001B[0m");
32
+ },
33
+ greenBright(msg: string): string {
34
+ return this.wrap("\u001B[92m", msg, "\u001B[0m");
35
+ },
36
+ grayBoldTitle(title: string): string {
37
+ return this.gray(this.bold(title));
38
+ },
39
+ };
40
+
41
+ // ── Unicode Box Drawing Characters ────────────────────────────────────────────
42
+
43
+ const kBoxTL = "\u256D"; // ╭
44
+ const kBoxTR = "\u256E"; // ╮
45
+ const kBoxV = "\u2502"; // │
46
+ const kBoxBL = "\u2570"; // ╰
47
+ const kBoxBR = "\u256F"; // ╯
48
+ const kBoxH = "\u2500"; // ─
49
+
50
+ // ── Terminal Detection ────────────────────────────────────────────────────────
51
+
52
+ function getHelpWidth(): number {
53
+ return Math.max(40, process.stdout.columns || 80);
54
+ }
55
+
56
+ function isTTY(fd: number): boolean {
57
+ return process.stdout.isTTY !== undefined;
58
+ }
59
+
60
+ // ── Width Helpers ─────────────────────────────────────────────────────────────
61
+
62
+ /** Counts display columns, skipping ANSI SGR sequences. */
63
+ function visibleWidth(s: string): number {
64
+ let w = 0;
65
+ let i = 0;
66
+ while (i < s.length) {
67
+ if (s[i] === "\u001B" && i + 1 < s.length && s[i + 1] === "[") {
68
+ i += 2;
69
+ while (i < s.length && s[i] !== "m") {
70
+ i += 1;
71
+ }
72
+ if (i < s.length) i += 1;
73
+ continue;
74
+ }
75
+ w += 1;
76
+ i += 1;
77
+ }
78
+ return w;
79
+ }
80
+
81
+ function repeatBoxH(n: number): string {
82
+ return kBoxH.repeat(Math.max(0, n));
83
+ }
84
+
85
+ function spaces(n: number): string {
86
+ return " ".repeat(Math.max(0, n));
87
+ }
88
+
89
+ function padVisible(s: string, width: number): string {
90
+ return s + spaces(Math.max(0, width - visibleWidth(s)));
91
+ }
92
+
93
+ // ── Text Wrapping ─────────────────────────────────────────────────────────────
94
+
95
+ function wrapParagraph(text: string, width: number): string[] {
96
+ const available = Math.max(1, width);
97
+ const out: string[] = [];
98
+ let cur = "";
99
+
100
+ for (const word of text.split(/\s+/).filter((w) => w.length > 0)) {
101
+ if (cur.length === 0) {
102
+ cur = word;
103
+ continue;
104
+ }
105
+ if (cur.length + 1 + word.length <= available) {
106
+ cur += " " + word;
107
+ } else {
108
+ out.push(cur);
109
+ cur = word;
110
+ }
111
+ }
112
+ if (cur.length > 0) out.push(cur);
113
+ return out;
114
+ }
115
+
116
+ function wrapText(text: string, width: number): string[] {
117
+ const out: string[] = [];
118
+ const lines = text.split("\n");
119
+
120
+ for (const line of lines) {
121
+ if (line.trim().length === 0) {
122
+ out.push("");
123
+ continue;
124
+ }
125
+ if (line[0] === " " || line[0] === "\t") {
126
+ out.push(line);
127
+ continue;
128
+ }
129
+ out.push(...wrapParagraph(line, width));
130
+ }
131
+ if (out.length === 0) out.push("");
132
+ return out;
133
+ }
134
+
135
+ // ── Option Label Formatting ───────────────────────────────────────────────────
136
+
137
+ function optKindLabel(k: CliOptionKind): string {
138
+ switch (k) {
139
+ case CliOptionKind.Presence:
140
+ return "";
141
+ case CliOptionKind.Number:
142
+ return " <number>";
143
+ case CliOptionKind.String:
144
+ return " <string>";
145
+ }
146
+ }
147
+
148
+ export function cliOptionLabel(o: CliOptionDef, color: boolean): string {
149
+ if (o.positional) {
150
+ if (o.argMax === 1) {
151
+ return o.argMin === 0 ? "[" + o.name + "]" : "<" + o.name + ">";
152
+ }
153
+ return o.argMin === 0 ? "[" + o.name + "...]" : "<" + o.name + "...>";
154
+ }
155
+ let r = "--" + o.name + optKindLabel(o.kind);
156
+ if (o.shortName) r += ", -" + o.shortName;
157
+ if (!color) return r;
158
+
159
+ const sepIdx = r.indexOf(", ");
160
+ if (sepIdx === -1) return style.aquaBold(r);
161
+ const left = r.slice(0, sepIdx);
162
+ const right = r.slice(sepIdx + 2);
163
+ return style.aquaBold(left) + " " + style.greenBright(right);
164
+ }
165
+
166
+ // ── Box Rendering ─────────────────────────────────────────────────────────────
167
+
168
+ interface HelpRow {
169
+ label: string;
170
+ description: string;
171
+ }
172
+
173
+ function renderTextBox(title: string, lines: string[], hw: number, color: boolean): string[] {
174
+ if (lines.length === 0) return [];
175
+
176
+ const titleLead = color
177
+ ? style.gray(kBoxH + " ") + style.grayBoldTitle(title) + style.gray(" ")
178
+ : kBoxH + " " + title + " ";
179
+
180
+ let contentWidth = visibleWidth(titleLead) + 1;
181
+ for (const line of lines) {
182
+ contentWidth = Math.max(contentWidth, visibleWidth(line));
183
+ }
184
+ contentWidth = Math.max(hw - 2, contentWidth);
185
+ contentWidth = Math.min(contentWidth, hw - 4);
186
+
187
+ const borderWidth = contentWidth + 2;
188
+ const headerFill = Math.max(1, borderWidth - visibleWidth(titleLead));
189
+
190
+ const out: string[] = [];
191
+ out.push(
192
+ (color ? style.gray(kBoxTL) : kBoxTL) +
193
+ titleLead +
194
+ (color ? style.gray(repeatBoxH(headerFill) + kBoxTR) : repeatBoxH(headerFill) + kBoxTR),
195
+ );
196
+
197
+ for (const line of lines) {
198
+ const padded = padVisible(line, contentWidth);
199
+ out.push(
200
+ (color ? style.gray(kBoxV) : kBoxV) + " " + padded + " " + (color ? style.gray(kBoxV) : kBoxV),
201
+ );
202
+ }
203
+
204
+ out.push(
205
+ (color ? style.gray(kBoxBL + repeatBoxH(borderWidth) + kBoxBR) : kBoxBL + repeatBoxH(borderWidth) + kBoxBR),
206
+ );
207
+
208
+ return out;
209
+ }
210
+
211
+ function renderTableBox(title: string, rows: HelpRow[], hw: number, color: boolean): string[] {
212
+ if (rows.length === 0) return [];
213
+
214
+ let labelWidth = 0;
215
+ for (const row of rows) {
216
+ labelWidth = Math.max(labelWidth, visibleWidth(row.label));
217
+ }
218
+
219
+ const titleChunk = kBoxH + " " + title + " ";
220
+ const minimumContentWidth = Math.max(visibleWidth(titleChunk) + 1, labelWidth + 2 + 18);
221
+ let contentWidth = Math.max(hw - 2, minimumContentWidth);
222
+ const descWidth = Math.max(1, contentWidth - labelWidth - 2);
223
+
224
+ const bodyLines: string[] = [];
225
+ for (const row of rows) {
226
+ const wrapped = wrapText(row.description, descWidth);
227
+ const first =
228
+ row.label + spaces(labelWidth - visibleWidth(row.label)) + " " +
229
+ (color ? style.white(wrapped[0]) : wrapped[0]);
230
+ bodyLines.push(first);
231
+ for (let idx = 1; idx < wrapped.length; idx++) {
232
+ const pad = color ? style.gray(spaces(labelWidth)) : spaces(labelWidth);
233
+ bodyLines.push(pad + " " + (color ? style.white(wrapped[idx]) : wrapped[idx]));
234
+ }
235
+ }
236
+
237
+ let titleLead: string;
238
+ if (color) {
239
+ titleLead = style.gray(kBoxH + " ") + style.grayBoldTitle(title) + style.gray(" ");
240
+ } else {
241
+ titleLead = kBoxH + " " + title + " ";
242
+ }
243
+
244
+ contentWidth = Math.max(contentWidth, visibleWidth(titleLead) + 1);
245
+ for (const line of bodyLines) {
246
+ contentWidth = Math.max(contentWidth, visibleWidth(line));
247
+ }
248
+ contentWidth = Math.min(contentWidth, hw - 4);
249
+
250
+ const borderWidth = contentWidth + 2;
251
+ const headerFill = Math.max(1, borderWidth - visibleWidth(titleLead));
252
+
253
+ const out: string[] = [];
254
+ out.push(
255
+ (color ? style.gray(kBoxTL) : kBoxTL) +
256
+ titleLead +
257
+ (color ? style.gray(repeatBoxH(headerFill) + kBoxTR) : repeatBoxH(headerFill) + kBoxTR),
258
+ );
259
+
260
+ for (const line of bodyLines) {
261
+ const padded = padVisible(line, contentWidth);
262
+ out.push(
263
+ (color ? style.gray(kBoxV) : kBoxV) + " " + padded + " " + (color ? style.gray(kBoxV) : kBoxV),
264
+ );
265
+ }
266
+
267
+ out.push(
268
+ (color ? style.gray(kBoxBL + repeatBoxH(borderWidth) + kBoxBR) : kBoxBL + repeatBoxH(borderWidth) + kBoxBR),
269
+ );
270
+
271
+ return out;
272
+ }
273
+
274
+ // ── Usage & Rows ──────────────────────────────────────────────────────────────
275
+
276
+ function usageLines(
277
+ appName: string,
278
+ helpPath: string[],
279
+ hasCommands: boolean,
280
+ hasArgs: boolean,
281
+ color: boolean,
282
+ ): string[] {
283
+ let fullPath = appName;
284
+ for (const seg of helpPath) {
285
+ fullPath += " " + seg;
286
+ }
287
+ const usageOpts = color ? style.aquaBold("[OPTIONS]") : "[OPTIONS]";
288
+ const usageCmd = color ? style.aquaBold("COMMAND") : "COMMAND";
289
+ const usageArgs = color ? style.aquaBold("[ARGS]...") : "[ARGS]...";
290
+
291
+ const out: string[] = [];
292
+ if (helpPath.length === 0) {
293
+ if (hasCommands) {
294
+ out.push(fullPath + " " + usageOpts + " " + usageCmd + " " + usageArgs);
295
+ } else {
296
+ out.push(fullPath + " " + usageOpts);
297
+ }
298
+ return out;
299
+ }
300
+ out.push(fullPath + " " + usageOpts + (hasArgs ? (" " + usageArgs) : ""));
301
+ if (hasCommands) {
302
+ out.push(fullPath + " " + usageCmd + " " + usageArgs);
303
+ }
304
+ return out;
305
+ }
306
+
307
+ function rowsForOptions(defs: CliOptionDef[], color: boolean): HelpRow[] {
308
+ const rows: HelpRow[] = [];
309
+ const helpLabel = color
310
+ ? style.aquaBold("--help, ") + style.greenBright("-h")
311
+ : "--help, -h";
312
+ rows.push({ label: helpLabel, description: "Show help for this command." });
313
+ for (const o of defs) {
314
+ if (o.positional) continue;
315
+ rows.push({ label: cliOptionLabel(o, color), description: o.description });
316
+ }
317
+ return rows;
318
+ }
319
+
320
+ function rowsForPositionals(defs: CliOptionDef[], color: boolean): HelpRow[] {
321
+ return defs
322
+ .filter((o) => o.positional)
323
+ .map((o) => ({ label: cliOptionLabel(o, color), description: o.description }));
324
+ }
325
+
326
+ function rowsForSubcommands(cmds: CliCommand[]): HelpRow[] {
327
+ return cmds
328
+ .sort((a, b) => a.key.localeCompare(b.key))
329
+ .map((c) => ({ label: c.key, description: c.description }));
330
+ }
331
+
332
+ // ── Main Help Render ──────────────────────────────────────────────────────────
333
+
334
+ export function cliHelpRender(schema: CliCommand, helpPath: string[], useStderr: boolean): string {
335
+ const hw = getHelpWidth();
336
+ const color = isTTY(1);
337
+
338
+ if (helpPath.length === 0) {
339
+ const lines: string[] = [];
340
+ lines.push("");
341
+ if (schema.description.length > 0) {
342
+ lines.push(color ? style.white(schema.description) : schema.description);
343
+ lines.push("");
344
+ }
345
+ lines.push(
346
+ renderTextBox(
347
+ "Usage",
348
+ usageLines(schema.key, helpPath, (schema.children ?? []).length > 0, false, color),
349
+ hw,
350
+ color,
351
+ ).join("\n"),
352
+ );
353
+
354
+ const optBox = renderTableBox("Options", rowsForOptions(schema.options ?? [], color), hw, color);
355
+ if (optBox.length > 0) {
356
+ lines.push("");
357
+ lines.push(optBox.join("\n"));
358
+ }
359
+ if ((schema.children ?? []).length > 0) {
360
+ lines.push("");
361
+ lines.push(
362
+ renderTableBox("Commands", rowsForSubcommands(schema.children ?? []), hw, color).join("\n"),
363
+ );
364
+ }
365
+ return lines.join("\n") + "\n\n";
366
+ }
367
+
368
+ let layer = schema.children ?? [];
369
+ let node: CliCommand | undefined;
370
+ for (const seg of helpPath) {
371
+ const ch = layer.find((c) => c.key === seg);
372
+ if (!ch) {
373
+ return (color ? style.red("Unknown help path.") : "Unknown help path.") + "\n";
374
+ }
375
+ node = ch;
376
+ layer = ch.children ?? [];
377
+ }
378
+ if (!node) {
379
+ return (color ? style.red("Unknown help path.") : "Unknown help path.") + "\n";
380
+ }
381
+
382
+ const lines: string[] = [];
383
+ lines.push("");
384
+ if (node.description.length > 0) {
385
+ lines.push(color ? style.white(node.description) : node.description);
386
+ lines.push("");
387
+ }
388
+ lines.push(
389
+ renderTextBox(
390
+ "Usage",
391
+ usageLines(schema.key, helpPath, (node.children ?? []).length > 0, (node.positionals ?? []).length > 0, color),
392
+ hw,
393
+ color,
394
+ ).join("\n"),
395
+ );
396
+
397
+ const optBox = renderTableBox("Options", rowsForOptions(node.options ?? [], color), hw, color);
398
+ if (optBox.length > 0) {
399
+ lines.push("");
400
+ lines.push(optBox.join("\n"));
401
+ }
402
+
403
+ const posBox = renderTableBox("Arguments", rowsForPositionals(node.positionals ?? [], color), hw, color);
404
+ if (posBox.length > 0) {
405
+ lines.push("");
406
+ lines.push(posBox.join("\n"));
407
+ }
408
+
409
+ const subBox = renderTableBox("Subcommands", rowsForSubcommands(node.children ?? []), hw, color);
410
+ if (subBox.length > 0) {
411
+ lines.push("");
412
+ lines.push(subBox.join("\n"));
413
+ }
414
+
415
+ if ((node.notes ?? "").length > 0) {
416
+ let resolved = node.notes!;
417
+ while (true) {
418
+ const r = resolved.indexOf("{app}");
419
+ if (r === -1) break;
420
+ resolved = resolved.slice(0, r) + schema.key + resolved.slice(r + 5);
421
+ }
422
+ lines.push("");
423
+ lines.push(
424
+ renderTextBox("Notes", wrapText(resolved, hw - 4), hw, color).join("\n"),
425
+ );
426
+ }
427
+
428
+ return lines.join("\n") + "\n\n";
429
+ }
@@ -0,0 +1,255 @@
1
+ /*
2
+ This test file covers parsing, validation, and completion regressions.
3
+ It exercises the public API rather than internal helpers so the tests follow the same
4
+ paths that users and example CLIs take.
5
+
6
+ It keeps the CLI contract stable by catching routing, option handling, and generated
7
+ shell output regressions.
8
+ */
9
+
10
+ import {
11
+ CliCommand,
12
+ createOption,
13
+ CliOptionKind,
14
+ CliFallbackMode,
15
+ cliValidateRoot,
16
+ parse,
17
+ postParseValidate,
18
+ completionBashScript,
19
+ completionZshScript,
20
+ ParseKind,
21
+ } from "./index.ts";
22
+ import { expect, test } from "bun:test";
23
+
24
+ test("bundled short presence flags", () => {
25
+ const root: CliCommand = {
26
+ key: "app",
27
+ description: "",
28
+ children: [
29
+ {
30
+ key: "x",
31
+ description: "cmd",
32
+ options: [
33
+ createOption("a", "", { kind: CliOptionKind.Presence, shortName: "a" }),
34
+ createOption("b", "", { kind: CliOptionKind.Presence, shortName: "b" }),
35
+ ],
36
+ handler: () => {},
37
+ },
38
+ ],
39
+ };
40
+ cliValidateRoot(root);
41
+ const pr = postParseValidate(root, parse(root, ["x", "-ab"]));
42
+ expect(pr.kind).toBe(ParseKind.Ok);
43
+ expect(pr.opts["a"]).toBe("1");
44
+ expect(pr.opts["b"]).toBe("1");
45
+ });
46
+
47
+ test("long option equals", () => {
48
+ const root: CliCommand = {
49
+ key: "app",
50
+ description: "",
51
+ children: [
52
+ {
53
+ key: "x",
54
+ description: "cmd",
55
+ options: [createOption("name", "", { kind: CliOptionKind.String })],
56
+ handler: () => {},
57
+ },
58
+ ],
59
+ };
60
+ cliValidateRoot(root);
61
+ const pr = postParseValidate(root, parse(root, ["x", "--name=pat"]));
62
+ expect(pr.kind).toBe(ParseKind.Ok);
63
+ expect(pr.opts["name"]).toBe("pat");
64
+ });
65
+
66
+ test("fallback missing or unknown root flags", () => {
67
+ const root: CliCommand = {
68
+ key: "app",
69
+ description: "",
70
+ children: [
71
+ {
72
+ key: "hello",
73
+ description: "Say hi.",
74
+ options: [createOption("name", "", { kind: CliOptionKind.String })],
75
+ handler: () => {},
76
+ },
77
+ ],
78
+ fallbackCommand: "hello",
79
+ fallbackMode: CliFallbackMode.MissingOrUnknown,
80
+ };
81
+ cliValidateRoot(root);
82
+ const pr = postParseValidate(root, parse(root, ["--name", "bob"]));
83
+ expect(pr.kind).toBe(ParseKind.Ok);
84
+ expect(pr.path).toEqual(["hello"]);
85
+ expect(pr.opts["name"]).toBe("bob");
86
+ });
87
+
88
+ test("unknown command", () => {
89
+ const root: CliCommand = {
90
+ key: "app",
91
+ description: "",
92
+ children: [{ key: "hello", description: "", handler: () => {} }],
93
+ };
94
+ cliValidateRoot(root);
95
+ const pr = parse(root, ["nope"]);
96
+ expect(pr.kind).toBe(ParseKind.Error);
97
+ expect(pr.errorMsg).toContain("Unknown command");
98
+ });
99
+
100
+ test("implicit help empty", () => {
101
+ const root: CliCommand = {
102
+ key: "app",
103
+ description: "",
104
+ children: [{ key: "x", description: "", handler: () => {} }],
105
+ };
106
+ cliValidateRoot(root);
107
+ const pr = parse(root, []);
108
+ expect(pr.kind).toBe(ParseKind.Help);
109
+ expect(pr.helpExplicit).toBe(false);
110
+ });
111
+
112
+ test("invalid number post validate", () => {
113
+ const root: CliCommand = {
114
+ key: "app",
115
+ description: "",
116
+ children: [
117
+ {
118
+ key: "x",
119
+ description: "",
120
+ options: [createOption("n", "", { kind: CliOptionKind.Number })],
121
+ handler: () => {},
122
+ },
123
+ ],
124
+ };
125
+ cliValidateRoot(root);
126
+ let pr = parse(root, ["x", "--n", "notnum"]);
127
+ pr = postParseValidate(root, pr);
128
+ expect(pr.kind).toBe(ParseKind.Error);
129
+ expect(pr.errorMsg).toContain("Invalid number");
130
+ });
131
+
132
+ test("supports scientific notation in numbers", () => {
133
+ const root: CliCommand = {
134
+ key: "app",
135
+ description: "",
136
+ children: [
137
+ {
138
+ key: "x",
139
+ description: "",
140
+ options: [createOption("n", "", { kind: CliOptionKind.Number })],
141
+ handler: () => {},
142
+ },
143
+ ],
144
+ };
145
+ cliValidateRoot(root);
146
+ let pr = parse(root, ["x", "--n", "1.23e4"]);
147
+ pr = postParseValidate(root, pr);
148
+ expect(pr.kind).toBe(ParseKind.Ok);
149
+ expect(Number(pr.opts["n"])).toBe(12300);
150
+ });
151
+
152
+ test("root must not have handler", () => {
153
+ const root: CliCommand = {
154
+ key: "app",
155
+ description: "",
156
+ children: [{ key: "x", description: "", handler: () => {} }],
157
+ handler: () => {},
158
+ };
159
+ expect(() => cliValidateRoot(root)).toThrow(/Program root must not set handler/);
160
+ });
161
+
162
+ test("root must not have positionals", () => {
163
+ const root: CliCommand = {
164
+ key: "app",
165
+ description: "",
166
+ positionals: [
167
+ createOption("p", "", { kind: CliOptionKind.String, positional: true }),
168
+ ],
169
+ children: [{ key: "x", description: "", handler: () => {} }],
170
+ };
171
+ expect(() => cliValidateRoot(root)).toThrow(/Program root must not declare positionals/);
172
+ });
173
+
174
+ test("completion scripts contain app name", () => {
175
+ const root: CliCommand = {
176
+ key: "myapp",
177
+ description: "Test",
178
+ children: [{ key: "hello", description: "Say hello.", handler: () => {} }],
179
+ };
180
+ cliValidateRoot(root);
181
+ const bash = completionBashScript(root);
182
+ expect(bash).toContain("bash completion for myapp");
183
+ expect(bash).toContain("complete -F _myapp myapp");
184
+
185
+ const zsh = completionZshScript(root);
186
+ expect(zsh).toContain("#compdef myapp");
187
+ expect(zsh).toContain("compdef _myapp myapp");
188
+ expect(zsh).toContain("hello:Say hello.");
189
+ });
190
+
191
+ test("completion scripts do not emit invalid bash substitutions", () => {
192
+ const root: CliCommand = {
193
+ key: "app",
194
+ description: "Test",
195
+ children: [{ key: "hello", description: "Say hello.", handler: () => {} }],
196
+ };
197
+ cliValidateRoot(root);
198
+ const bash = completionBashScript(root);
199
+ expect(bash).not.toContain("${${");
200
+ });
201
+
202
+ test("completion scripts escape shell-sensitive command text in zsh", () => {
203
+ const root: CliCommand = {
204
+ key: "app",
205
+ description: "Test",
206
+ children: [
207
+ {
208
+ key: "quote'cmd",
209
+ description: "Say 'hello' and keep going.",
210
+ handler: () => {},
211
+ },
212
+ ],
213
+ };
214
+ cliValidateRoot(root);
215
+ const zsh = completionZshScript(root);
216
+ expect(zsh).toContain("quote'\\''cmd:Say '\\''hello'\\'' and keep going.");
217
+ });
218
+
219
+ test("completion scripts keep dotted app names in registration names", () => {
220
+ const root: CliCommand = {
221
+ key: "minimal.ts",
222
+ description: "Test",
223
+ children: [{ key: "hello", description: "Say hello.", handler: () => {} }],
224
+ };
225
+ cliValidateRoot(root);
226
+
227
+ const bash = completionBashScript(root);
228
+ expect(bash).toContain("complete -F _minimal_ts minimal.ts");
229
+
230
+ const zsh = completionZshScript(root);
231
+ expect(zsh).toContain("compdef _minimal_ts minimal.ts");
232
+ });
233
+
234
+ test("stops parsing options at --", () => {
235
+ const root: CliCommand = {
236
+ key: "app",
237
+ description: "",
238
+ children: [
239
+ {
240
+ key: "x",
241
+ description: "cmd",
242
+ options: [createOption("name", "", { kind: CliOptionKind.String })],
243
+ positionals: [
244
+ createOption("files", "", { kind: CliOptionKind.String, positional: true, argMax: 0, argMin: 0 })
245
+ ],
246
+ handler: () => {},
247
+ },
248
+ ],
249
+ };
250
+ cliValidateRoot(root);
251
+ const pr = postParseValidate(root, parse(root, ["x", "--name", "pat", "--", "--name", "bob", "-x"]));
252
+ expect(pr.kind).toBe(ParseKind.Ok);
253
+ expect(pr.opts["name"]).toBe("pat");
254
+ expect(pr.args).toEqual(["--name", "bob", "-x"]);
255
+ });
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ /*
2
+ This entrypoint re-exports the public API and keeps the runtime split into modules.
3
+ It gathers the package surface in one place while the actual execution flow lives in
4
+ focused files for parsing, help, validation, completion, and runtime dispatch.
5
+
6
+ It gives consumers one stable import path without forcing them to know the internal
7
+ module layout.
8
+ */
9
+
10
+ export { cliBuiltinCompletionGroup, completionBashScript, completionZshScript } from "./completion.ts";
11
+ export { cliRun, cliErrWithHelp } from "./runtime";
12
+ export { CliContext } from "./context.ts";
13
+ export { cliOptionLabel, cliHelpRender } from "./help.ts";
14
+ export { ParseKind, parse, postParseValidate } from "./parse.ts";
15
+ export type { ParseResult } from "./parse.ts";
16
+ export {
17
+ CliOptionKind,
18
+ CliFallbackMode,
19
+ CliSchemaValidationError,
20
+ createOption,
21
+ } from "./types.ts";
22
+ export type { CliOptionDef, CliCommand, CliHandler } from "./types.ts";
23
+ export { fullStringIsDouble, strictParseDouble } from "./utils.ts";
24
+ export { cliValidateRoot } from "./validate.ts";