claudeup 4.17.0 → 4.18.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/package.json +1 -1
- package/src/__tests__/alias-parser.test.ts +317 -0
- package/src/__tests__/alias-shell-writer.test.ts +661 -0
- package/src/__tests__/alias-store.test.ts +86 -0
- package/src/__tests__/gitignore-fixer.test.ts +64 -1
- package/src/__tests__/gitignore-prerun.test.ts +2 -2
- package/src/__tests__/gitignore-service.test.ts +42 -0
- package/src/__tests__/marketplaces.test.ts +40 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +120 -0
- package/src/__tests__/useGitignoreModal.test.ts +2 -2
- package/src/data/alias-flags.js +196 -0
- package/src/data/alias-flags.ts +291 -0
- package/src/data/gitignore-reasons.js +97 -0
- package/src/data/gitignore-reasons.ts +103 -0
- package/src/data/marketplaces.js +5 -3
- package/src/data/marketplaces.ts +5 -4
- package/src/services/alias-settings.js +51 -0
- package/src/services/alias-settings.ts +63 -0
- package/src/services/alias-shell-writer.js +764 -0
- package/src/services/alias-shell-writer.ts +873 -0
- package/src/services/alias-store.js +77 -0
- package/src/services/alias-store.ts +112 -0
- package/src/services/gitignore-fixer.js +70 -10
- package/src/services/gitignore-fixer.ts +76 -9
- package/src/services/gitignore-prerun.js +3 -3
- package/src/services/gitignore-prerun.ts +3 -3
- package/src/services/gitignore-service.js +20 -2
- package/src/services/gitignore-service.ts +23 -1
- package/src/services/marketplace-fetcher.js +96 -0
- package/src/services/marketplace-fetcher.ts +137 -0
- package/src/services/plugin-manager.js +6 -59
- package/src/services/plugin-manager.ts +16 -91
- package/src/services/skillsmp-client.js +29 -9
- package/src/services/skillsmp-client.ts +38 -8
- package/src/types/gitignore.ts +1 -1
- package/src/ui/App.js +10 -4
- package/src/ui/App.tsx +9 -3
- package/src/ui/components/TabBar.js +2 -1
- package/src/ui/components/TabBar.tsx +2 -1
- package/src/ui/components/layout/FooterHints.js +29 -0
- package/src/ui/components/layout/FooterHints.tsx +52 -0
- package/src/ui/components/layout/ScreenLayout.js +2 -1
- package/src/ui/components/layout/ScreenLayout.tsx +12 -3
- package/src/ui/components/layout/index.js +1 -0
- package/src/ui/components/layout/index.ts +5 -0
- package/src/ui/components/modals/SelectModal.js +8 -1
- package/src/ui/components/modals/SelectModal.tsx +12 -1
- package/src/ui/hooks/useGitignoreModal.js +7 -8
- package/src/ui/hooks/useGitignoreModal.ts +8 -9
- package/src/ui/renderers/gitignoreRenderers.js +36 -23
- package/src/ui/renderers/gitignoreRenderers.tsx +50 -41
- package/src/ui/screens/AliasScreen.js +1008 -0
- package/src/ui/screens/AliasScreen.tsx +1402 -0
- package/src/ui/screens/CliToolsScreen.js +6 -1
- package/src/ui/screens/CliToolsScreen.tsx +6 -1
- package/src/ui/screens/EnvVarsScreen.js +6 -1
- package/src/ui/screens/EnvVarsScreen.tsx +6 -1
- package/src/ui/screens/GitignoreScreen.js +189 -88
- package/src/ui/screens/GitignoreScreen.tsx +312 -132
- package/src/ui/screens/McpRegistryScreen.js +13 -2
- package/src/ui/screens/McpRegistryScreen.tsx +13 -2
- package/src/ui/screens/McpScreen.js +6 -1
- package/src/ui/screens/McpScreen.tsx +6 -1
- package/src/ui/screens/ModelSelectorScreen.js +8 -2
- package/src/ui/screens/ModelSelectorScreen.tsx +8 -2
- package/src/ui/screens/PluginsScreen.js +13 -2
- package/src/ui/screens/PluginsScreen.tsx +13 -2
- package/src/ui/screens/ProfilesScreen.js +8 -1
- package/src/ui/screens/ProfilesScreen.tsx +8 -1
- package/src/ui/screens/SkillsScreen.js +21 -4
- package/src/ui/screens/SkillsScreen.tsx +39 -5
- package/src/ui/screens/StatusLineScreen.js +7 -1
- package/src/ui/screens/StatusLineScreen.tsx +7 -1
- package/src/ui/screens/index.js +1 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/types.ts +4 -2
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render the managed `claude` alias and splice it into shell rc files.
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
* - Pure render functions (POSIX vs fish) that take a config and emit text.
|
|
6
|
+
* - I/O helpers that detect installed shells and replace the managed block
|
|
7
|
+
* idempotently using sentinel comment markers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile, writeFile, stat } from "node:fs/promises";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import {
|
|
15
|
+
ALIAS_FLAGS,
|
|
16
|
+
getFlagById,
|
|
17
|
+
type AliasFlag,
|
|
18
|
+
} from "../data/alias-flags.js";
|
|
19
|
+
import type { AliasConfig, FlagValue } from "./alias-store.js";
|
|
20
|
+
|
|
21
|
+
export type ShellKind = "zsh" | "bash" | "fish";
|
|
22
|
+
|
|
23
|
+
export interface ShellTarget {
|
|
24
|
+
kind: ShellKind;
|
|
25
|
+
/** Absolute path to the rc file. */
|
|
26
|
+
path: string;
|
|
27
|
+
/** True if the file currently exists on disk. */
|
|
28
|
+
exists: boolean;
|
|
29
|
+
/** True if `$SHELL` points at this shell — the default write target. */
|
|
30
|
+
isDefault: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const BLOCK_BEGIN = "# >>> claudeup managed alias >>>";
|
|
34
|
+
const BLOCK_END = "# <<< claudeup managed alias <<<";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Probe the user's home dir for the three supported shell rc files.
|
|
38
|
+
*
|
|
39
|
+
* Path discovery is intentionally simple: we don't follow `$ZDOTDIR` or
|
|
40
|
+
* fish's `$XDG_CONFIG_HOME` overrides yet. If users hit those, we expose
|
|
41
|
+
* a path override later — for now the conventional locations cover the
|
|
42
|
+
* default install on macOS / Linux.
|
|
43
|
+
*/
|
|
44
|
+
export async function detectShells(
|
|
45
|
+
home: string = homedir(),
|
|
46
|
+
shellEnv: string = process.env.SHELL ?? "",
|
|
47
|
+
): Promise<ShellTarget[]> {
|
|
48
|
+
const candidates: Array<{ kind: ShellKind; path: string }> = [
|
|
49
|
+
{ kind: "zsh", path: join(home, ".zshrc") },
|
|
50
|
+
{ kind: "bash", path: join(home, ".bashrc") },
|
|
51
|
+
{ kind: "fish", path: join(home, ".config", "fish", "config.fish") },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const defaultKind = inferDefaultShell(shellEnv);
|
|
55
|
+
|
|
56
|
+
const results: ShellTarget[] = [];
|
|
57
|
+
for (const c of candidates) {
|
|
58
|
+
let exists = existsSync(c.path);
|
|
59
|
+
if (exists) {
|
|
60
|
+
// existsSync can lie on broken symlinks — check stat too
|
|
61
|
+
try {
|
|
62
|
+
await stat(c.path);
|
|
63
|
+
} catch {
|
|
64
|
+
exists = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
results.push({
|
|
68
|
+
kind: c.kind,
|
|
69
|
+
path: c.path,
|
|
70
|
+
exists,
|
|
71
|
+
isDefault: c.kind === defaultKind,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function inferDefaultShell(shellEnv: string): ShellKind | null {
|
|
78
|
+
if (shellEnv.endsWith("/zsh")) return "zsh";
|
|
79
|
+
if (shellEnv.endsWith("/bash")) return "bash";
|
|
80
|
+
if (shellEnv.endsWith("/fish")) return "fish";
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Rendering ────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One emitted argv token in the alias body. A token can be:
|
|
88
|
+
*
|
|
89
|
+
* - A plain `literal` token like `--ide` or `low` (gets single-quoted in
|
|
90
|
+
* the outer alias body).
|
|
91
|
+
* - A `composite` token, where the actual argv value is the concatenation
|
|
92
|
+
* of multiple parts. Used by templated flags so a value like
|
|
93
|
+
* `dev-{folder}-{day}` becomes ONE shell argument with two embedded
|
|
94
|
+
* substitutions, not three separate tokens.
|
|
95
|
+
*
|
|
96
|
+
* Composite parts are rendered together with no separating whitespace and
|
|
97
|
+
* with the appropriate close-outer / reopen-outer dance per shell.
|
|
98
|
+
*/
|
|
99
|
+
export type Segment =
|
|
100
|
+
| { kind: "literal"; text: string }
|
|
101
|
+
| { kind: "composite"; parts: SegmentPart[] };
|
|
102
|
+
|
|
103
|
+
export type SegmentPart =
|
|
104
|
+
| { kind: "literal"; text: string }
|
|
105
|
+
| { kind: "raw"; posix: string; fish: string };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build the alias body as a list of segments. Pure function.
|
|
109
|
+
*
|
|
110
|
+
* Validation:
|
|
111
|
+
* - `xorGroup` collisions are resolved by emitting only the first enabled
|
|
112
|
+
* flag in the group. The UI is responsible for warning the user; the
|
|
113
|
+
* renderer never silently keeps both.
|
|
114
|
+
* - `requires` is enforced: a flag whose dependency is disabled is dropped.
|
|
115
|
+
*/
|
|
116
|
+
export function renderArgs(config: AliasConfig): Segment[] {
|
|
117
|
+
const out: Segment[] = [];
|
|
118
|
+
const seenXor = new Set<string>();
|
|
119
|
+
|
|
120
|
+
for (const flag of ALIAS_FLAGS) {
|
|
121
|
+
if (flag.requires) {
|
|
122
|
+
const dep = config.flags[flag.requires];
|
|
123
|
+
if (!isFlagEnabled(dep)) continue;
|
|
124
|
+
}
|
|
125
|
+
if (flag.xorGroup) {
|
|
126
|
+
if (seenXor.has(flag.xorGroup)) continue;
|
|
127
|
+
}
|
|
128
|
+
const value = config.flags[flag.id];
|
|
129
|
+
if (!value) continue;
|
|
130
|
+
|
|
131
|
+
const rendered = renderFlag(flag, value);
|
|
132
|
+
if (rendered.length === 0) continue;
|
|
133
|
+
out.push(...rendered);
|
|
134
|
+
if (flag.xorGroup) seenXor.add(flag.xorGroup);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Convenience: render each segment to its literal-token approximation.
|
|
142
|
+
* Composite segments are stringified by joining their literal parts with
|
|
143
|
+
* `<sub>` placeholders for raw parts. Used by tests and validation summaries
|
|
144
|
+
* where the actual shell substitution doesn't matter.
|
|
145
|
+
*/
|
|
146
|
+
export function renderArgsAsTokens(config: AliasConfig): string[] {
|
|
147
|
+
return renderArgs(config).map((seg) => {
|
|
148
|
+
if (seg.kind === "literal") return seg.text;
|
|
149
|
+
return seg.parts
|
|
150
|
+
.map((p) => (p.kind === "literal" ? p.text : `<${p.posix}>`))
|
|
151
|
+
.join("");
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isFlagEnabled(value: FlagValue | undefined): boolean {
|
|
156
|
+
if (!value) return false;
|
|
157
|
+
switch (value.kind) {
|
|
158
|
+
case "boolean":
|
|
159
|
+
return value.enabled;
|
|
160
|
+
case "tri-state":
|
|
161
|
+
return value.state !== "unset";
|
|
162
|
+
case "select":
|
|
163
|
+
case "text":
|
|
164
|
+
case "optional-text":
|
|
165
|
+
case "text-list":
|
|
166
|
+
case "multi-with-custom":
|
|
167
|
+
return value.enabled;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function lit(text: string): Segment {
|
|
172
|
+
return { kind: "literal", text };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Wrap a templated value as a composite segment if it contains any tokens,
|
|
177
|
+
* otherwise return it as a plain literal.
|
|
178
|
+
*/
|
|
179
|
+
function templatedSegment(value: string): Segment {
|
|
180
|
+
const parts = expandTemplateValue(value);
|
|
181
|
+
if (parts.length === 1 && parts[0].kind === "literal") {
|
|
182
|
+
return { kind: "literal", text: parts[0].text };
|
|
183
|
+
}
|
|
184
|
+
return { kind: "composite", parts };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderFlag(flag: AliasFlag, value: FlagValue): Segment[] {
|
|
188
|
+
switch (value.kind) {
|
|
189
|
+
case "boolean":
|
|
190
|
+
return value.enabled ? [lit(flag.flag)] : [];
|
|
191
|
+
case "tri-state":
|
|
192
|
+
if (value.state === "on") return [lit(flag.flag)];
|
|
193
|
+
if (value.state === "off" && flag.triStateOff)
|
|
194
|
+
return [lit(flag.triStateOff)];
|
|
195
|
+
return [];
|
|
196
|
+
case "select":
|
|
197
|
+
if (!value.enabled) return [];
|
|
198
|
+
// Empty value means "bare flag, default variant" (e.g. `--tmux` alone).
|
|
199
|
+
return value.value === ""
|
|
200
|
+
? [lit(flag.flag)]
|
|
201
|
+
: [lit(flag.flag), lit(value.value)];
|
|
202
|
+
case "text":
|
|
203
|
+
if (!value.enabled || !value.value) return [];
|
|
204
|
+
return [
|
|
205
|
+
lit(flag.flag),
|
|
206
|
+
flag.templated ? templatedSegment(value.value) : lit(value.value),
|
|
207
|
+
];
|
|
208
|
+
case "optional-text":
|
|
209
|
+
if (!value.enabled) return [];
|
|
210
|
+
if (!value.value) return [lit(flag.flag)];
|
|
211
|
+
return [
|
|
212
|
+
lit(flag.flag),
|
|
213
|
+
flag.templated ? templatedSegment(value.value) : lit(value.value),
|
|
214
|
+
];
|
|
215
|
+
case "text-list": {
|
|
216
|
+
if (!value.enabled || value.values.length === 0) return [];
|
|
217
|
+
const out: Segment[] = [];
|
|
218
|
+
for (const v of value.values) {
|
|
219
|
+
if (!v) continue;
|
|
220
|
+
out.push(
|
|
221
|
+
lit(flag.flag),
|
|
222
|
+
flag.templated ? templatedSegment(v) : lit(v),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
case "multi-with-custom": {
|
|
228
|
+
if (!value.enabled) return [];
|
|
229
|
+
const tokens = dedupe([...value.picked, ...value.custom]).filter(
|
|
230
|
+
(t) => t.length > 0,
|
|
231
|
+
);
|
|
232
|
+
if (tokens.length === 0) return [lit(flag.flag)];
|
|
233
|
+
return [lit(flag.flag), lit(tokens.join(","))];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Per-token shell-substitution code. POSIX and fish forms diverge slightly:
|
|
240
|
+
*
|
|
241
|
+
* - POSIX: classic `$(cmd)` with double quotes around `$PWD` so paths with
|
|
242
|
+
* spaces survive word-splitting in `basename`. The double quotes are
|
|
243
|
+
* tolerable inside the outer-double-quoted segment because we're already
|
|
244
|
+
* committed to a "embedded substitution" layer.
|
|
245
|
+
* - fish: `(cmd)` and fish handles variable expansion without the
|
|
246
|
+
* word-splitting concern, so `$PWD` doesn't need inner quoting.
|
|
247
|
+
*
|
|
248
|
+
* Note for POSIX: when this segment is emitted as `'"$(basename "$PWD")"'`,
|
|
249
|
+
* the shell sees `"$(basename "$PWD")"` — outer `"…"` enclosing an inner
|
|
250
|
+
* `"$PWD"`. POSIX (and bash/zsh) handle nested double quotes inside command
|
|
251
|
+
* substitution correctly: the inner `"$PWD"` is parsed within the `$(...)`
|
|
252
|
+
* subshell scope, not the outer shell's scope.
|
|
253
|
+
*/
|
|
254
|
+
const TOKEN_TO_SUB: Record<string, { posix: string; fish: string }> = {
|
|
255
|
+
"{folder}": {
|
|
256
|
+
posix: '$(basename "$PWD")',
|
|
257
|
+
fish: "(basename $PWD)",
|
|
258
|
+
},
|
|
259
|
+
"{day}": { posix: "$(date +%d)", fish: "(date +%d)" },
|
|
260
|
+
"{month}": { posix: "$(date +%m)", fish: "(date +%m)" },
|
|
261
|
+
"{year}": { posix: "$(date +%Y)", fish: "(date +%Y)" },
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const TEMPLATE_RE = /\{(folder|day|month|year)\}/g;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Split a templated value into alternating literal and raw parts.
|
|
268
|
+
* Empty literal pieces are dropped. If the value contains no tokens, returns
|
|
269
|
+
* a single literal part covering the full string.
|
|
270
|
+
*/
|
|
271
|
+
export function expandTemplateValue(value: string): SegmentPart[] {
|
|
272
|
+
const out: SegmentPart[] = [];
|
|
273
|
+
let last = 0;
|
|
274
|
+
for (const match of value.matchAll(TEMPLATE_RE)) {
|
|
275
|
+
const start = match.index;
|
|
276
|
+
if (start > last) {
|
|
277
|
+
out.push({ kind: "literal", text: value.slice(last, start) });
|
|
278
|
+
}
|
|
279
|
+
const sub = TOKEN_TO_SUB[match[0]];
|
|
280
|
+
if (sub) {
|
|
281
|
+
out.push({ kind: "raw", posix: sub.posix, fish: sub.fish });
|
|
282
|
+
}
|
|
283
|
+
last = start + match[0].length;
|
|
284
|
+
}
|
|
285
|
+
if (last < value.length) {
|
|
286
|
+
out.push({ kind: "literal", text: value.slice(last) });
|
|
287
|
+
}
|
|
288
|
+
if (out.length === 0) {
|
|
289
|
+
out.push({ kind: "literal", text: value });
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function dedupe(arr: string[]): string[] {
|
|
295
|
+
return Array.from(new Set(arr));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Fish quoting. Fish single quotes are simpler — only `\` and `'` need escapes.
|
|
300
|
+
*/
|
|
301
|
+
function quoteFish(s: string): string {
|
|
302
|
+
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface RenderedAlias {
|
|
306
|
+
shell: ShellKind;
|
|
307
|
+
/** The full block including BEGIN/END markers, ending with a newline. */
|
|
308
|
+
block: string;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function renderAlias(
|
|
312
|
+
config: AliasConfig,
|
|
313
|
+
shell: ShellKind,
|
|
314
|
+
): RenderedAlias {
|
|
315
|
+
const args = renderArgs(config);
|
|
316
|
+
const name = config.aliasName;
|
|
317
|
+
if (shell === "fish") {
|
|
318
|
+
return { shell, block: renderFishBlock(name, args) };
|
|
319
|
+
}
|
|
320
|
+
return { shell, block: renderPosixBlock(name, args) };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function renderPosixBlock(name: string, segments: Segment[]): string {
|
|
324
|
+
// The alias body lives inside outer single quotes:
|
|
325
|
+
// alias <name>='claude … '
|
|
326
|
+
// Each segment becomes one shell-arg inside that body. Composite segments
|
|
327
|
+
// (templated values) interleave literal text with double-quoted shell
|
|
328
|
+
// substitutions via close-outer + double-quote + reopen-outer.
|
|
329
|
+
const body =
|
|
330
|
+
segments.length === 0
|
|
331
|
+
? ""
|
|
332
|
+
: " " + segments.map(renderPosixSegment).join(" ");
|
|
333
|
+
const line = `alias ${name}='claude${body}'`;
|
|
334
|
+
return `${BLOCK_BEGIN}\n${line}\n${BLOCK_END}\n`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function renderPosixSegment(seg: Segment): string {
|
|
338
|
+
if (seg.kind === "literal") return quoteInsidePosixAlias(seg.text);
|
|
339
|
+
return seg.parts
|
|
340
|
+
.map((p) =>
|
|
341
|
+
p.kind === "literal"
|
|
342
|
+
? quoteInsidePosixAlias(p.text)
|
|
343
|
+
: embedPosixSubstitution(p.posix),
|
|
344
|
+
)
|
|
345
|
+
.join("");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Embed shell substitution code inside the outer single-quoted alias body
|
|
350
|
+
* so it fires when the alias is expanded, not when the alias is defined.
|
|
351
|
+
*
|
|
352
|
+
* The outer alias body is `'…'`. To inject `$(cmd)` and have it evaluate at
|
|
353
|
+
* expansion time, we close the outer `'`, switch to double quotes (which
|
|
354
|
+
* interpolate), emit the substitution, close the double quotes, reopen the
|
|
355
|
+
* outer `'`. Net source text: `'"$(cmd)"'`.
|
|
356
|
+
*
|
|
357
|
+
* Any inner double quotes in the substitution code (e.g. `"$PWD"` for
|
|
358
|
+
* spaces-in-paths protection) survive because POSIX command substitution
|
|
359
|
+
* has its own quoting context inside `$(...)`.
|
|
360
|
+
*/
|
|
361
|
+
function embedPosixSubstitution(posixCode: string): string {
|
|
362
|
+
return `'"${posixCode}"'`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const POSIX_BARE = /^[A-Za-z0-9_\-=,:.\/!@%+]+$/;
|
|
366
|
+
|
|
367
|
+
function quoteInsidePosixAlias(token: string): string {
|
|
368
|
+
if (POSIX_BARE.test(token)) return token;
|
|
369
|
+
// No internal single quotes guaranteed by upstream validation.
|
|
370
|
+
return `'\\''${token}'\\''`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Tokens with embedded single quotes can't be cleanly nested inside an outer
|
|
375
|
+
* single-quoted alias body without compounding escape layers that make the
|
|
376
|
+
* file unreadable. The UI surfaces this as an error before write.
|
|
377
|
+
*
|
|
378
|
+
* Composite segments are not checked here — their literal parts are checked
|
|
379
|
+
* via the same recursion. A `'` in a templated value's literal portion would
|
|
380
|
+
* still be unrenderable, so we walk the parts.
|
|
381
|
+
*/
|
|
382
|
+
export function findUnquotableTokens(segments: Segment[]): string[] {
|
|
383
|
+
const out: string[] = [];
|
|
384
|
+
for (const seg of segments) {
|
|
385
|
+
if (seg.kind === "literal") {
|
|
386
|
+
if (seg.text.includes("'")) out.push(seg.text);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
for (const p of seg.parts) {
|
|
390
|
+
if (p.kind === "literal" && p.text.includes("'")) out.push(p.text);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function renderFishBlock(name: string, segments: Segment[]): string {
|
|
397
|
+
// Fish: `alias <name> 'claude --foo bar'` — outer single quotes, no `=`.
|
|
398
|
+
const body =
|
|
399
|
+
segments.length === 0
|
|
400
|
+
? ""
|
|
401
|
+
: " " + segments.map(renderFishSegment).join(" ");
|
|
402
|
+
const line = `alias ${name} 'claude${body}'`;
|
|
403
|
+
return `${BLOCK_BEGIN}\n${line}\n${BLOCK_END}\n`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function renderFishSegment(seg: Segment): string {
|
|
407
|
+
if (seg.kind === "literal") return quoteFish(seg.text);
|
|
408
|
+
return seg.parts
|
|
409
|
+
.map((p) =>
|
|
410
|
+
p.kind === "literal"
|
|
411
|
+
? quoteFish(p.text)
|
|
412
|
+
: embedFishSubstitution(p.fish),
|
|
413
|
+
)
|
|
414
|
+
.join("");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Fish substitution embedding mirrors POSIX. Outer `'…'` is fish's literal
|
|
419
|
+
* single-quote (no interpolation), so close-and-double-quote-reopen is the
|
|
420
|
+
* same dance: `'"(cmd)"'` becomes literal-' + `(cmd)` evaluated at expansion
|
|
421
|
+
* time + literal-'.
|
|
422
|
+
*/
|
|
423
|
+
function embedFishSubstitution(fishCode: string): string {
|
|
424
|
+
return `'"${fishCode}"'`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ─── Splicing ─────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Replace the existing managed block in `existing` with `block`, or append
|
|
431
|
+
* `block` to the end if no block is present. Pure function — no I/O.
|
|
432
|
+
*/
|
|
433
|
+
export function spliceManagedBlock(existing: string, block: string): string {
|
|
434
|
+
const beginIdx = existing.indexOf(BLOCK_BEGIN);
|
|
435
|
+
if (beginIdx === -1) {
|
|
436
|
+
if (existing.length === 0) return block;
|
|
437
|
+
const sep = existing.endsWith("\n") ? "" : "\n";
|
|
438
|
+
return existing + sep + "\n" + block;
|
|
439
|
+
}
|
|
440
|
+
const endIdx = existing.indexOf(BLOCK_END, beginIdx);
|
|
441
|
+
if (endIdx === -1) {
|
|
442
|
+
// Malformed block — replace from BEGIN to EOF to be safe.
|
|
443
|
+
return existing.slice(0, beginIdx) + block;
|
|
444
|
+
}
|
|
445
|
+
// Consume the trailing newline of the END marker if present.
|
|
446
|
+
let cutEnd = endIdx + BLOCK_END.length;
|
|
447
|
+
if (existing[cutEnd] === "\n") cutEnd += 1;
|
|
448
|
+
return existing.slice(0, beginIdx) + block + existing.slice(cutEnd);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export interface WriteResult {
|
|
452
|
+
shell: ShellKind;
|
|
453
|
+
path: string;
|
|
454
|
+
/** "created" if the file did not exist; "updated" otherwise. */
|
|
455
|
+
action: "created" | "updated";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function writeAliasToShell(
|
|
459
|
+
config: AliasConfig,
|
|
460
|
+
target: ShellTarget,
|
|
461
|
+
): Promise<WriteResult> {
|
|
462
|
+
const args = renderArgs(config);
|
|
463
|
+
const unquotable = findUnquotableTokens(args);
|
|
464
|
+
if (unquotable.length > 0) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
`Cannot render alias: ${unquotable.length} value${
|
|
467
|
+
unquotable.length === 1 ? "" : "s"
|
|
468
|
+
} contain a single quote, which can't be embedded in a shell alias. Edit the offending value(s) and try again: ${unquotable
|
|
469
|
+
.map((t) => JSON.stringify(t))
|
|
470
|
+
.join(", ")}`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const rendered = renderAlias(config, target.kind);
|
|
474
|
+
const existing = target.exists ? await readFile(target.path, "utf8") : "";
|
|
475
|
+
const next = spliceManagedBlock(existing, rendered.block);
|
|
476
|
+
await writeFile(target.path, next, "utf8");
|
|
477
|
+
return {
|
|
478
|
+
shell: target.kind,
|
|
479
|
+
path: target.path,
|
|
480
|
+
action: target.exists ? "updated" : "created",
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ─── Validation summary (UI helper) ───────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
export interface FlagValidationIssue {
|
|
487
|
+
flagId: string;
|
|
488
|
+
reason: string;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Summarize which flags are in conflict given the current config.
|
|
493
|
+
* The UI uses this to grey out / annotate rows. The renderer enforces the
|
|
494
|
+
* same rules independently — this helper is purely for display.
|
|
495
|
+
*/
|
|
496
|
+
export function validateConfig(config: AliasConfig): FlagValidationIssue[] {
|
|
497
|
+
const issues: FlagValidationIssue[] = [];
|
|
498
|
+
|
|
499
|
+
// xor groups
|
|
500
|
+
const groups = new Map<string, string[]>();
|
|
501
|
+
for (const flag of ALIAS_FLAGS) {
|
|
502
|
+
if (!flag.xorGroup) continue;
|
|
503
|
+
if (!isFlagEnabled(config.flags[flag.id])) continue;
|
|
504
|
+
const list = groups.get(flag.xorGroup) ?? [];
|
|
505
|
+
list.push(flag.id);
|
|
506
|
+
groups.set(flag.xorGroup, list);
|
|
507
|
+
}
|
|
508
|
+
for (const [group, ids] of groups) {
|
|
509
|
+
if (ids.length > 1) {
|
|
510
|
+
const winner = ids[0];
|
|
511
|
+
const losers = ids.slice(1);
|
|
512
|
+
for (const loser of losers) {
|
|
513
|
+
issues.push({
|
|
514
|
+
flagId: loser,
|
|
515
|
+
reason: `Mutually exclusive with --${winner}; will be dropped on write.`,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// requires
|
|
522
|
+
for (const flag of ALIAS_FLAGS) {
|
|
523
|
+
if (!flag.requires) continue;
|
|
524
|
+
if (!isFlagEnabled(config.flags[flag.id])) continue;
|
|
525
|
+
if (!isFlagEnabled(config.flags[flag.requires])) {
|
|
526
|
+
const dep = getFlagById(flag.requires);
|
|
527
|
+
issues.push({
|
|
528
|
+
flagId: flag.id,
|
|
529
|
+
reason: `Requires ${dep.flag}; will be dropped on write.`,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return issues;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ─── Parser ──────────────────────────────────────────────────────────────
|
|
538
|
+
//
|
|
539
|
+
// Reverses what the writer emits. Read shell rc text, extract the managed
|
|
540
|
+
// block, find the `alias <name>='claude <body>'` line, tokenize the body
|
|
541
|
+
// into argv strings (undoing POSIX nested-quote escapes and template
|
|
542
|
+
// substitution embeds), then map argv to a FlagValue per the catalog.
|
|
543
|
+
//
|
|
544
|
+
// Only the POSIX dialect (zsh/bash) is supported on parse today. Fish round-
|
|
545
|
+
// trip isn't symmetric to POSIX in our writer's output anyway (fish's auto-
|
|
546
|
+
// concat of adjacent quoted strings makes parsing more involved), and the
|
|
547
|
+
// vast majority of users are on zsh.
|
|
548
|
+
|
|
549
|
+
export interface ParseResult {
|
|
550
|
+
aliasName: string | null;
|
|
551
|
+
args: string[];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Find the managed block in shell rc text, parse the alias line, and return
|
|
556
|
+
* the alias name plus the argv tokens after `claude`.
|
|
557
|
+
*
|
|
558
|
+
* Returns `null` when no managed block exists, the block is malformed, or
|
|
559
|
+
* the alias line can't be parsed. Callers treat null as "no managed alias
|
|
560
|
+
* on disk — start from defaults".
|
|
561
|
+
*/
|
|
562
|
+
export function parseManagedBlock(rcText: string): ParseResult | null {
|
|
563
|
+
const beginIdx = rcText.indexOf(BLOCK_BEGIN);
|
|
564
|
+
if (beginIdx === -1) return null;
|
|
565
|
+
const endIdx = rcText.indexOf(BLOCK_END, beginIdx);
|
|
566
|
+
if (endIdx === -1) return null;
|
|
567
|
+
|
|
568
|
+
// Lines inside the block, sans markers.
|
|
569
|
+
const inner = rcText.slice(beginIdx + BLOCK_BEGIN.length, endIdx);
|
|
570
|
+
// Find the `alias <name>=...` line (skip blank lines, comments, the markers).
|
|
571
|
+
const aliasLine = inner
|
|
572
|
+
.split("\n")
|
|
573
|
+
.map((l) => l.trim())
|
|
574
|
+
.find((l) => /^alias\s+[A-Za-z_][A-Za-z0-9_-]*\s*=/.test(l));
|
|
575
|
+
if (!aliasLine) return null;
|
|
576
|
+
|
|
577
|
+
return parseAliasLine(aliasLine);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const ALIAS_LINE_RE = /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*'(.*)'\s*$/;
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Parse a single `alias <name>='<body>'` line. Body is the raw text inside
|
|
584
|
+
* the outer single quotes; the tokenizer below undoes the writer's escape
|
|
585
|
+
* scheme.
|
|
586
|
+
*/
|
|
587
|
+
export function parseAliasLine(line: string): ParseResult | null {
|
|
588
|
+
const match = ALIAS_LINE_RE.exec(line);
|
|
589
|
+
if (!match) return null;
|
|
590
|
+
const [, aliasName, body] = match;
|
|
591
|
+
// First token is the command itself (claude). Drop it; we only want argv.
|
|
592
|
+
const tokens = tokenizePosixAliasBody(body);
|
|
593
|
+
if (tokens.length === 0 || tokens[0] !== "claude") return null;
|
|
594
|
+
return { aliasName, args: tokens.slice(1) };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Tokenize the body of a POSIX alias inside the outer single quotes.
|
|
599
|
+
*
|
|
600
|
+
* Our writer emits three shapes inside the outer `'…'`:
|
|
601
|
+
* 1. Bare tokens (matching POSIX_BARE) — emitted as-is.
|
|
602
|
+
* 2. Quoted literals: `'\''text without single quotes'\''` — these survive
|
|
603
|
+
* the outer single quotes by closing them, emitting a literal `'`,
|
|
604
|
+
* single-quoting the inner content, then a literal `'`, and reopening.
|
|
605
|
+
* 3. Substitution embeds: `'"$(cmd)"'` — close outer ', open ", embed,
|
|
606
|
+
* close ", reopen outer '. Result is one shell arg with the
|
|
607
|
+
* substitution that fires on alias expansion.
|
|
608
|
+
*
|
|
609
|
+
* Reading the body left-to-right, we expect to see:
|
|
610
|
+
* - bare chars (collect into the current token's value)
|
|
611
|
+
* - whitespace (delimits tokens)
|
|
612
|
+
* - `'\''<text>'\''` (collect inner text verbatim into the current token)
|
|
613
|
+
* - `'"<sub>"'` (record as a raw substitution and unparse it back into
|
|
614
|
+
* the closest matching template token via `unparseSubstitution`)
|
|
615
|
+
*
|
|
616
|
+
* Tokens emitted as `composite` segments (template values) re-tokenize into
|
|
617
|
+
* a single shell arg whose pieces are interleaved literal+raw. We rebuild
|
|
618
|
+
* those as `{token}` strings to match what the UI stores.
|
|
619
|
+
*/
|
|
620
|
+
export function tokenizePosixAliasBody(body: string): string[] {
|
|
621
|
+
const tokens: string[] = [];
|
|
622
|
+
let current = "";
|
|
623
|
+
let inToken = false;
|
|
624
|
+
let i = 0;
|
|
625
|
+
const len = body.length;
|
|
626
|
+
|
|
627
|
+
const flush = () => {
|
|
628
|
+
if (inToken) {
|
|
629
|
+
tokens.push(current);
|
|
630
|
+
current = "";
|
|
631
|
+
inToken = false;
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
while (i < len) {
|
|
636
|
+
const ch = body[i];
|
|
637
|
+
|
|
638
|
+
if (ch === " " || ch === "\t") {
|
|
639
|
+
flush();
|
|
640
|
+
i += 1;
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Quoted literal: '\''…'\''
|
|
645
|
+
if (body.startsWith(`'\\''`, i)) {
|
|
646
|
+
// Skip the opening sequence.
|
|
647
|
+
i += 4;
|
|
648
|
+
const end = body.indexOf(`'\\''`, i);
|
|
649
|
+
if (end === -1) return tokens; // malformed — bail, return what we have
|
|
650
|
+
current += body.slice(i, end);
|
|
651
|
+
inToken = true;
|
|
652
|
+
i = end + 4;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Substitution embed: '"<sub>"'
|
|
657
|
+
if (body.startsWith(`'"`, i)) {
|
|
658
|
+
const inner = i + 2;
|
|
659
|
+
const close = body.indexOf(`"'`, inner);
|
|
660
|
+
if (close === -1) return tokens;
|
|
661
|
+
const subCode = body.slice(inner, close);
|
|
662
|
+
const token = unparseSubstitution(subCode);
|
|
663
|
+
current += token;
|
|
664
|
+
inToken = true;
|
|
665
|
+
i = close + 2;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Bare character.
|
|
670
|
+
current += ch;
|
|
671
|
+
inToken = true;
|
|
672
|
+
i += 1;
|
|
673
|
+
}
|
|
674
|
+
flush();
|
|
675
|
+
return tokens;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Map a POSIX substitution code back to its template token, if one matches.
|
|
680
|
+
* Falls through to a `$(...)` literal when no token matches — that way we
|
|
681
|
+
* preserve hand-edited substitutions as opaque values rather than dropping
|
|
682
|
+
* them.
|
|
683
|
+
*
|
|
684
|
+
* Mirror of TOKEN_TO_SUB in the writer.
|
|
685
|
+
*/
|
|
686
|
+
function unparseSubstitution(posixCode: string): string {
|
|
687
|
+
switch (posixCode) {
|
|
688
|
+
case '$(basename "$PWD")':
|
|
689
|
+
return "{folder}";
|
|
690
|
+
case "$(date +%d)":
|
|
691
|
+
return "{day}";
|
|
692
|
+
case "$(date +%m)":
|
|
693
|
+
return "{month}";
|
|
694
|
+
case "$(date +%Y)":
|
|
695
|
+
return "{year}";
|
|
696
|
+
default:
|
|
697
|
+
// Unknown substitution: pass through the raw code so the user can see
|
|
698
|
+
// it in the UI and decide whether to keep it. The writer can't faithfully
|
|
699
|
+
// round-trip it, but at least we don't silently drop it.
|
|
700
|
+
return posixCode;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Apply parsed argv tokens to a FlagValue map. Walks the catalog in order,
|
|
706
|
+
* consuming tokens that match each flag's pattern. Tokens that don't match
|
|
707
|
+
* any flag are dropped silently (e.g. the user hand-added a flag we don't
|
|
708
|
+
* know about). Returns a fresh `Record<flagId, FlagValue>` suitable for
|
|
709
|
+
* splicing into an AliasConfig.
|
|
710
|
+
*
|
|
711
|
+
* Ordering: the writer emits flags in catalog order, so a left-to-right
|
|
712
|
+
* scan is sufficient. We pre-index by flag string for `--foo` lookups.
|
|
713
|
+
*/
|
|
714
|
+
export function argsToFlagValues(args: string[]): Record<string, FlagValue> {
|
|
715
|
+
// Index every flag by its primary `flag` string AND its triStateOff variant.
|
|
716
|
+
const byFlag = new Map<string, { flag: AliasFlag; off?: boolean }>();
|
|
717
|
+
for (const flag of ALIAS_FLAGS) {
|
|
718
|
+
byFlag.set(flag.flag, { flag, off: false });
|
|
719
|
+
if (flag.triStateOff) {
|
|
720
|
+
byFlag.set(flag.triStateOff, { flag, off: true });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const out: Record<string, FlagValue> = {};
|
|
725
|
+
let i = 0;
|
|
726
|
+
while (i < args.length) {
|
|
727
|
+
const tok = args[i];
|
|
728
|
+
const entry = byFlag.get(tok);
|
|
729
|
+
if (!entry) {
|
|
730
|
+
// Unknown flag — skip this token AND its likely value, if next looks
|
|
731
|
+
// like a value (doesn't start with --). This is a best-effort skip
|
|
732
|
+
// so unknown flag pairs don't cascade-corrupt the rest of the parse.
|
|
733
|
+
i += 1;
|
|
734
|
+
if (i < args.length && !args[i].startsWith("-")) i += 1;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const { flag, off } = entry;
|
|
738
|
+
const peek = (): string | undefined => args[i + 1];
|
|
739
|
+
const takeValue = (): string => {
|
|
740
|
+
const v = args[i + 1];
|
|
741
|
+
i += 2;
|
|
742
|
+
return v;
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
switch (flag.kind) {
|
|
746
|
+
case "boolean":
|
|
747
|
+
out[flag.id] = { kind: "boolean", enabled: true };
|
|
748
|
+
i += 1;
|
|
749
|
+
break;
|
|
750
|
+
case "tri-state":
|
|
751
|
+
out[flag.id] = { kind: "tri-state", state: off ? "off" : "on" };
|
|
752
|
+
i += 1;
|
|
753
|
+
break;
|
|
754
|
+
case "select": {
|
|
755
|
+
// The writer emits `--flag` alone for "bare" empty value, or
|
|
756
|
+
// `--flag <value>` for a selection. We can't tell without peeking
|
|
757
|
+
// at whether the next token is a known value.
|
|
758
|
+
const opts = flag.options ?? [];
|
|
759
|
+
const next = peek();
|
|
760
|
+
const valid = next !== undefined && opts.some((o) => o.value === next);
|
|
761
|
+
if (valid) {
|
|
762
|
+
out[flag.id] = { kind: "select", enabled: true, value: takeValue() };
|
|
763
|
+
} else {
|
|
764
|
+
out[flag.id] = { kind: "select", enabled: true, value: "" };
|
|
765
|
+
i += 1;
|
|
766
|
+
}
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
case "text":
|
|
770
|
+
// `text` always emits a value; if there's no next token we treat as
|
|
771
|
+
// disabled (defensive).
|
|
772
|
+
if (peek() !== undefined) {
|
|
773
|
+
out[flag.id] = { kind: "text", enabled: true, value: takeValue() };
|
|
774
|
+
} else {
|
|
775
|
+
out[flag.id] = { kind: "text", enabled: false, value: "" };
|
|
776
|
+
i += 1;
|
|
777
|
+
}
|
|
778
|
+
break;
|
|
779
|
+
case "optional-text": {
|
|
780
|
+
// Optional-text: next token is the value if it doesn't look like a
|
|
781
|
+
// flag. We can't perfectly distinguish a deliberately-bare emission
|
|
782
|
+
// from one followed by an unrelated flag, but the writer's emission
|
|
783
|
+
// of bare is `flag.flag` alone with the next token being another
|
|
784
|
+
// catalog flag — so checking the lookup tells us.
|
|
785
|
+
const next = peek();
|
|
786
|
+
if (next === undefined || byFlag.has(next)) {
|
|
787
|
+
out[flag.id] = { kind: "optional-text", enabled: true, value: "" };
|
|
788
|
+
i += 1;
|
|
789
|
+
} else {
|
|
790
|
+
out[flag.id] = {
|
|
791
|
+
kind: "optional-text",
|
|
792
|
+
enabled: true,
|
|
793
|
+
value: takeValue(),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
case "text-list": {
|
|
799
|
+
// Each instance of the flag emits one value. Collect repeats.
|
|
800
|
+
const values: string[] = [];
|
|
801
|
+
// Consume the first pair.
|
|
802
|
+
if (peek() !== undefined) values.push(takeValue());
|
|
803
|
+
else i += 1;
|
|
804
|
+
// Consume additional pairs of the same flag.
|
|
805
|
+
while (i < args.length && args[i] === flag.flag) {
|
|
806
|
+
if (i + 1 < args.length) {
|
|
807
|
+
values.push(args[i + 1]);
|
|
808
|
+
i += 2;
|
|
809
|
+
} else {
|
|
810
|
+
i += 1;
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
out[flag.id] = {
|
|
815
|
+
kind: "text-list",
|
|
816
|
+
enabled: true,
|
|
817
|
+
values,
|
|
818
|
+
};
|
|
819
|
+
break;
|
|
820
|
+
}
|
|
821
|
+
case "multi-with-custom": {
|
|
822
|
+
// Either bare `--debug` (no filter) or `--debug a,b,c`.
|
|
823
|
+
const next = peek();
|
|
824
|
+
if (next === undefined || byFlag.has(next)) {
|
|
825
|
+
out[flag.id] = {
|
|
826
|
+
kind: "multi-with-custom",
|
|
827
|
+
enabled: true,
|
|
828
|
+
picked: [],
|
|
829
|
+
custom: [],
|
|
830
|
+
};
|
|
831
|
+
i += 1;
|
|
832
|
+
} else {
|
|
833
|
+
const tokens = takeValue()
|
|
834
|
+
.split(",")
|
|
835
|
+
.map((s) => s.trim())
|
|
836
|
+
.filter((s) => s.length > 0);
|
|
837
|
+
const picklist = new Set(flag.picklist ?? []);
|
|
838
|
+
const picked: string[] = [];
|
|
839
|
+
const custom: string[] = [];
|
|
840
|
+
for (const t of tokens) {
|
|
841
|
+
if (picklist.has(t)) picked.push(t);
|
|
842
|
+
else custom.push(t);
|
|
843
|
+
}
|
|
844
|
+
out[flag.id] = {
|
|
845
|
+
kind: "multi-with-custom",
|
|
846
|
+
enabled: true,
|
|
847
|
+
picked,
|
|
848
|
+
custom,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return out;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* High-level entry point: read shell rc text, parse the managed block, and
|
|
860
|
+
* return a populated flag map plus the parsed alias name. Returns null when
|
|
861
|
+
* there's no managed block to parse from.
|
|
862
|
+
*/
|
|
863
|
+
export function parseAliasFromRc(rcText: string): {
|
|
864
|
+
aliasName: string;
|
|
865
|
+
flags: Record<string, FlagValue>;
|
|
866
|
+
} | null {
|
|
867
|
+
const parsed = parseManagedBlock(rcText);
|
|
868
|
+
if (!parsed || !parsed.aliasName) return null;
|
|
869
|
+
return {
|
|
870
|
+
aliasName: parsed.aliasName,
|
|
871
|
+
flags: argsToFlagValues(parsed.args),
|
|
872
|
+
};
|
|
873
|
+
}
|