1export interface ParsedCliArgs {
2 readonly flags: Record<string, string | null>;
3 readonly positionals: string[];
4}
5
6export interface ParseCliArgsOptions {
7 readonly booleanFlags?: readonly string[];
8}
9
10export function tokenizeCliArgs(args?: string): ReadonlyArray<string> {
11 const input = args?.trim();
12 if (!input) return [];
13
14 const tokens: string[] = [];
15 let current = "";
16 let quote: "'" | '"' | undefined;
17 let quoted = false;
18
19 for (let index = 0; index < input.length; index++) {
20 const char = input[index];
21 if (char === undefined) continue;
22
23 if (quote) {
24 if (char === quote) {
25 quote = undefined;
26 quoted = true;
27 } else if (char === "\\" && quote === '"') {
28 const next = input[index + 1];
29 if (next !== undefined && ['"', "\\", "$", "`"].includes(next)) {
30 current += next;
31 index++;
32 } else {
33 current += char;
34 }
35 } else {
36 current += char;
37 }
38 continue;
39 }
40
41 if (char === "'" || char === '"') {
42 quote = char;
43 quoted = true;
44 } else if (/\s/.test(char)) {
45 if (current || quoted) {
46 tokens.push(current);
47 current = "";
48 quoted = false;
49 }
50 } else if (char === "\\") {
51 const next = input[index + 1];
52 if (next !== undefined && /\s/.test(next)) {
53 current += next;
54 index++;
55 } else {
56 current += char;
57 }
58 } else {
59 current += char;
60 }
61 }
62
63 if (current || quoted) tokens.push(current);
64 return tokens;
65}
66
67/**
68 * Parse CLI-style arguments into flags and positionals.
69 *
70 * Accepts a string (quote-aware tokenized) or a pre-split argv array.
71 * Supports `--key value`, `--key=value`, and `--flag` (boolean) syntax.
72 *
73 * parseCliArgs("")
74 * → { flags: {}, positionals: [] }
75 *
76 * parseCliArgs("--chrome")
77 * → { flags: { chrome: null }, positionals: [] }
78 *
79 * parseCliArgs("--chrome --effort high")
80 * → { flags: { chrome: null, effort: "high" }, positionals: [] }
81 *
82 * parseCliArgs("--effort=high")
83 * → { flags: { effort: "high" }, positionals: [] }
84 *
85 * parseCliArgs(["1.2.3", "--root", "/path", "--github-output"], { booleanFlags: ["github-output"] })
86 * → { flags: { root: "/path", "github-output": null }, positionals: ["1.2.3"] }
87 */
88export function parseCliArgs(
89 args: string | readonly string[],
90 options?: ParseCliArgsOptions,
91): ParsedCliArgs {
92 const tokens = typeof args === "string" ? tokenizeCliArgs(args) : Array.from(args);
93 const booleanSet = options?.booleanFlags ? new Set(options.booleanFlags) : undefined;
94
95 const flags: Record<string, string | null> = {};
96 const positionals: string[] = [];
97
98 for (let i = 0; i < tokens.length; i++) {
99 const token = tokens[i]!;
100
101 if (token.startsWith("--")) {
102 const rest = token.slice(2);
103 if (!rest) continue;
104
105 // Handle --key=value syntax
106 const eqIndex = rest.indexOf("=");
107 if (eqIndex !== -1) {
108 flags[rest.slice(0, eqIndex)] = rest.slice(eqIndex + 1);
109 continue;
110 }
111
112 // Known boolean flag — never consumes next token
113 if (booleanSet?.has(rest)) {
114 flags[rest] = null;
115 continue;
116 }
117
118 // Handle --key value or --flag (boolean)
119 const next = tokens[i + 1];
120 if (next !== undefined && !next.startsWith("--")) {
121 flags[rest] = next;
122 i++;
123 } else {
124 flags[rest] = null;
125 }
126 } else {
127 positionals.push(token);
128 }
129 }
130
131 return { flags, positionals };
132}