claudeup 6.2.1 → 6.3.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.
Files changed (150) hide show
  1. package/README.md +22 -0
  2. package/bin/claudeup.js +6 -1
  3. package/package.json +6 -6
  4. package/src/__tests__/alias-adopt.test.ts +331 -329
  5. package/src/__tests__/alias-parser.test.ts +384 -376
  6. package/src/__tests__/alias-shell-writer.test.ts +638 -625
  7. package/src/__tests__/alias-store.test.ts +143 -143
  8. package/src/__tests__/catalog-cache-store.test.ts +265 -265
  9. package/src/__tests__/catalog-notice.test.ts +123 -123
  10. package/src/__tests__/cli-apply-seams.test.ts +12 -8
  11. package/src/__tests__/cli-router.test.ts +132 -3
  12. package/src/__tests__/cli-update-view.test.ts +27 -8
  13. package/src/__tests__/community-staleness.test.ts +0 -2
  14. package/src/__tests__/component-badge.test.ts +4 -4
  15. package/src/__tests__/content-drift.test.ts +5 -1
  16. package/src/__tests__/conventions-integration.test.ts +774 -689
  17. package/src/__tests__/conventions-manager.test.ts +1216 -1152
  18. package/src/__tests__/doctor-bins.test.ts +14 -6
  19. package/src/__tests__/doctor.test.ts +438 -425
  20. package/src/__tests__/dual-write-prevention.test.ts +277 -282
  21. package/src/__tests__/enabled-not-installed.test.ts +71 -63
  22. package/src/__tests__/file-locking.test.ts +196 -196
  23. package/src/__tests__/gap-fill-versions.test.ts +318 -311
  24. package/src/__tests__/github-budget.test.ts +170 -164
  25. package/src/__tests__/gitignore-detector.test.ts +160 -152
  26. package/src/__tests__/gitignore-fixer.test.ts +285 -247
  27. package/src/__tests__/gitignore-prerun.test.ts +57 -57
  28. package/src/__tests__/gitignore-resolver.test.ts +173 -146
  29. package/src/__tests__/gitignore-service.test.ts +121 -119
  30. package/src/__tests__/install-command.test.ts +3 -3
  31. package/src/__tests__/install-plan.test.ts +13 -7
  32. package/src/__tests__/manifest.test.ts +16 -5
  33. package/src/__tests__/markdown-renderer.test.ts +0 -1
  34. package/src/__tests__/marketplace-badge.test.ts +111 -90
  35. package/src/__tests__/marketplace-version.test.ts +64 -61
  36. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  37. package/src/__tests__/plugin-requires.test.ts +18 -4
  38. package/src/__tests__/plugin-setup.test.ts +12 -12
  39. package/src/__tests__/profile-command.test.ts +1 -7
  40. package/src/__tests__/profile-materializer.test.ts +31 -8
  41. package/src/__tests__/profile-sync.test.ts +23 -5
  42. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  43. package/src/__tests__/resolve-executable.test.ts +1 -1
  44. package/src/__tests__/resolver.test.ts +38 -13
  45. package/src/__tests__/scope-action.test.ts +52 -50
  46. package/src/__tests__/scope-squares.test.tsx +4 -5
  47. package/src/__tests__/skill-install-files.test.ts +111 -103
  48. package/src/__tests__/symlink-manager.test.ts +27 -10
  49. package/src/__tests__/theme-env.test.ts +281 -0
  50. package/src/__tests__/theme-resolve.test.ts +380 -0
  51. package/src/__tests__/update-apply.test.ts +1 -3
  52. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  53. package/src/__tests__/version-snapshot.test.ts +183 -165
  54. package/src/cli/ansi.ts +23 -15
  55. package/src/cli/doctor.ts +10 -9
  56. package/src/cli/install.ts +3 -3
  57. package/src/cli/profile.ts +5 -4
  58. package/src/cli/prompt.ts +0 -1
  59. package/src/cli/router.ts +69 -2
  60. package/src/cli/update-view.ts +11 -6
  61. package/src/cli/update.ts +9 -4
  62. package/src/cli/upgrade.ts +6 -4
  63. package/src/data/alias-flags.ts +260 -260
  64. package/src/data/gitignore-defaults.ts +20 -20
  65. package/src/data/gitignore-reasons.ts +86 -92
  66. package/src/data/gitignore-templates.ts +14 -14
  67. package/src/data/predefined-profiles.ts +1 -2
  68. package/src/data/skill-repos.ts +43 -35
  69. package/src/main.tsx +29 -6
  70. package/src/opentui.d.ts +1 -3
  71. package/src/prerunner/index.ts +7 -8
  72. package/src/services/alias-settings.ts +20 -23
  73. package/src/services/alias-shell-writer.ts +800 -803
  74. package/src/services/alias-store.ts +77 -77
  75. package/src/services/claude-settings.ts +27 -18
  76. package/src/services/content-drift.ts +13 -3
  77. package/src/services/conventions-manager.ts +692 -617
  78. package/src/services/doctor.ts +464 -403
  79. package/src/services/gitignore-detector.ts +113 -103
  80. package/src/services/gitignore-fixer.ts +204 -189
  81. package/src/services/gitignore-prerun.ts +26 -26
  82. package/src/services/gitignore-resolver.ts +162 -144
  83. package/src/services/gitignore-service.ts +120 -117
  84. package/src/services/install-plan.ts +4 -4
  85. package/src/services/manifest.ts +1 -1
  86. package/src/services/marketplace-catalog-git.ts +5 -2
  87. package/src/services/marketplace-fetcher.ts +241 -235
  88. package/src/services/plugin-manager.ts +17 -20
  89. package/src/services/plugin-mcp-config.ts +2 -2
  90. package/src/services/plugin-requires.ts +9 -6
  91. package/src/services/plugin-setup.ts +7 -9
  92. package/src/services/plugin-version-check.ts +4 -4
  93. package/src/services/profile-materializer.ts +9 -5
  94. package/src/services/profile-sync.ts +12 -4
  95. package/src/services/profiles.ts +3 -3
  96. package/src/services/resolver.ts +10 -6
  97. package/src/services/settings-manager.ts +82 -46
  98. package/src/services/skills-manager.ts +11 -9
  99. package/src/services/symlink-manager.ts +2 -5
  100. package/src/services/toolchain.ts +3 -3
  101. package/src/services/update-cache.ts +1 -1
  102. package/src/services/update-engine.ts +4 -4
  103. package/src/services/update-plan.ts +9 -11
  104. package/src/services/version-snapshot.ts +88 -88
  105. package/src/types/gitignore.ts +28 -28
  106. package/src/ui/App.tsx +36 -36
  107. package/src/ui/adapters/settingsAdapter.ts +2 -2
  108. package/src/ui/components/CategoryHeader.tsx +1 -1
  109. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  110. package/src/ui/components/ScopeIndicator.tsx +3 -1
  111. package/src/ui/components/ScrollableList.tsx +2 -1
  112. package/src/ui/components/StyledText.tsx +1 -1
  113. package/src/ui/components/layout/FooterHints.tsx +5 -3
  114. package/src/ui/components/layout/Panel.tsx +2 -2
  115. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  116. package/src/ui/components/modals/InputModal.tsx +7 -1
  117. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  118. package/src/ui/components/modals/SelectModal.tsx +1 -1
  119. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  120. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  121. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  122. package/src/ui/hooks/index.ts +0 -1
  123. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  124. package/src/ui/hooks/useMismatchModal.ts +4 -10
  125. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  126. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  127. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  128. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  129. package/src/ui/renderers/profileRenderers.tsx +9 -8
  130. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  131. package/src/ui/renderers/skillRenderers.tsx +14 -14
  132. package/src/ui/screens/AliasScreen.tsx +31 -34
  133. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  134. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  135. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  136. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  137. package/src/ui/screens/McpScreen.tsx +10 -10
  138. package/src/ui/screens/PluginsScreen.tsx +17 -21
  139. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  140. package/src/ui/screens/SkillsScreen.tsx +16 -14
  141. package/src/ui/state/AppContext.tsx +4 -8
  142. package/src/ui/state/DimensionsContext.tsx +3 -2
  143. package/src/ui/theme-mode.ts +20 -38
  144. package/src/ui/theme-resolve.ts +148 -0
  145. package/src/utils/clipboard.ts +5 -5
  146. package/src/utils/command-utils.ts +2 -2
  147. package/src/utils/config-dir.ts +1 -3
  148. package/src/utils/file-locking.ts +88 -86
  149. package/src/ui/hooks/useAsyncData.ts +0 -127
  150. package/src/ui/state/AnimationContext.tsx +0 -76
@@ -7,34 +7,34 @@
7
7
  * idempotently using sentinel comment markers.
8
8
  */
9
9
 
10
- import { readFile, writeFile, stat } from "node:fs/promises";
11
10
  import { existsSync } from "node:fs";
12
- import { join } from "node:path";
11
+ import { readFile, stat, writeFile } from "node:fs/promises";
13
12
  import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
14
  import {
15
- ALIAS_FLAGS,
16
- getFlagById,
17
- type AliasFlag,
15
+ ALIAS_FLAGS,
16
+ type AliasFlag,
17
+ getFlagById,
18
18
  } from "../data/alias-flags.js";
19
19
  import {
20
- CHANNELS_FLAG_ID,
21
- defaultValueFor,
22
- derivedChannelValues,
23
- listValues,
24
- type AliasConfig,
25
- type FlagValue,
20
+ type AliasConfig,
21
+ CHANNELS_FLAG_ID,
22
+ type FlagValue,
23
+ defaultValueFor,
24
+ derivedChannelValues,
25
+ listValues,
26
26
  } from "./alias-store.js";
27
27
 
28
28
  export type ShellKind = "zsh" | "bash" | "fish";
29
29
 
30
30
  export interface ShellTarget {
31
- kind: ShellKind;
32
- /** Absolute path to the rc file. */
33
- path: string;
34
- /** True if the file currently exists on disk. */
35
- exists: boolean;
36
- /** True if `$SHELL` points at this shell — the default write target. */
37
- isDefault: boolean;
31
+ kind: ShellKind;
32
+ /** Absolute path to the rc file. */
33
+ path: string;
34
+ /** True if the file currently exists on disk. */
35
+ exists: boolean;
36
+ /** True if `$SHELL` points at this shell — the default write target. */
37
+ isDefault: boolean;
38
38
  }
39
39
 
40
40
  const BLOCK_BEGIN = "# >>> claudeup managed alias >>>";
@@ -49,43 +49,43 @@ const BLOCK_END = "# <<< claudeup managed alias <<<";
49
49
  * default install on macOS / Linux.
50
50
  */
51
51
  export async function detectShells(
52
- home: string = homedir(),
53
- shellEnv: string = process.env.SHELL ?? "",
52
+ home: string = homedir(),
53
+ shellEnv: string = process.env.SHELL ?? "",
54
54
  ): Promise<ShellTarget[]> {
55
- const candidates: Array<{ kind: ShellKind; path: string }> = [
56
- { kind: "zsh", path: join(home, ".zshrc") },
57
- { kind: "bash", path: join(home, ".bashrc") },
58
- { kind: "fish", path: join(home, ".config", "fish", "config.fish") },
59
- ];
60
-
61
- const defaultKind = inferDefaultShell(shellEnv);
62
-
63
- const results: ShellTarget[] = [];
64
- for (const c of candidates) {
65
- let exists = existsSync(c.path);
66
- if (exists) {
67
- // existsSync can lie on broken symlinks — check stat too
68
- try {
69
- await stat(c.path);
70
- } catch {
71
- exists = false;
72
- }
73
- }
74
- results.push({
75
- kind: c.kind,
76
- path: c.path,
77
- exists,
78
- isDefault: c.kind === defaultKind,
79
- });
80
- }
81
- return results;
55
+ const candidates: Array<{ kind: ShellKind; path: string }> = [
56
+ { kind: "zsh", path: join(home, ".zshrc") },
57
+ { kind: "bash", path: join(home, ".bashrc") },
58
+ { kind: "fish", path: join(home, ".config", "fish", "config.fish") },
59
+ ];
60
+
61
+ const defaultKind = inferDefaultShell(shellEnv);
62
+
63
+ const results: ShellTarget[] = [];
64
+ for (const c of candidates) {
65
+ let exists = existsSync(c.path);
66
+ if (exists) {
67
+ // existsSync can lie on broken symlinks — check stat too
68
+ try {
69
+ await stat(c.path);
70
+ } catch {
71
+ exists = false;
72
+ }
73
+ }
74
+ results.push({
75
+ kind: c.kind,
76
+ path: c.path,
77
+ exists,
78
+ isDefault: c.kind === defaultKind,
79
+ });
80
+ }
81
+ return results;
82
82
  }
83
83
 
84
84
  function inferDefaultShell(shellEnv: string): ShellKind | null {
85
- if (shellEnv.endsWith("/zsh")) return "zsh";
86
- if (shellEnv.endsWith("/bash")) return "bash";
87
- if (shellEnv.endsWith("/fish")) return "fish";
88
- return null;
85
+ if (shellEnv.endsWith("/zsh")) return "zsh";
86
+ if (shellEnv.endsWith("/bash")) return "bash";
87
+ if (shellEnv.endsWith("/fish")) return "fish";
88
+ return null;
89
89
  }
90
90
 
91
91
  // ─── Rendering ────────────────────────────────────────────────────────────
@@ -104,12 +104,12 @@ function inferDefaultShell(shellEnv: string): ShellKind | null {
104
104
  * with the appropriate close-outer / reopen-outer dance per shell.
105
105
  */
106
106
  export type Segment =
107
- | { kind: "literal"; text: string }
108
- | { kind: "composite"; parts: SegmentPart[] };
107
+ | { kind: "literal"; text: string }
108
+ | { kind: "composite"; parts: SegmentPart[] };
109
109
 
110
110
  export type SegmentPart =
111
- | { kind: "literal"; text: string }
112
- | { kind: "raw"; posix: string; fish: string };
111
+ | { kind: "literal"; text: string }
112
+ | { kind: "raw"; posix: string; fish: string };
113
113
 
114
114
  /**
115
115
  * Build the alias body as a list of segments. Pure function.
@@ -121,36 +121,36 @@ export type SegmentPart =
121
121
  * - `requires` is enforced: a flag whose dependency is disabled is dropped.
122
122
  */
123
123
  export function renderArgs(config: AliasConfig): Segment[] {
124
- const out: Segment[] = [];
125
- const seenXor = new Set<string>();
126
-
127
- for (const flag of ALIAS_FLAGS) {
128
- if (flag.requires) {
129
- const dep = config.flags[flag.requires];
130
- if (!isFlagEnabled(dep)) continue;
131
- }
132
- if (flag.xorGroup) {
133
- if (seenXor.has(flag.xorGroup)) continue;
134
- }
135
- const value = config.flags[flag.id];
136
- if (!value) continue;
137
-
138
- // `--channels` is the union (own ∪ dev-load-if-enabled), computed here at
139
- // render time — the union is NOT stored, so own/derived stay distinct in
140
- // the UI. A dev-loaded channel must run, so derived values force `--channels`
141
- // to emit even when the user's own channels list is disabled/empty.
142
- const renderValue =
143
- flag.id === CHANNELS_FLAG_ID
144
- ? channelsUnionValue(config.flags, value)
145
- : value;
146
-
147
- const rendered = renderFlag(flag, renderValue);
148
- if (rendered.length === 0) continue;
149
- out.push(...rendered);
150
- if (flag.xorGroup) seenXor.add(flag.xorGroup);
151
- }
152
-
153
- return out;
124
+ const out: Segment[] = [];
125
+ const seenXor = new Set<string>();
126
+
127
+ for (const flag of ALIAS_FLAGS) {
128
+ if (flag.requires) {
129
+ const dep = config.flags[flag.requires];
130
+ if (!isFlagEnabled(dep)) continue;
131
+ }
132
+ if (flag.xorGroup) {
133
+ if (seenXor.has(flag.xorGroup)) continue;
134
+ }
135
+ const value = config.flags[flag.id];
136
+ if (!value) continue;
137
+
138
+ // `--channels` is the union (own ∪ dev-load-if-enabled), computed here at
139
+ // render time — the union is NOT stored, so own/derived stay distinct in
140
+ // the UI. A dev-loaded channel must run, so derived values force `--channels`
141
+ // to emit even when the user's own channels list is disabled/empty.
142
+ const renderValue =
143
+ flag.id === CHANNELS_FLAG_ID
144
+ ? channelsUnionValue(config.flags, value)
145
+ : value;
146
+
147
+ const rendered = renderFlag(flag, renderValue);
148
+ if (rendered.length === 0) continue;
149
+ out.push(...rendered);
150
+ if (flag.xorGroup) seenXor.add(flag.xorGroup);
151
+ }
152
+
153
+ return out;
154
154
  }
155
155
 
156
156
  /**
@@ -160,32 +160,32 @@ export function renderArgs(config: AliasConfig): Segment[] {
160
160
  * where the actual shell substitution doesn't matter.
161
161
  */
162
162
  export function renderArgsAsTokens(config: AliasConfig): string[] {
163
- return renderArgs(config).map((seg) => {
164
- if (seg.kind === "literal") return seg.text;
165
- return seg.parts
166
- .map((p) => (p.kind === "literal" ? p.text : `<${p.posix}>`))
167
- .join("");
168
- });
163
+ return renderArgs(config).map((seg) => {
164
+ if (seg.kind === "literal") return seg.text;
165
+ return seg.parts
166
+ .map((p) => (p.kind === "literal" ? p.text : `<${p.posix}>`))
167
+ .join("");
168
+ });
169
169
  }
170
170
 
171
171
  function isFlagEnabled(value: FlagValue | undefined): boolean {
172
- if (!value) return false;
173
- switch (value.kind) {
174
- case "boolean":
175
- return value.enabled;
176
- case "tri-state":
177
- return value.state !== "unset";
178
- case "select":
179
- case "text":
180
- case "optional-text":
181
- case "text-list":
182
- case "multi-with-custom":
183
- return value.enabled;
184
- }
172
+ if (!value) return false;
173
+ switch (value.kind) {
174
+ case "boolean":
175
+ return value.enabled;
176
+ case "tri-state":
177
+ return value.state !== "unset";
178
+ case "select":
179
+ case "text":
180
+ case "optional-text":
181
+ case "text-list":
182
+ case "multi-with-custom":
183
+ return value.enabled;
184
+ }
185
185
  }
186
186
 
187
187
  function lit(text: string): Segment {
188
- return { kind: "literal", text };
188
+ return { kind: "literal", text };
189
189
  }
190
190
 
191
191
  /**
@@ -193,11 +193,11 @@ function lit(text: string): Segment {
193
193
  * otherwise return it as a plain literal.
194
194
  */
195
195
  function templatedSegment(value: string): Segment {
196
- const parts = expandTemplateValue(value);
197
- if (parts.length === 1 && parts[0].kind === "literal") {
198
- return { kind: "literal", text: parts[0].text };
199
- }
200
- return { kind: "composite", parts };
196
+ const parts = expandTemplateValue(value);
197
+ if (parts.length === 1 && parts[0].kind === "literal") {
198
+ return { kind: "literal", text: parts[0].text };
199
+ }
200
+ return { kind: "composite", parts };
201
201
  }
202
202
 
203
203
  /**
@@ -209,66 +209,62 @@ function templatedSegment(value: string): Segment {
209
209
  * is never written back to `channels.values`, so provenance is preserved.
210
210
  */
211
211
  function channelsUnionValue(
212
- flags: Record<string, FlagValue>,
213
- own: FlagValue,
212
+ flags: Record<string, FlagValue>,
213
+ own: FlagValue,
214
214
  ): FlagValue {
215
- const ownValues = own.kind === "text-list" && own.enabled
216
- ? listValues(own)
217
- : [];
218
- const derived = derivedChannelValues(flags);
219
- const merged = dedupe([...ownValues, ...derived]).filter((v) => v.length > 0);
220
- return { kind: "text-list", enabled: merged.length > 0, values: merged };
215
+ const ownValues =
216
+ own.kind === "text-list" && own.enabled ? listValues(own) : [];
217
+ const derived = derivedChannelValues(flags);
218
+ const merged = dedupe([...ownValues, ...derived]).filter((v) => v.length > 0);
219
+ return { kind: "text-list", enabled: merged.length > 0, values: merged };
221
220
  }
222
221
 
223
222
  function renderFlag(flag: AliasFlag, value: FlagValue): Segment[] {
224
- switch (value.kind) {
225
- case "boolean":
226
- return value.enabled ? [lit(flag.flag)] : [];
227
- case "tri-state":
228
- if (value.state === "on") return [lit(flag.flag)];
229
- if (value.state === "off" && flag.triStateOff)
230
- return [lit(flag.triStateOff)];
231
- return [];
232
- case "select":
233
- if (!value.enabled) return [];
234
- // Empty value means "bare flag, default variant" (e.g. `--tmux` alone).
235
- return value.value === ""
236
- ? [lit(flag.flag)]
237
- : [lit(flag.flag), lit(value.value)];
238
- case "text":
239
- if (!value.enabled || !value.value) return [];
240
- return [
241
- lit(flag.flag),
242
- flag.templated ? templatedSegment(value.value) : lit(value.value),
243
- ];
244
- case "optional-text":
245
- if (!value.enabled) return [];
246
- if (!value.value) return [lit(flag.flag)];
247
- return [
248
- lit(flag.flag),
249
- flag.templated ? templatedSegment(value.value) : lit(value.value),
250
- ];
251
- case "text-list": {
252
- if (!value.enabled || value.values.length === 0) return [];
253
- const out: Segment[] = [];
254
- for (const v of value.values) {
255
- if (!v) continue;
256
- out.push(
257
- lit(flag.flag),
258
- flag.templated ? templatedSegment(v) : lit(v),
259
- );
260
- }
261
- return out;
262
- }
263
- case "multi-with-custom": {
264
- if (!value.enabled) return [];
265
- const tokens = dedupe([...value.picked, ...value.custom]).filter(
266
- (t) => t.length > 0,
267
- );
268
- if (tokens.length === 0) return [lit(flag.flag)];
269
- return [lit(flag.flag), lit(tokens.join(","))];
270
- }
271
- }
223
+ switch (value.kind) {
224
+ case "boolean":
225
+ return value.enabled ? [lit(flag.flag)] : [];
226
+ case "tri-state":
227
+ if (value.state === "on") return [lit(flag.flag)];
228
+ if (value.state === "off" && flag.triStateOff)
229
+ return [lit(flag.triStateOff)];
230
+ return [];
231
+ case "select":
232
+ if (!value.enabled) return [];
233
+ // Empty value means "bare flag, default variant" (e.g. `--tmux` alone).
234
+ return value.value === ""
235
+ ? [lit(flag.flag)]
236
+ : [lit(flag.flag), lit(value.value)];
237
+ case "text":
238
+ if (!value.enabled || !value.value) return [];
239
+ return [
240
+ lit(flag.flag),
241
+ flag.templated ? templatedSegment(value.value) : lit(value.value),
242
+ ];
243
+ case "optional-text":
244
+ if (!value.enabled) return [];
245
+ if (!value.value) return [lit(flag.flag)];
246
+ return [
247
+ lit(flag.flag),
248
+ flag.templated ? templatedSegment(value.value) : lit(value.value),
249
+ ];
250
+ case "text-list": {
251
+ if (!value.enabled || value.values.length === 0) return [];
252
+ const out: Segment[] = [];
253
+ for (const v of value.values) {
254
+ if (!v) continue;
255
+ out.push(lit(flag.flag), flag.templated ? templatedSegment(v) : lit(v));
256
+ }
257
+ return out;
258
+ }
259
+ case "multi-with-custom": {
260
+ if (!value.enabled) return [];
261
+ const tokens = dedupe([...value.picked, ...value.custom]).filter(
262
+ (t) => t.length > 0,
263
+ );
264
+ if (tokens.length === 0) return [lit(flag.flag)];
265
+ return [lit(flag.flag), lit(tokens.join(","))];
266
+ }
267
+ }
272
268
  }
273
269
 
274
270
  /**
@@ -288,13 +284,13 @@ function renderFlag(flag: AliasFlag, value: FlagValue): Segment[] {
288
284
  * subshell scope, not the outer shell's scope.
289
285
  */
290
286
  const TOKEN_TO_SUB: Record<string, { posix: string; fish: string }> = {
291
- "{folder}": {
292
- posix: '$(basename "$PWD")',
293
- fish: "(basename $PWD)",
294
- },
295
- "{day}": { posix: "$(date +%d)", fish: "(date +%d)" },
296
- "{month}": { posix: "$(date +%m)", fish: "(date +%m)" },
297
- "{year}": { posix: "$(date +%Y)", fish: "(date +%Y)" },
287
+ "{folder}": {
288
+ posix: '$(basename "$PWD")',
289
+ fish: "(basename $PWD)",
290
+ },
291
+ "{day}": { posix: "$(date +%d)", fish: "(date +%d)" },
292
+ "{month}": { posix: "$(date +%m)", fish: "(date +%m)" },
293
+ "{year}": { posix: "$(date +%Y)", fish: "(date +%Y)" },
298
294
  };
299
295
 
300
296
  const TEMPLATE_RE = /\{(folder|day|month|year)\}/g;
@@ -305,80 +301,80 @@ const TEMPLATE_RE = /\{(folder|day|month|year)\}/g;
305
301
  * a single literal part covering the full string.
306
302
  */
307
303
  export function expandTemplateValue(value: string): SegmentPart[] {
308
- const out: SegmentPart[] = [];
309
- let last = 0;
310
- for (const match of value.matchAll(TEMPLATE_RE)) {
311
- const start = match.index;
312
- if (start > last) {
313
- out.push({ kind: "literal", text: value.slice(last, start) });
314
- }
315
- const sub = TOKEN_TO_SUB[match[0]];
316
- if (sub) {
317
- out.push({ kind: "raw", posix: sub.posix, fish: sub.fish });
318
- }
319
- last = start + match[0].length;
320
- }
321
- if (last < value.length) {
322
- out.push({ kind: "literal", text: value.slice(last) });
323
- }
324
- if (out.length === 0) {
325
- out.push({ kind: "literal", text: value });
326
- }
327
- return out;
304
+ const out: SegmentPart[] = [];
305
+ let last = 0;
306
+ for (const match of value.matchAll(TEMPLATE_RE)) {
307
+ const start = match.index;
308
+ if (start > last) {
309
+ out.push({ kind: "literal", text: value.slice(last, start) });
310
+ }
311
+ const sub = TOKEN_TO_SUB[match[0]];
312
+ if (sub) {
313
+ out.push({ kind: "raw", posix: sub.posix, fish: sub.fish });
314
+ }
315
+ last = start + match[0].length;
316
+ }
317
+ if (last < value.length) {
318
+ out.push({ kind: "literal", text: value.slice(last) });
319
+ }
320
+ if (out.length === 0) {
321
+ out.push({ kind: "literal", text: value });
322
+ }
323
+ return out;
328
324
  }
329
325
 
330
326
  function dedupe(arr: string[]): string[] {
331
- return Array.from(new Set(arr));
327
+ return Array.from(new Set(arr));
332
328
  }
333
329
 
334
330
  /**
335
331
  * Fish quoting. Fish single quotes are simpler — only `\` and `'` need escapes.
336
332
  */
337
333
  function quoteFish(s: string): string {
338
- return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
334
+ return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
339
335
  }
340
336
 
341
337
  export interface RenderedAlias {
342
- shell: ShellKind;
343
- /** The full block including BEGIN/END markers, ending with a newline. */
344
- block: string;
338
+ shell: ShellKind;
339
+ /** The full block including BEGIN/END markers, ending with a newline. */
340
+ block: string;
345
341
  }
346
342
 
347
343
  export function renderAlias(
348
- config: AliasConfig,
349
- shell: ShellKind,
344
+ config: AliasConfig,
345
+ shell: ShellKind,
350
346
  ): RenderedAlias {
351
- const args = renderArgs(config);
352
- const name = config.aliasName;
353
- if (shell === "fish") {
354
- return { shell, block: renderFishBlock(name, args) };
355
- }
356
- return { shell, block: renderPosixBlock(name, args) };
347
+ const args = renderArgs(config);
348
+ const name = config.aliasName;
349
+ if (shell === "fish") {
350
+ return { shell, block: renderFishBlock(name, args) };
351
+ }
352
+ return { shell, block: renderPosixBlock(name, args) };
357
353
  }
358
354
 
359
355
  function renderPosixBlock(name: string, segments: Segment[]): string {
360
- // The alias body lives inside outer single quotes:
361
- // alias <name>='claude … '
362
- // Each segment becomes one shell-arg inside that body. Composite segments
363
- // (templated values) interleave literal text with double-quoted shell
364
- // substitutions via close-outer + double-quote + reopen-outer.
365
- const body =
366
- segments.length === 0
367
- ? ""
368
- : " " + segments.map(renderPosixSegment).join(" ");
369
- const line = `alias ${name}='claude${body}'`;
370
- return `${BLOCK_BEGIN}\n${line}\n${BLOCK_END}\n`;
356
+ // The alias body lives inside outer single quotes:
357
+ // alias <name>='claude … '
358
+ // Each segment becomes one shell-arg inside that body. Composite segments
359
+ // (templated values) interleave literal text with double-quoted shell
360
+ // substitutions via close-outer + double-quote + reopen-outer.
361
+ const body =
362
+ segments.length === 0
363
+ ? ""
364
+ : ` ${segments.map(renderPosixSegment).join(" ")}`;
365
+ const line = `alias ${name}='claude${body}'`;
366
+ return `${BLOCK_BEGIN}\n${line}\n${BLOCK_END}\n`;
371
367
  }
372
368
 
373
369
  function renderPosixSegment(seg: Segment): string {
374
- if (seg.kind === "literal") return quoteInsidePosixAlias(seg.text);
375
- return seg.parts
376
- .map((p) =>
377
- p.kind === "literal"
378
- ? quoteInsidePosixAlias(p.text)
379
- : embedPosixSubstitution(p.posix),
380
- )
381
- .join("");
370
+ if (seg.kind === "literal") return quoteInsidePosixAlias(seg.text);
371
+ return seg.parts
372
+ .map((p) =>
373
+ p.kind === "literal"
374
+ ? quoteInsidePosixAlias(p.text)
375
+ : embedPosixSubstitution(p.posix),
376
+ )
377
+ .join("");
382
378
  }
383
379
 
384
380
  /**
@@ -395,15 +391,15 @@ function renderPosixSegment(seg: Segment): string {
395
391
  * has its own quoting context inside `$(...)`.
396
392
  */
397
393
  function embedPosixSubstitution(posixCode: string): string {
398
- return `'"${posixCode}"'`;
394
+ return `'"${posixCode}"'`;
399
395
  }
400
396
 
401
397
  const POSIX_BARE = /^[A-Za-z0-9_\-=,:.\/!@%+]+$/;
402
398
 
403
399
  function quoteInsidePosixAlias(token: string): string {
404
- if (POSIX_BARE.test(token)) return token;
405
- // No internal single quotes guaranteed by upstream validation.
406
- return `'\\''${token}'\\''`;
400
+ if (POSIX_BARE.test(token)) return token;
401
+ // No internal single quotes guaranteed by upstream validation.
402
+ return `'\\''${token}'\\''`;
407
403
  }
408
404
 
409
405
  /**
@@ -416,38 +412,36 @@ function quoteInsidePosixAlias(token: string): string {
416
412
  * still be unrenderable, so we walk the parts.
417
413
  */
418
414
  export function findUnquotableTokens(segments: Segment[]): string[] {
419
- const out: string[] = [];
420
- for (const seg of segments) {
421
- if (seg.kind === "literal") {
422
- if (seg.text.includes("'")) out.push(seg.text);
423
- continue;
424
- }
425
- for (const p of seg.parts) {
426
- if (p.kind === "literal" && p.text.includes("'")) out.push(p.text);
427
- }
428
- }
429
- return out;
415
+ const out: string[] = [];
416
+ for (const seg of segments) {
417
+ if (seg.kind === "literal") {
418
+ if (seg.text.includes("'")) out.push(seg.text);
419
+ continue;
420
+ }
421
+ for (const p of seg.parts) {
422
+ if (p.kind === "literal" && p.text.includes("'")) out.push(p.text);
423
+ }
424
+ }
425
+ return out;
430
426
  }
431
427
 
432
428
  function renderFishBlock(name: string, segments: Segment[]): string {
433
- // Fish: `alias <name> 'claude --foo bar'` — outer single quotes, no `=`.
434
- const body =
435
- segments.length === 0
436
- ? ""
437
- : " " + segments.map(renderFishSegment).join(" ");
438
- const line = `alias ${name} 'claude${body}'`;
439
- return `${BLOCK_BEGIN}\n${line}\n${BLOCK_END}\n`;
429
+ // Fish: `alias <name> 'claude --foo bar'` — outer single quotes, no `=`.
430
+ const body =
431
+ segments.length === 0
432
+ ? ""
433
+ : ` ${segments.map(renderFishSegment).join(" ")}`;
434
+ const line = `alias ${name} 'claude${body}'`;
435
+ return `${BLOCK_BEGIN}\n${line}\n${BLOCK_END}\n`;
440
436
  }
441
437
 
442
438
  function renderFishSegment(seg: Segment): string {
443
- if (seg.kind === "literal") return quoteFish(seg.text);
444
- return seg.parts
445
- .map((p) =>
446
- p.kind === "literal"
447
- ? quoteFish(p.text)
448
- : embedFishSubstitution(p.fish),
449
- )
450
- .join("");
439
+ if (seg.kind === "literal") return quoteFish(seg.text);
440
+ return seg.parts
441
+ .map((p) =>
442
+ p.kind === "literal" ? quoteFish(p.text) : embedFishSubstitution(p.fish),
443
+ )
444
+ .join("");
451
445
  }
452
446
 
453
447
  /**
@@ -457,7 +451,7 @@ function renderFishSegment(seg: Segment): string {
457
451
  * time + literal-'.
458
452
  */
459
453
  function embedFishSubstitution(fishCode: string): string {
460
- return `'"${fishCode}"'`;
454
+ return `'"${fishCode}"'`;
461
455
  }
462
456
 
463
457
  // ─── Splicing ─────────────────────────────────────────────────────────────
@@ -467,83 +461,87 @@ function embedFishSubstitution(fishCode: string): string {
467
461
  * `block` to the end if no block is present. Pure function — no I/O.
468
462
  */
469
463
  export function spliceManagedBlock(existing: string, block: string): string {
470
- const beginIdx = existing.indexOf(BLOCK_BEGIN);
471
- if (beginIdx === -1) {
472
- if (existing.length === 0) return block;
473
- const sep = existing.endsWith("\n") ? "" : "\n";
474
- return existing + sep + "\n" + block;
475
- }
476
- const endIdx = existing.indexOf(BLOCK_END, beginIdx);
477
- if (endIdx === -1) {
478
- // Malformed block — replace from BEGIN to EOF to be safe.
479
- return existing.slice(0, beginIdx) + block;
480
- }
481
- // Consume the trailing newline of the END marker if present.
482
- let cutEnd = endIdx + BLOCK_END.length;
483
- if (existing[cutEnd] === "\n") cutEnd += 1;
484
- return existing.slice(0, beginIdx) + block + existing.slice(cutEnd);
464
+ const beginIdx = existing.indexOf(BLOCK_BEGIN);
465
+ if (beginIdx === -1) {
466
+ if (existing.length === 0) return block;
467
+ const sep = existing.endsWith("\n") ? "" : "\n";
468
+ return `${existing + sep}\n${block}`;
469
+ }
470
+ const endIdx = existing.indexOf(BLOCK_END, beginIdx);
471
+ if (endIdx === -1) {
472
+ // Malformed block — replace from BEGIN to EOF to be safe.
473
+ return existing.slice(0, beginIdx) + block;
474
+ }
475
+ // Consume the trailing newline of the END marker if present.
476
+ let cutEnd = endIdx + BLOCK_END.length;
477
+ if (existing[cutEnd] === "\n") cutEnd += 1;
478
+ return existing.slice(0, beginIdx) + block + existing.slice(cutEnd);
485
479
  }
486
480
 
487
481
  export interface WriteResult {
488
- shell: ShellKind;
489
- path: string;
490
- /** "created" if the file did not exist; "updated" otherwise. */
491
- action: "created" | "updated";
482
+ shell: ShellKind;
483
+ path: string;
484
+ /** "created" if the file did not exist; "updated" otherwise. */
485
+ action: "created" | "updated";
492
486
  }
493
487
 
494
488
  export interface WriteOptions {
495
- /**
496
- * Line range [start, end] (zero-based, inclusive) of a hand-written alias
497
- * to REPLACE in place with the managed block — the adoption path. Only
498
- * honored when the file has no managed block yet (a first write after
499
- * adoption); subsequent writes find the markers and ignore this. Guarantees
500
- * the adopted line is removed so no duplicate alias survives.
501
- */
502
- adoptLineRange?: [number, number];
489
+ /**
490
+ * Line range [start, end] (zero-based, inclusive) of a hand-written alias
491
+ * to REPLACE in place with the managed block — the adoption path. Only
492
+ * honored when the file has no managed block yet (a first write after
493
+ * adoption); subsequent writes find the markers and ignore this. Guarantees
494
+ * the adopted line is removed so no duplicate alias survives.
495
+ */
496
+ adoptLineRange?: [number, number];
503
497
  }
504
498
 
505
499
  export async function writeAliasToShell(
506
- config: AliasConfig,
507
- target: ShellTarget,
508
- options: WriteOptions = {},
500
+ config: AliasConfig,
501
+ target: ShellTarget,
502
+ options: WriteOptions = {},
509
503
  ): Promise<WriteResult> {
510
- const args = renderArgs(config);
511
- const unquotable = findUnquotableTokens(args);
512
- if (unquotable.length > 0) {
513
- throw new Error(
514
- `Cannot render alias: ${unquotable.length} value${
515
- unquotable.length === 1 ? "" : "s"
516
- } contain a single quote, which can't be embedded in a shell alias. Edit the offending value(s) and try again: ${unquotable
517
- .map((t) => JSON.stringify(t))
518
- .join(", ")}`,
519
- );
520
- }
521
- const rendered = renderAlias(config, target.kind);
522
- const existing = target.exists ? await readFile(target.path, "utf8") : "";
523
-
524
- // Adoption: when a line range was supplied AND the file has no managed block
525
- // yet, replace the adopted alias line in place — the managed block takes its
526
- // slot, so the original can't survive as a duplicate. Once a managed block
527
- // exists, marker-based splicing is position-independent and takes over.
528
- const hasBlock = existing.includes(BLOCK_BEGIN);
529
- const next =
530
- options.adoptLineRange && !hasBlock
531
- ? spliceManagedBlockAtRange(existing, rendered.block, options.adoptLineRange)
532
- : spliceManagedBlock(existing, rendered.block);
533
-
534
- await writeFile(target.path, next, "utf8");
535
- return {
536
- shell: target.kind,
537
- path: target.path,
538
- action: target.exists ? "updated" : "created",
539
- };
504
+ const args = renderArgs(config);
505
+ const unquotable = findUnquotableTokens(args);
506
+ if (unquotable.length > 0) {
507
+ throw new Error(
508
+ `Cannot render alias: ${unquotable.length} value${
509
+ unquotable.length === 1 ? "" : "s"
510
+ } contain a single quote, which can't be embedded in a shell alias. Edit the offending value(s) and try again: ${unquotable
511
+ .map((t) => JSON.stringify(t))
512
+ .join(", ")}`,
513
+ );
514
+ }
515
+ const rendered = renderAlias(config, target.kind);
516
+ const existing = target.exists ? await readFile(target.path, "utf8") : "";
517
+
518
+ // Adoption: when a line range was supplied AND the file has no managed block
519
+ // yet, replace the adopted alias line in place — the managed block takes its
520
+ // slot, so the original can't survive as a duplicate. Once a managed block
521
+ // exists, marker-based splicing is position-independent and takes over.
522
+ const hasBlock = existing.includes(BLOCK_BEGIN);
523
+ const next =
524
+ options.adoptLineRange && !hasBlock
525
+ ? spliceManagedBlockAtRange(
526
+ existing,
527
+ rendered.block,
528
+ options.adoptLineRange,
529
+ )
530
+ : spliceManagedBlock(existing, rendered.block);
531
+
532
+ await writeFile(target.path, next, "utf8");
533
+ return {
534
+ shell: target.kind,
535
+ path: target.path,
536
+ action: target.exists ? "updated" : "created",
537
+ };
540
538
  }
541
539
 
542
540
  // ─── Validation summary (UI helper) ───────────────────────────────────────
543
541
 
544
542
  export interface FlagValidationIssue {
545
- flagId: string;
546
- reason: string;
543
+ flagId: string;
544
+ reason: string;
547
545
  }
548
546
 
549
547
  /**
@@ -552,44 +550,44 @@ export interface FlagValidationIssue {
552
550
  * same rules independently — this helper is purely for display.
553
551
  */
554
552
  export function validateConfig(config: AliasConfig): FlagValidationIssue[] {
555
- const issues: FlagValidationIssue[] = [];
556
-
557
- // xor groups
558
- const groups = new Map<string, string[]>();
559
- for (const flag of ALIAS_FLAGS) {
560
- if (!flag.xorGroup) continue;
561
- if (!isFlagEnabled(config.flags[flag.id])) continue;
562
- const list = groups.get(flag.xorGroup) ?? [];
563
- list.push(flag.id);
564
- groups.set(flag.xorGroup, list);
565
- }
566
- for (const [group, ids] of groups) {
567
- if (ids.length > 1) {
568
- const winner = ids[0];
569
- const losers = ids.slice(1);
570
- for (const loser of losers) {
571
- issues.push({
572
- flagId: loser,
573
- reason: `Mutually exclusive with --${winner}; will be dropped on write.`,
574
- });
575
- }
576
- }
577
- }
578
-
579
- // requires
580
- for (const flag of ALIAS_FLAGS) {
581
- if (!flag.requires) continue;
582
- if (!isFlagEnabled(config.flags[flag.id])) continue;
583
- if (!isFlagEnabled(config.flags[flag.requires])) {
584
- const dep = getFlagById(flag.requires);
585
- issues.push({
586
- flagId: flag.id,
587
- reason: `Requires ${dep.flag}; will be dropped on write.`,
588
- });
589
- }
590
- }
591
-
592
- return issues;
553
+ const issues: FlagValidationIssue[] = [];
554
+
555
+ // xor groups
556
+ const groups = new Map<string, string[]>();
557
+ for (const flag of ALIAS_FLAGS) {
558
+ if (!flag.xorGroup) continue;
559
+ if (!isFlagEnabled(config.flags[flag.id])) continue;
560
+ const list = groups.get(flag.xorGroup) ?? [];
561
+ list.push(flag.id);
562
+ groups.set(flag.xorGroup, list);
563
+ }
564
+ for (const [group, ids] of groups) {
565
+ if (ids.length > 1) {
566
+ const winner = ids[0];
567
+ const losers = ids.slice(1);
568
+ for (const loser of losers) {
569
+ issues.push({
570
+ flagId: loser,
571
+ reason: `Mutually exclusive with --${winner}; will be dropped on write.`,
572
+ });
573
+ }
574
+ }
575
+ }
576
+
577
+ // requires
578
+ for (const flag of ALIAS_FLAGS) {
579
+ if (!flag.requires) continue;
580
+ if (!isFlagEnabled(config.flags[flag.id])) continue;
581
+ if (!isFlagEnabled(config.flags[flag.requires])) {
582
+ const dep = getFlagById(flag.requires);
583
+ issues.push({
584
+ flagId: flag.id,
585
+ reason: `Requires ${dep.flag}; will be dropped on write.`,
586
+ });
587
+ }
588
+ }
589
+
590
+ return issues;
593
591
  }
594
592
 
595
593
  // ─── Parser ──────────────────────────────────────────────────────────────
@@ -605,8 +603,8 @@ export function validateConfig(config: AliasConfig): FlagValidationIssue[] {
605
603
  // vast majority of users are on zsh.
606
604
 
607
605
  export interface ParseResult {
608
- aliasName: string | null;
609
- args: string[];
606
+ aliasName: string | null;
607
+ args: string[];
610
608
  }
611
609
 
612
610
  /**
@@ -618,21 +616,21 @@ export interface ParseResult {
618
616
  * on disk — start from defaults".
619
617
  */
620
618
  export function parseManagedBlock(rcText: string): ParseResult | null {
621
- const beginIdx = rcText.indexOf(BLOCK_BEGIN);
622
- if (beginIdx === -1) return null;
623
- const endIdx = rcText.indexOf(BLOCK_END, beginIdx);
624
- if (endIdx === -1) return null;
625
-
626
- // Lines inside the block, sans markers.
627
- const inner = rcText.slice(beginIdx + BLOCK_BEGIN.length, endIdx);
628
- // Find the `alias <name>=...` line (skip blank lines, comments, the markers).
629
- const aliasLine = inner
630
- .split("\n")
631
- .map((l) => l.trim())
632
- .find((l) => /^alias\s+[A-Za-z_][A-Za-z0-9_-]*\s*=/.test(l));
633
- if (!aliasLine) return null;
634
-
635
- return parseAliasLine(aliasLine);
619
+ const beginIdx = rcText.indexOf(BLOCK_BEGIN);
620
+ if (beginIdx === -1) return null;
621
+ const endIdx = rcText.indexOf(BLOCK_END, beginIdx);
622
+ if (endIdx === -1) return null;
623
+
624
+ // Lines inside the block, sans markers.
625
+ const inner = rcText.slice(beginIdx + BLOCK_BEGIN.length, endIdx);
626
+ // Find the `alias <name>=...` line (skip blank lines, comments, the markers).
627
+ const aliasLine = inner
628
+ .split("\n")
629
+ .map((l) => l.trim())
630
+ .find((l) => /^alias\s+[A-Za-z_][A-Za-z0-9_-]*\s*=/.test(l));
631
+ if (!aliasLine) return null;
632
+
633
+ return parseAliasLine(aliasLine);
636
634
  }
637
635
 
638
636
  const ALIAS_LINE_RE = /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*'(.*)'\s*$/;
@@ -643,13 +641,13 @@ const ALIAS_LINE_RE = /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*'(.*)'\s*$/;
643
641
  * scheme.
644
642
  */
645
643
  export function parseAliasLine(line: string): ParseResult | null {
646
- const match = ALIAS_LINE_RE.exec(line);
647
- if (!match) return null;
648
- const [, aliasName, body] = match;
649
- // First token is the command itself (claude). Drop it; we only want argv.
650
- const tokens = tokenizePosixAliasBody(body);
651
- if (tokens.length === 0 || tokens[0] !== "claude") return null;
652
- return { aliasName, args: tokens.slice(1) };
644
+ const match = ALIAS_LINE_RE.exec(line);
645
+ if (!match) return null;
646
+ const [, aliasName, body] = match;
647
+ // First token is the command itself (claude). Drop it; we only want argv.
648
+ const tokens = tokenizePosixAliasBody(body);
649
+ if (tokens.length === 0 || tokens[0] !== "claude") return null;
650
+ return { aliasName, args: tokens.slice(1) };
653
651
  }
654
652
 
655
653
  /**
@@ -676,61 +674,61 @@ export function parseAliasLine(line: string): ParseResult | null {
676
674
  * those as `{token}` strings to match what the UI stores.
677
675
  */
678
676
  export function tokenizePosixAliasBody(body: string): string[] {
679
- const tokens: string[] = [];
680
- let current = "";
681
- let inToken = false;
682
- let i = 0;
683
- const len = body.length;
684
-
685
- const flush = () => {
686
- if (inToken) {
687
- tokens.push(current);
688
- current = "";
689
- inToken = false;
690
- }
691
- };
692
-
693
- while (i < len) {
694
- const ch = body[i];
695
-
696
- if (ch === " " || ch === "\t") {
697
- flush();
698
- i += 1;
699
- continue;
700
- }
701
-
702
- // Quoted literal: '\''…'\''
703
- if (body.startsWith(`'\\''`, i)) {
704
- // Skip the opening sequence.
705
- i += 4;
706
- const end = body.indexOf(`'\\''`, i);
707
- if (end === -1) return tokens; // malformed — bail, return what we have
708
- current += body.slice(i, end);
709
- inToken = true;
710
- i = end + 4;
711
- continue;
712
- }
713
-
714
- // Substitution embed: '"<sub>"'
715
- if (body.startsWith(`'"`, i)) {
716
- const inner = i + 2;
717
- const close = body.indexOf(`"'`, inner);
718
- if (close === -1) return tokens;
719
- const subCode = body.slice(inner, close);
720
- const token = unparseSubstitution(subCode);
721
- current += token;
722
- inToken = true;
723
- i = close + 2;
724
- continue;
725
- }
726
-
727
- // Bare character.
728
- current += ch;
729
- inToken = true;
730
- i += 1;
731
- }
732
- flush();
733
- return tokens;
677
+ const tokens: string[] = [];
678
+ let current = "";
679
+ let inToken = false;
680
+ let i = 0;
681
+ const len = body.length;
682
+
683
+ const flush = () => {
684
+ if (inToken) {
685
+ tokens.push(current);
686
+ current = "";
687
+ inToken = false;
688
+ }
689
+ };
690
+
691
+ while (i < len) {
692
+ const ch = body[i];
693
+
694
+ if (ch === " " || ch === "\t") {
695
+ flush();
696
+ i += 1;
697
+ continue;
698
+ }
699
+
700
+ // Quoted literal: '\''…'\''
701
+ if (body.startsWith(`'\\''`, i)) {
702
+ // Skip the opening sequence.
703
+ i += 4;
704
+ const end = body.indexOf(`'\\''`, i);
705
+ if (end === -1) return tokens; // malformed — bail, return what we have
706
+ current += body.slice(i, end);
707
+ inToken = true;
708
+ i = end + 4;
709
+ continue;
710
+ }
711
+
712
+ // Substitution embed: '"<sub>"'
713
+ if (body.startsWith(`'"`, i)) {
714
+ const inner = i + 2;
715
+ const close = body.indexOf(`"'`, inner);
716
+ if (close === -1) return tokens;
717
+ const subCode = body.slice(inner, close);
718
+ const token = unparseSubstitution(subCode);
719
+ current += token;
720
+ inToken = true;
721
+ i = close + 2;
722
+ continue;
723
+ }
724
+
725
+ // Bare character.
726
+ current += ch;
727
+ inToken = true;
728
+ i += 1;
729
+ }
730
+ flush();
731
+ return tokens;
734
732
  }
735
733
 
736
734
  /**
@@ -742,21 +740,21 @@ export function tokenizePosixAliasBody(body: string): string[] {
742
740
  * Mirror of TOKEN_TO_SUB in the writer.
743
741
  */
744
742
  function unparseSubstitution(posixCode: string): string {
745
- switch (posixCode) {
746
- case '$(basename "$PWD")':
747
- return "{folder}";
748
- case "$(date +%d)":
749
- return "{day}";
750
- case "$(date +%m)":
751
- return "{month}";
752
- case "$(date +%Y)":
753
- return "{year}";
754
- default:
755
- // Unknown substitution: pass through the raw code so the user can see
756
- // it in the UI and decide whether to keep it. The writer can't faithfully
757
- // round-trip it, but at least we don't silently drop it.
758
- return posixCode;
759
- }
743
+ switch (posixCode) {
744
+ case '$(basename "$PWD")':
745
+ return "{folder}";
746
+ case "$(date +%d)":
747
+ return "{day}";
748
+ case "$(date +%m)":
749
+ return "{month}";
750
+ case "$(date +%Y)":
751
+ return "{year}";
752
+ default:
753
+ // Unknown substitution: pass through the raw code so the user can see
754
+ // it in the UI and decide whether to keep it. The writer can't faithfully
755
+ // round-trip it, but at least we don't silently drop it.
756
+ return posixCode;
757
+ }
760
758
  }
761
759
 
762
760
  /**
@@ -770,7 +768,7 @@ function unparseSubstitution(posixCode: string): string {
770
768
  * scan is sufficient. We pre-index by flag string for `--foo` lookups.
771
769
  */
772
770
  export function argsToFlagValues(args: string[]): Record<string, FlagValue> {
773
- return argsToFlagValuesWithLeftovers(args).flags;
771
+ return argsToFlagValuesWithLeftovers(args).flags;
774
772
  }
775
773
 
776
774
  /**
@@ -785,9 +783,9 @@ export function argsToFlagValues(args: string[]): Record<string, FlagValue> {
785
783
  * take ownership / do NOT remove their line".
786
784
  */
787
785
  export interface ParsedFlagsWithLeftovers {
788
- flags: Record<string, FlagValue>;
789
- /** argv tokens that matched no catalog flag (and so would be dropped). */
790
- leftovers: string[];
786
+ flags: Record<string, FlagValue>;
787
+ /** argv tokens that matched no catalog flag (and so would be dropped). */
788
+ leftovers: string[];
791
789
  }
792
790
 
793
791
  /**
@@ -797,174 +795,174 @@ export interface ParsedFlagsWithLeftovers {
797
795
  * full extent of what wouldn't round-trip.
798
796
  */
799
797
  export function argsToFlagValuesWithLeftovers(
800
- args: string[],
798
+ args: string[],
801
799
  ): ParsedFlagsWithLeftovers {
802
- // Index every flag by its primary `flag` string AND its triStateOff variant.
803
- const byFlag = new Map<string, { flag: AliasFlag; off?: boolean }>();
804
- for (const flag of ALIAS_FLAGS) {
805
- byFlag.set(flag.flag, { flag, off: false });
806
- if (flag.triStateOff) {
807
- byFlag.set(flag.triStateOff, { flag, off: true });
808
- }
809
- }
810
-
811
- const out: Record<string, FlagValue> = {};
812
- const leftovers: string[] = [];
813
- let i = 0;
814
- while (i < args.length) {
815
- const tok = args[i];
816
- const entry = byFlag.get(tok);
817
- if (!entry) {
818
- // Unknown token — record it. If the next token looks like its value
819
- // (doesn't start with `-`), consume and record that too so unknown
820
- // flag/value pairs don't cascade-corrupt the rest of the parse, and so
821
- // the adoption gate sees the full unrecognized span.
822
- leftovers.push(tok);
823
- i += 1;
824
- if (i < args.length && !args[i].startsWith("-")) {
825
- leftovers.push(args[i]);
826
- i += 1;
827
- }
828
- continue;
829
- }
830
- const { flag, off } = entry;
831
- const peek = (): string | undefined => args[i + 1];
832
- const takeValue = (): string => {
833
- const v = args[i + 1];
834
- i += 2;
835
- return v;
836
- };
837
-
838
- switch (flag.kind) {
839
- case "boolean":
840
- out[flag.id] = { kind: "boolean", enabled: true };
841
- i += 1;
842
- break;
843
- case "tri-state":
844
- out[flag.id] = { kind: "tri-state", state: off ? "off" : "on" };
845
- i += 1;
846
- break;
847
- case "select": {
848
- // The writer emits `--flag` alone for "bare" empty value, or
849
- // `--flag <value>` for a selection. We can't tell without peeking
850
- // at whether the next token is a known value.
851
- const opts = flag.options ?? [];
852
- const next = peek();
853
- const valid = next !== undefined && opts.some((o) => o.value === next);
854
- if (valid) {
855
- out[flag.id] = { kind: "select", enabled: true, value: takeValue() };
856
- } else {
857
- out[flag.id] = { kind: "select", enabled: true, value: "" };
858
- i += 1;
859
- }
860
- break;
861
- }
862
- case "text":
863
- // `text` always emits a value; if there's no next token we treat as
864
- // disabled (defensive).
865
- if (peek() !== undefined) {
866
- out[flag.id] = { kind: "text", enabled: true, value: takeValue() };
867
- } else {
868
- out[flag.id] = { kind: "text", enabled: false, value: "" };
869
- i += 1;
870
- }
871
- break;
872
- case "optional-text": {
873
- // Optional-text: next token is the value if it doesn't look like a
874
- // flag. We can't perfectly distinguish a deliberately-bare emission
875
- // from one followed by an unrelated flag, but the writer's emission
876
- // of bare is `flag.flag` alone with the next token being another
877
- // catalog flag — so checking the lookup tells us.
878
- const next = peek();
879
- if (next === undefined || byFlag.has(next)) {
880
- out[flag.id] = { kind: "optional-text", enabled: true, value: "" };
881
- i += 1;
882
- } else {
883
- out[flag.id] = {
884
- kind: "optional-text",
885
- enabled: true,
886
- value: takeValue(),
887
- };
888
- }
889
- break;
890
- }
891
- case "text-list": {
892
- // Each instance of the flag emits one value. Collect repeats.
893
- const values: string[] = [];
894
- // Consume the first pair.
895
- if (peek() !== undefined) values.push(takeValue());
896
- else i += 1;
897
- // Consume additional pairs of the same flag.
898
- while (i < args.length && args[i] === flag.flag) {
899
- if (i + 1 < args.length) {
900
- values.push(args[i + 1]);
901
- i += 2;
902
- } else {
903
- i += 1;
904
- break;
905
- }
906
- }
907
- out[flag.id] = {
908
- kind: "text-list",
909
- enabled: true,
910
- values,
911
- };
912
- break;
913
- }
914
- case "multi-with-custom": {
915
- // Either bare `--debug` (no filter) or `--debug a,b,c`.
916
- const next = peek();
917
- if (next === undefined || byFlag.has(next)) {
918
- out[flag.id] = {
919
- kind: "multi-with-custom",
920
- enabled: true,
921
- picked: [],
922
- custom: [],
923
- };
924
- i += 1;
925
- } else {
926
- const tokens = takeValue()
927
- .split(",")
928
- .map((s) => s.trim())
929
- .filter((s) => s.length > 0);
930
- const picklist = new Set(flag.picklist ?? []);
931
- const picked: string[] = [];
932
- const custom: string[] = [];
933
- for (const t of tokens) {
934
- if (picklist.has(t)) picked.push(t);
935
- else custom.push(t);
936
- }
937
- out[flag.id] = {
938
- kind: "multi-with-custom",
939
- enabled: true,
940
- picked,
941
- custom,
942
- };
943
- }
944
- break;
945
- }
946
- }
947
- }
948
-
949
- // Restore channels provenance: the writer emits `--channels` as
950
- // (own ∪ dev-load), so a naive parse folds derived values back into
951
- // channels.values. Subtract the dev-load values (when that flag was
952
- // emitted/enabled) so channels.values holds only the user's OWN channels —
953
- // the derived ones come from the dev-load flag at display/render time.
954
- const channels = out[CHANNELS_FLAG_ID];
955
- const derived = derivedChannelValues(out);
956
- if (channels && channels.kind === "text-list" && derived.length > 0) {
957
- const ownOnly = channels.values.filter((v) => !derived.includes(v));
958
- out[CHANNELS_FLAG_ID] = {
959
- kind: "text-list",
960
- // Keep enabled only if the user has their own channels; a channels list
961
- // that was purely derived shouldn't appear enabled after the subtraction.
962
- enabled: ownOnly.length > 0,
963
- values: ownOnly,
964
- };
965
- }
966
-
967
- return { flags: out, leftovers };
800
+ // Index every flag by its primary `flag` string AND its triStateOff variant.
801
+ const byFlag = new Map<string, { flag: AliasFlag; off?: boolean }>();
802
+ for (const flag of ALIAS_FLAGS) {
803
+ byFlag.set(flag.flag, { flag, off: false });
804
+ if (flag.triStateOff) {
805
+ byFlag.set(flag.triStateOff, { flag, off: true });
806
+ }
807
+ }
808
+
809
+ const out: Record<string, FlagValue> = {};
810
+ const leftovers: string[] = [];
811
+ let i = 0;
812
+ while (i < args.length) {
813
+ const tok = args[i];
814
+ const entry = byFlag.get(tok);
815
+ if (!entry) {
816
+ // Unknown token — record it. If the next token looks like its value
817
+ // (doesn't start with `-`), consume and record that too so unknown
818
+ // flag/value pairs don't cascade-corrupt the rest of the parse, and so
819
+ // the adoption gate sees the full unrecognized span.
820
+ leftovers.push(tok);
821
+ i += 1;
822
+ if (i < args.length && !args[i].startsWith("-")) {
823
+ leftovers.push(args[i]);
824
+ i += 1;
825
+ }
826
+ continue;
827
+ }
828
+ const { flag, off } = entry;
829
+ const peek = (): string | undefined => args[i + 1];
830
+ const takeValue = (): string => {
831
+ const v = args[i + 1];
832
+ i += 2;
833
+ return v;
834
+ };
835
+
836
+ switch (flag.kind) {
837
+ case "boolean":
838
+ out[flag.id] = { kind: "boolean", enabled: true };
839
+ i += 1;
840
+ break;
841
+ case "tri-state":
842
+ out[flag.id] = { kind: "tri-state", state: off ? "off" : "on" };
843
+ i += 1;
844
+ break;
845
+ case "select": {
846
+ // The writer emits `--flag` alone for "bare" empty value, or
847
+ // `--flag <value>` for a selection. We can't tell without peeking
848
+ // at whether the next token is a known value.
849
+ const opts = flag.options ?? [];
850
+ const next = peek();
851
+ const valid = next !== undefined && opts.some((o) => o.value === next);
852
+ if (valid) {
853
+ out[flag.id] = { kind: "select", enabled: true, value: takeValue() };
854
+ } else {
855
+ out[flag.id] = { kind: "select", enabled: true, value: "" };
856
+ i += 1;
857
+ }
858
+ break;
859
+ }
860
+ case "text":
861
+ // `text` always emits a value; if there's no next token we treat as
862
+ // disabled (defensive).
863
+ if (peek() !== undefined) {
864
+ out[flag.id] = { kind: "text", enabled: true, value: takeValue() };
865
+ } else {
866
+ out[flag.id] = { kind: "text", enabled: false, value: "" };
867
+ i += 1;
868
+ }
869
+ break;
870
+ case "optional-text": {
871
+ // Optional-text: next token is the value if it doesn't look like a
872
+ // flag. We can't perfectly distinguish a deliberately-bare emission
873
+ // from one followed by an unrelated flag, but the writer's emission
874
+ // of bare is `flag.flag` alone with the next token being another
875
+ // catalog flag — so checking the lookup tells us.
876
+ const next = peek();
877
+ if (next === undefined || byFlag.has(next)) {
878
+ out[flag.id] = { kind: "optional-text", enabled: true, value: "" };
879
+ i += 1;
880
+ } else {
881
+ out[flag.id] = {
882
+ kind: "optional-text",
883
+ enabled: true,
884
+ value: takeValue(),
885
+ };
886
+ }
887
+ break;
888
+ }
889
+ case "text-list": {
890
+ // Each instance of the flag emits one value. Collect repeats.
891
+ const values: string[] = [];
892
+ // Consume the first pair.
893
+ if (peek() !== undefined) values.push(takeValue());
894
+ else i += 1;
895
+ // Consume additional pairs of the same flag.
896
+ while (i < args.length && args[i] === flag.flag) {
897
+ if (i + 1 < args.length) {
898
+ values.push(args[i + 1]);
899
+ i += 2;
900
+ } else {
901
+ i += 1;
902
+ break;
903
+ }
904
+ }
905
+ out[flag.id] = {
906
+ kind: "text-list",
907
+ enabled: true,
908
+ values,
909
+ };
910
+ break;
911
+ }
912
+ case "multi-with-custom": {
913
+ // Either bare `--debug` (no filter) or `--debug a,b,c`.
914
+ const next = peek();
915
+ if (next === undefined || byFlag.has(next)) {
916
+ out[flag.id] = {
917
+ kind: "multi-with-custom",
918
+ enabled: true,
919
+ picked: [],
920
+ custom: [],
921
+ };
922
+ i += 1;
923
+ } else {
924
+ const tokens = takeValue()
925
+ .split(",")
926
+ .map((s) => s.trim())
927
+ .filter((s) => s.length > 0);
928
+ const picklist = new Set(flag.picklist ?? []);
929
+ const picked: string[] = [];
930
+ const custom: string[] = [];
931
+ for (const t of tokens) {
932
+ if (picklist.has(t)) picked.push(t);
933
+ else custom.push(t);
934
+ }
935
+ out[flag.id] = {
936
+ kind: "multi-with-custom",
937
+ enabled: true,
938
+ picked,
939
+ custom,
940
+ };
941
+ }
942
+ break;
943
+ }
944
+ }
945
+ }
946
+
947
+ // Restore channels provenance: the writer emits `--channels` as
948
+ // (own ∪ dev-load), so a naive parse folds derived values back into
949
+ // channels.values. Subtract the dev-load values (when that flag was
950
+ // emitted/enabled) so channels.values holds only the user's OWN channels —
951
+ // the derived ones come from the dev-load flag at display/render time.
952
+ const channels = out[CHANNELS_FLAG_ID];
953
+ const derived = derivedChannelValues(out);
954
+ if (channels && channels.kind === "text-list" && derived.length > 0) {
955
+ const ownOnly = channels.values.filter((v) => !derived.includes(v));
956
+ out[CHANNELS_FLAG_ID] = {
957
+ kind: "text-list",
958
+ // Keep enabled only if the user has their own channels; a channels list
959
+ // that was purely derived shouldn't appear enabled after the subtraction.
960
+ enabled: ownOnly.length > 0,
961
+ values: ownOnly,
962
+ };
963
+ }
964
+
965
+ return { flags: out, leftovers };
968
966
  }
969
967
 
970
968
  /**
@@ -973,15 +971,15 @@ export function argsToFlagValuesWithLeftovers(
973
971
  * there's no managed block to parse from.
974
972
  */
975
973
  export function parseAliasFromRc(rcText: string): {
976
- aliasName: string;
977
- flags: Record<string, FlagValue>;
974
+ aliasName: string;
975
+ flags: Record<string, FlagValue>;
978
976
  } | null {
979
- const parsed = parseManagedBlock(rcText);
980
- if (!parsed || !parsed.aliasName) return null;
981
- return {
982
- aliasName: parsed.aliasName,
983
- flags: argsToFlagValues(parsed.args),
984
- };
977
+ const parsed = parseManagedBlock(rcText);
978
+ if (!parsed || !parsed.aliasName) return null;
979
+ return {
980
+ aliasName: parsed.aliasName,
981
+ flags: argsToFlagValues(parsed.args),
982
+ };
985
983
  }
986
984
 
987
985
  // ─── Adoption (detect & offer to import a hand-written alias) ───────────────
@@ -1006,36 +1004,36 @@ export function parseAliasFromRc(rcText: string): {
1006
1004
  * A hand-written `claude`-wrapping alias found OUTSIDE the managed block.
1007
1005
  */
1008
1006
  export interface AdoptableAlias {
1009
- /** The alias name (e.g. `c`). */
1010
- name: string;
1011
- /** Parsed flag values from the alias body. */
1012
- flags: Record<string, FlagValue>;
1013
- /**
1014
- * Zero-based line indices [start, end] of the alias line in the rc file.
1015
- * Single-line today (start === end), but kept as a range so the splice
1016
- * helper has the exact span to replace.
1017
- */
1018
- lineRange: [number, number];
1019
- /** argv tokens that didn't map to any catalog flag (data we'd drop). */
1020
- leftovers: string[];
1021
- /**
1022
- * True when the alias round-trips exactly: leftovers empty AND the parsed
1023
- * flags survive a render→reparse fixpoint. Only a lossless alias may have
1024
- * its original line removed on adoption.
1025
- */
1026
- lossless: boolean;
1027
- /** The raw alias line, verbatim (for display / preservation). */
1028
- rawLine: string;
1029
- /**
1030
- * Other top-level `claude`-wrapping alias lines we found but did NOT pick.
1031
- * Surfaced so the UI can note "also found N other claude aliases" rather
1032
- * than silently choosing the first.
1033
- */
1034
- others: string[];
1007
+ /** The alias name (e.g. `c`). */
1008
+ name: string;
1009
+ /** Parsed flag values from the alias body. */
1010
+ flags: Record<string, FlagValue>;
1011
+ /**
1012
+ * Zero-based line indices [start, end] of the alias line in the rc file.
1013
+ * Single-line today (start === end), but kept as a range so the splice
1014
+ * helper has the exact span to replace.
1015
+ */
1016
+ lineRange: [number, number];
1017
+ /** argv tokens that didn't map to any catalog flag (data we'd drop). */
1018
+ leftovers: string[];
1019
+ /**
1020
+ * True when the alias round-trips exactly: leftovers empty AND the parsed
1021
+ * flags survive a render→reparse fixpoint. Only a lossless alias may have
1022
+ * its original line removed on adoption.
1023
+ */
1024
+ lossless: boolean;
1025
+ /** The raw alias line, verbatim (for display / preservation). */
1026
+ rawLine: string;
1027
+ /**
1028
+ * Other top-level `claude`-wrapping alias lines we found but did NOT pick.
1029
+ * Surfaced so the UI can note "also found N other claude aliases" rather
1030
+ * than silently choosing the first.
1031
+ */
1032
+ others: string[];
1035
1033
  }
1036
1034
 
1037
1035
  const ANY_ALIAS_LINE_RE =
1038
- /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(['"])(.*)\2\s*$/;
1036
+ /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(['"])(.*)\2\s*$/;
1039
1037
 
1040
1038
  /**
1041
1039
  * Tokenize a general (hand-written) POSIX alias body into argv strings.
@@ -1053,63 +1051,63 @@ const ANY_ALIAS_LINE_RE =
1053
1051
  * caller treats the whole alias as not-adoptable rather than guessing.
1054
1052
  */
1055
1053
  export function tokenizeGeneralAliasBody(body: string): string[] | null {
1056
- const tokens: string[] = [];
1057
- let current = "";
1058
- let inToken = false;
1059
- let i = 0;
1060
- const len = body.length;
1061
-
1062
- const flush = () => {
1063
- if (inToken) {
1064
- tokens.push(current);
1065
- current = "";
1066
- inToken = false;
1067
- }
1068
- };
1069
-
1070
- while (i < len) {
1071
- const ch = body[i];
1072
-
1073
- if (ch === " " || ch === "\t") {
1074
- flush();
1075
- i += 1;
1076
- continue;
1077
- }
1078
-
1079
- // Bail on dynamic content — we can't round-trip a variable/substitution.
1080
- if (ch === "$" || ch === "`") return null;
1081
-
1082
- if (ch === "'") {
1083
- // Single-quoted run: literal until the next single quote.
1084
- const end = body.indexOf("'", i + 1);
1085
- if (end === -1) return null; // unterminated
1086
- current += body.slice(i + 1, end);
1087
- inToken = true;
1088
- i = end + 1;
1089
- continue;
1090
- }
1091
-
1092
- if (ch === '"') {
1093
- // Double-quoted run: literal until the next double quote. We reject any
1094
- // `$` or backtick inside (caught at the top of the loop on the next
1095
- // pass only if unquoted — so scan the run explicitly here).
1096
- const end = body.indexOf('"', i + 1);
1097
- if (end === -1) return null; // unterminated
1098
- const inner = body.slice(i + 1, end);
1099
- if (inner.includes("$") || inner.includes("`")) return null;
1100
- current += inner;
1101
- inToken = true;
1102
- i = end + 1;
1103
- continue;
1104
- }
1105
-
1106
- // Bare character.
1107
- current += ch;
1108
- inToken = true;
1109
- i += 1;
1110
- }
1111
- flush();
1112
- return tokens;
1054
+ const tokens: string[] = [];
1055
+ let current = "";
1056
+ let inToken = false;
1057
+ let i = 0;
1058
+ const len = body.length;
1059
+
1060
+ const flush = () => {
1061
+ if (inToken) {
1062
+ tokens.push(current);
1063
+ current = "";
1064
+ inToken = false;
1065
+ }
1066
+ };
1067
+
1068
+ while (i < len) {
1069
+ const ch = body[i];
1070
+
1071
+ if (ch === " " || ch === "\t") {
1072
+ flush();
1073
+ i += 1;
1074
+ continue;
1075
+ }
1076
+
1077
+ // Bail on dynamic content — we can't round-trip a variable/substitution.
1078
+ if (ch === "$" || ch === "`") return null;
1079
+
1080
+ if (ch === "'") {
1081
+ // Single-quoted run: literal until the next single quote.
1082
+ const end = body.indexOf("'", i + 1);
1083
+ if (end === -1) return null; // unterminated
1084
+ current += body.slice(i + 1, end);
1085
+ inToken = true;
1086
+ i = end + 1;
1087
+ continue;
1088
+ }
1089
+
1090
+ if (ch === '"') {
1091
+ // Double-quoted run: literal until the next double quote. We reject any
1092
+ // `$` or backtick inside (caught at the top of the loop on the next
1093
+ // pass only if unquoted — so scan the run explicitly here).
1094
+ const end = body.indexOf('"', i + 1);
1095
+ if (end === -1) return null; // unterminated
1096
+ const inner = body.slice(i + 1, end);
1097
+ if (inner.includes("$") || inner.includes("`")) return null;
1098
+ current += inner;
1099
+ inToken = true;
1100
+ i = end + 1;
1101
+ continue;
1102
+ }
1103
+
1104
+ // Bare character.
1105
+ current += ch;
1106
+ inToken = true;
1107
+ i += 1;
1108
+ }
1109
+ flush();
1110
+ return tokens;
1113
1111
  }
1114
1112
 
1115
1113
  /**
@@ -1117,28 +1115,27 @@ export function tokenizeGeneralAliasBody(body: string): string[] | null {
1117
1115
  * AdoptableAlias (sans `lineRange`/`others`, which the scanner fills in), or
1118
1116
  * null when the line isn't a `claude`-wrapping alias we can tokenize.
1119
1117
  */
1120
- function parseAdoptableLine(line: string): Omit<
1121
- AdoptableAlias,
1122
- "lineRange" | "others"
1123
- > | null {
1124
- const match = ANY_ALIAS_LINE_RE.exec(line.trim());
1125
- if (!match) return null;
1126
- const [, name, , body] = match;
1127
- const tokens = tokenizeGeneralAliasBody(body);
1128
- if (!tokens || tokens.length === 0 || tokens[0] !== "claude") return null;
1129
-
1130
- const args = tokens.slice(1);
1131
- const { flags, leftovers } = argsToFlagValuesWithLeftovers(args);
1132
-
1133
- // Fixpoint: render the parsed flags back to tokens and re-parse. If the
1134
- // result differs, a token was mis-bucketed (e.g. a select value mistaken
1135
- // for bare). Only an exact fixpoint with no leftovers is lossless.
1136
- const config: AliasConfig = { aliasName: name, flags: withDefaults(flags) };
1137
- const reparsed = argsToFlagValues(renderArgsAsTokens(config));
1138
- const fixpoint = flagsDeepEqual(withDefaults(flags), withDefaults(reparsed));
1139
- const lossless = leftovers.length === 0 && fixpoint;
1140
-
1141
- return { name, flags, leftovers, lossless, rawLine: line };
1118
+ function parseAdoptableLine(
1119
+ line: string,
1120
+ ): Omit<AdoptableAlias, "lineRange" | "others"> | null {
1121
+ const match = ANY_ALIAS_LINE_RE.exec(line.trim());
1122
+ if (!match) return null;
1123
+ const [, name, , body] = match;
1124
+ const tokens = tokenizeGeneralAliasBody(body);
1125
+ if (!tokens || tokens.length === 0 || tokens[0] !== "claude") return null;
1126
+
1127
+ const args = tokens.slice(1);
1128
+ const { flags, leftovers } = argsToFlagValuesWithLeftovers(args);
1129
+
1130
+ // Fixpoint: render the parsed flags back to tokens and re-parse. If the
1131
+ // result differs, a token was mis-bucketed (e.g. a select value mistaken
1132
+ // for bare). Only an exact fixpoint with no leftovers is lossless.
1133
+ const config: AliasConfig = { aliasName: name, flags: withDefaults(flags) };
1134
+ const reparsed = argsToFlagValues(renderArgsAsTokens(config));
1135
+ const fixpoint = flagsDeepEqual(withDefaults(flags), withDefaults(reparsed));
1136
+ const lossless = leftovers.length === 0 && fixpoint;
1137
+
1138
+ return { name, flags, leftovers, lossless, rawLine: line };
1142
1139
  }
1143
1140
 
1144
1141
  /**
@@ -1155,41 +1152,41 @@ function parseAdoptableLine(line: string): Omit<
1155
1152
  * guard here too so the function is correct in isolation).
1156
1153
  */
1157
1154
  export function findAdoptableAlias(rcText: string): AdoptableAlias | null {
1158
- const lines = rcText.split("\n");
1159
-
1160
- // Compute the [begin, end] line span of the managed block, if present, so we
1161
- // can exclude any alias inside it.
1162
- let blockStart = -1;
1163
- let blockEnd = -1;
1164
- for (let i = 0; i < lines.length; i++) {
1165
- if (lines[i].includes(BLOCK_BEGIN)) blockStart = i;
1166
- else if (lines[i].includes(BLOCK_END)) {
1167
- blockEnd = i;
1168
- break;
1169
- }
1170
- }
1171
- const insideBlock = (i: number) =>
1172
- blockStart !== -1 && blockEnd !== -1 && i >= blockStart && i <= blockEnd;
1173
-
1174
- let picked: AdoptableAlias | null = null;
1175
- const others: string[] = [];
1176
-
1177
- for (let i = 0; i < lines.length; i++) {
1178
- const line = lines[i];
1179
- if (insideBlock(i)) continue;
1180
- // Top-level only: no leading whitespace.
1181
- if (/^\s/.test(line)) continue;
1182
- const parsed = parseAdoptableLine(line);
1183
- if (!parsed) continue;
1184
- if (picked === null) {
1185
- picked = { ...parsed, lineRange: [i, i], others: [] };
1186
- } else {
1187
- others.push(line.trim());
1188
- }
1189
- }
1190
-
1191
- if (picked) picked.others = others;
1192
- return picked;
1155
+ const lines = rcText.split("\n");
1156
+
1157
+ // Compute the [begin, end] line span of the managed block, if present, so we
1158
+ // can exclude any alias inside it.
1159
+ let blockStart = -1;
1160
+ let blockEnd = -1;
1161
+ for (let i = 0; i < lines.length; i++) {
1162
+ if (lines[i].includes(BLOCK_BEGIN)) blockStart = i;
1163
+ else if (lines[i].includes(BLOCK_END)) {
1164
+ blockEnd = i;
1165
+ break;
1166
+ }
1167
+ }
1168
+ const insideBlock = (i: number) =>
1169
+ blockStart !== -1 && blockEnd !== -1 && i >= blockStart && i <= blockEnd;
1170
+
1171
+ let picked: AdoptableAlias | null = null;
1172
+ const others: string[] = [];
1173
+
1174
+ for (let i = 0; i < lines.length; i++) {
1175
+ const line = lines[i];
1176
+ if (insideBlock(i)) continue;
1177
+ // Top-level only: no leading whitespace.
1178
+ if (/^\s/.test(line)) continue;
1179
+ const parsed = parseAdoptableLine(line);
1180
+ if (!parsed) continue;
1181
+ if (picked === null) {
1182
+ picked = { ...parsed, lineRange: [i, i], others: [] };
1183
+ } else {
1184
+ others.push(line.trim());
1185
+ }
1186
+ }
1187
+
1188
+ if (picked) picked.others = others;
1189
+ return picked;
1193
1190
  }
1194
1191
 
1195
1192
  /**
@@ -1200,48 +1197,48 @@ export function findAdoptableAlias(rcText: string): AdoptableAlias | null {
1200
1197
  * (append) when the range is out of bounds.
1201
1198
  */
1202
1199
  export function spliceManagedBlockAtRange(
1203
- existing: string,
1204
- block: string,
1205
- range: [number, number],
1200
+ existing: string,
1201
+ block: string,
1202
+ range: [number, number],
1206
1203
  ): string {
1207
- const lines = existing.split("\n");
1208
- const [start, end] = range;
1209
- if (start < 0 || end >= lines.length || start > end) {
1210
- return spliceManagedBlock(existing, block);
1211
- }
1212
- // `block` ends with a newline; splice it as its own line(s) where the old
1213
- // alias line was. Rejoin and drop the duplicate trailing newline `block`
1214
- // would introduce when followed by more lines.
1215
- const before = lines.slice(0, start);
1216
- const after = lines.slice(end + 1);
1217
- const blockLines = block.replace(/\n$/, "").split("\n");
1218
- return [...before, ...blockLines, ...after].join("\n");
1204
+ const lines = existing.split("\n");
1205
+ const [start, end] = range;
1206
+ if (start < 0 || end >= lines.length || start > end) {
1207
+ return spliceManagedBlock(existing, block);
1208
+ }
1209
+ // `block` ends with a newline; splice it as its own line(s) where the old
1210
+ // alias line was. Rejoin and drop the duplicate trailing newline `block`
1211
+ // would introduce when followed by more lines.
1212
+ const before = lines.slice(0, start);
1213
+ const after = lines.slice(end + 1);
1214
+ const blockLines = block.replace(/\n$/, "").split("\n");
1215
+ return [...before, ...blockLines, ...after].join("\n");
1219
1216
  }
1220
1217
 
1221
1218
  /** Fill any catalog flags missing from a partial map with their defaults. */
1222
1219
  function withDefaults(
1223
- partial: Record<string, FlagValue>,
1220
+ partial: Record<string, FlagValue>,
1224
1221
  ): Record<string, FlagValue> {
1225
- const out: Record<string, FlagValue> = {};
1226
- for (const flag of ALIAS_FLAGS) {
1227
- out[flag.id] = partial[flag.id] ?? defaultValueFor(flag);
1228
- }
1229
- return out;
1222
+ const out: Record<string, FlagValue> = {};
1223
+ for (const flag of ALIAS_FLAGS) {
1224
+ out[flag.id] = partial[flag.id] ?? defaultValueFor(flag);
1225
+ }
1226
+ return out;
1230
1227
  }
1231
1228
 
1232
1229
  /** Deep-equal two flag maps via per-key JSON compare (small maps). */
1233
1230
  function flagsDeepEqual(
1234
- a: Record<string, FlagValue>,
1235
- b: Record<string, FlagValue>,
1231
+ a: Record<string, FlagValue>,
1232
+ b: Record<string, FlagValue>,
1236
1233
  ): boolean {
1237
- const aKeys = Object.keys(a).sort();
1238
- const bKeys = Object.keys(b).sort();
1239
- if (aKeys.length !== bKeys.length) return false;
1240
- for (let i = 0; i < aKeys.length; i++) {
1241
- if (aKeys[i] !== bKeys[i]) return false;
1242
- }
1243
- for (const k of aKeys) {
1244
- if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
1245
- }
1246
- return true;
1234
+ const aKeys = Object.keys(a).sort();
1235
+ const bKeys = Object.keys(b).sort();
1236
+ if (aKeys.length !== bKeys.length) return false;
1237
+ for (let i = 0; i < aKeys.length; i++) {
1238
+ if (aKeys[i] !== bKeys[i]) return false;
1239
+ }
1240
+ for (const k of aKeys) {
1241
+ if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
1242
+ }
1243
+ return true;
1247
1244
  }