claudeup 6.2.0 → 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.
- package/README.md +22 -0
- package/bin/claudeup.js +6 -1
- package/package.json +6 -6
- package/src/__tests__/alias-adopt.test.ts +331 -329
- package/src/__tests__/alias-parser.test.ts +384 -376
- package/src/__tests__/alias-shell-writer.test.ts +638 -625
- package/src/__tests__/alias-store.test.ts +143 -143
- package/src/__tests__/catalog-cache-store.test.ts +265 -265
- package/src/__tests__/catalog-notice.test.ts +123 -123
- package/src/__tests__/cli-apply-seams.test.ts +12 -8
- package/src/__tests__/cli-router.test.ts +132 -3
- package/src/__tests__/cli-update-view.test.ts +27 -8
- package/src/__tests__/community-staleness.test.ts +0 -2
- package/src/__tests__/component-badge.test.ts +4 -4
- package/src/__tests__/content-drift.test.ts +5 -1
- package/src/__tests__/conventions-integration.test.ts +774 -689
- package/src/__tests__/conventions-manager.test.ts +1216 -1152
- package/src/__tests__/doctor-bins.test.ts +14 -6
- package/src/__tests__/doctor.test.ts +438 -425
- package/src/__tests__/dual-write-prevention.test.ts +277 -282
- package/src/__tests__/enabled-not-installed.test.ts +71 -63
- package/src/__tests__/file-locking.test.ts +196 -196
- package/src/__tests__/gap-fill-versions.test.ts +318 -311
- package/src/__tests__/github-budget.test.ts +170 -164
- package/src/__tests__/gitignore-detector.test.ts +160 -152
- package/src/__tests__/gitignore-fixer.test.ts +285 -247
- package/src/__tests__/gitignore-prerun.test.ts +57 -57
- package/src/__tests__/gitignore-resolver.test.ts +173 -146
- package/src/__tests__/gitignore-service.test.ts +121 -119
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/install-command.test.ts +3 -3
- package/src/__tests__/install-plan.test.ts +13 -7
- package/src/__tests__/manifest.test.ts +16 -5
- package/src/__tests__/markdown-renderer.test.ts +0 -1
- package/src/__tests__/marketplace-badge.test.ts +111 -90
- package/src/__tests__/marketplace-version.test.ts +64 -61
- package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
- package/src/__tests__/plugin-requires.test.ts +55 -1
- package/src/__tests__/plugin-setup.test.ts +111 -9
- package/src/__tests__/profile-command.test.ts +1 -7
- package/src/__tests__/profile-materializer.test.ts +31 -8
- package/src/__tests__/profile-sync.test.ts +23 -5
- package/src/__tests__/registry-version-resolution.test.ts +61 -47
- package/src/__tests__/resolve-executable.test.ts +1 -1
- package/src/__tests__/resolver.test.ts +38 -13
- package/src/__tests__/scope-action.test.ts +52 -50
- package/src/__tests__/scope-squares.test.tsx +4 -5
- package/src/__tests__/skill-install-files.test.ts +111 -103
- package/src/__tests__/symlink-manager.test.ts +27 -10
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/update-apply.test.ts +1 -3
- package/src/__tests__/useGitignoreModal.test.ts +69 -61
- package/src/__tests__/version-snapshot.test.ts +183 -165
- package/src/cli/ansi.ts +23 -15
- package/src/cli/doctor.ts +10 -9
- package/src/cli/install.ts +3 -3
- package/src/cli/profile.ts +5 -4
- package/src/cli/prompt.ts +0 -1
- package/src/cli/router.ts +69 -2
- package/src/cli/update-view.ts +11 -6
- package/src/cli/update.ts +9 -4
- package/src/cli/upgrade.ts +6 -4
- package/src/data/alias-flags.ts +260 -260
- package/src/data/gitignore-defaults.ts +20 -20
- package/src/data/gitignore-reasons.ts +86 -92
- package/src/data/gitignore-templates.ts +14 -14
- package/src/data/predefined-profiles.ts +1 -2
- package/src/data/skill-repos.ts +43 -35
- package/src/main.tsx +29 -6
- package/src/opentui.d.ts +1 -3
- package/src/prerunner/index.ts +7 -8
- package/src/services/alias-settings.ts +20 -23
- package/src/services/alias-shell-writer.ts +800 -803
- package/src/services/alias-store.ts +77 -77
- package/src/services/claude-settings.ts +27 -18
- package/src/services/content-drift.ts +14 -4
- package/src/services/conventions-manager.ts +692 -617
- package/src/services/doctor.ts +464 -403
- package/src/services/gitignore-detector.ts +113 -103
- package/src/services/gitignore-fixer.ts +204 -189
- package/src/services/gitignore-prerun.ts +26 -26
- package/src/services/gitignore-resolver.ts +162 -144
- package/src/services/gitignore-service.ts +120 -117
- package/src/services/install-plan.ts +4 -4
- package/src/services/local-marketplace.ts +7 -5
- package/src/services/manifest.ts +1 -1
- package/src/services/marketplace-catalog-git.ts +5 -2
- package/src/services/marketplace-fetcher.ts +241 -235
- package/src/services/plugin-manager.ts +17 -20
- package/src/services/plugin-mcp-config.ts +2 -2
- package/src/services/plugin-requires.ts +16 -8
- package/src/services/plugin-setup.ts +32 -15
- package/src/services/plugin-version-check.ts +4 -4
- package/src/services/profile-materializer.ts +9 -5
- package/src/services/profile-sync.ts +12 -4
- package/src/services/profiles.ts +3 -3
- package/src/services/resolver.ts +10 -6
- package/src/services/settings-manager.ts +82 -46
- package/src/services/skills-manager.ts +11 -9
- package/src/services/symlink-manager.ts +2 -5
- package/src/services/toolchain.ts +3 -3
- package/src/services/update-cache.ts +1 -1
- package/src/services/update-engine.ts +4 -4
- package/src/services/update-plan.ts +9 -11
- package/src/services/version-snapshot.ts +88 -88
- package/src/types/gitignore.ts +28 -28
- package/src/ui/App.tsx +36 -36
- package/src/ui/adapters/settingsAdapter.ts +2 -2
- package/src/ui/components/CategoryHeader.tsx +1 -1
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +3 -1
- package/src/ui/components/ScrollableList.tsx +2 -1
- package/src/ui/components/StyledText.tsx +1 -1
- package/src/ui/components/layout/FooterHints.tsx +5 -3
- package/src/ui/components/layout/Panel.tsx +2 -2
- package/src/ui/components/layout/ScreenLayout.tsx +5 -4
- package/src/ui/components/modals/InputModal.tsx +7 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -3
- package/src/ui/components/modals/SelectModal.tsx +1 -1
- package/src/ui/components/primitives/DetailSection.tsx +1 -1
- package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +1 -5
- package/src/ui/hooks/useMismatchModal.ts +4 -10
- package/src/ui/renderers/cliToolRenderers.tsx +2 -2
- package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +3 -9
- package/src/ui/renderers/profileRenderers.tsx +9 -8
- package/src/ui/renderers/settingsRenderers.tsx +4 -4
- package/src/ui/renderers/skillRenderers.tsx +14 -14
- package/src/ui/screens/AliasScreen.tsx +31 -34
- package/src/ui/screens/CliToolsScreen.tsx +14 -13
- package/src/ui/screens/EnvVarsScreen.tsx +8 -8
- package/src/ui/screens/GitignoreScreen.tsx +15 -14
- package/src/ui/screens/McpRegistryScreen.tsx +10 -7
- package/src/ui/screens/McpScreen.tsx +10 -10
- package/src/ui/screens/PluginsScreen.tsx +17 -21
- package/src/ui/screens/ProfilesScreen.tsx +25 -24
- package/src/ui/screens/SkillsScreen.tsx +16 -14
- package/src/ui/state/AppContext.tsx +4 -8
- package/src/ui/state/DimensionsContext.tsx +3 -2
- package/src/ui/theme-mode.ts +20 -38
- package/src/ui/theme-resolve.ts +148 -0
- package/src/utils/clipboard.ts +5 -5
- package/src/utils/command-utils.ts +2 -2
- package/src/utils/config-dir.ts +1 -3
- package/src/utils/file-locking.ts +88 -86
- package/src/utils/go-module.ts +36 -0
- package/src/ui/hooks/useAsyncData.ts +0 -127
- 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 {
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
ALIAS_FLAGS,
|
|
16
|
+
type AliasFlag,
|
|
17
|
+
getFlagById,
|
|
18
18
|
} from "../data/alias-flags.js";
|
|
19
19
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
53
|
-
|
|
52
|
+
home: string = homedir(),
|
|
53
|
+
shellEnv: string = process.env.SHELL ?? "",
|
|
54
54
|
): Promise<ShellTarget[]> {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
108
|
-
|
|
107
|
+
| { kind: "literal"; text: string }
|
|
108
|
+
| { kind: "composite"; parts: SegmentPart[] };
|
|
109
109
|
|
|
110
110
|
export type SegmentPart =
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
213
|
-
|
|
212
|
+
flags: Record<string, FlagValue>,
|
|
213
|
+
own: FlagValue,
|
|
214
214
|
): FlagValue {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
+
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
339
335
|
}
|
|
340
336
|
|
|
341
337
|
export interface RenderedAlias {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
349
|
-
|
|
344
|
+
config: AliasConfig,
|
|
345
|
+
shell: ShellKind,
|
|
350
346
|
): RenderedAlias {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
500
|
+
config: AliasConfig,
|
|
501
|
+
target: ShellTarget,
|
|
502
|
+
options: WriteOptions = {},
|
|
509
503
|
): Promise<WriteResult> {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
546
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
798
|
+
args: string[],
|
|
801
799
|
): ParsedFlagsWithLeftovers {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
977
|
-
|
|
974
|
+
aliasName: string;
|
|
975
|
+
flags: Record<string, FlagValue>;
|
|
978
976
|
} | null {
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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(
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1200
|
+
existing: string,
|
|
1201
|
+
block: string,
|
|
1202
|
+
range: [number, number],
|
|
1206
1203
|
): string {
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
-
|
|
1220
|
+
partial: Record<string, FlagValue>,
|
|
1224
1221
|
): Record<string, FlagValue> {
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
-
|
|
1235
|
-
|
|
1231
|
+
a: Record<string, FlagValue>,
|
|
1232
|
+
b: Record<string, FlagValue>,
|
|
1236
1233
|
): boolean {
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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
|
}
|