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.
- 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__/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 +18 -4
- package/src/__tests__/plugin-setup.test.ts +12 -12
- 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 +13 -3
- 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/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 +9 -6
- package/src/services/plugin-setup.ts +7 -9
- 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/ui/hooks/useAsyncData.ts +0 -127
- package/src/ui/state/AnimationContext.tsx +0 -76
|
@@ -1,544 +1,549 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
7
|
+
type Segment,
|
|
8
|
+
type ShellTarget,
|
|
9
|
+
detectShells,
|
|
10
|
+
expandTemplateValue,
|
|
11
|
+
findUnquotableTokens,
|
|
12
|
+
renderAlias,
|
|
13
|
+
renderArgsAsTokens as renderArgs,
|
|
14
|
+
renderArgs as renderArgsSegments,
|
|
15
|
+
spliceManagedBlock,
|
|
16
|
+
validateConfig,
|
|
17
|
+
writeAliasToShell,
|
|
18
18
|
} from "../services/alias-shell-writer";
|
|
19
19
|
import { defaultAliasConfig } from "../services/alias-store";
|
|
20
20
|
|
|
21
21
|
describe("renderArgs", () => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
22
|
+
it("emits nothing when every flag is at default (off)", () => {
|
|
23
|
+
expect(renderArgs(defaultAliasConfig())).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders boolean flags as bare tokens", () => {
|
|
27
|
+
const c = defaultAliasConfig();
|
|
28
|
+
c.flags["dangerously-skip-permissions"] = {
|
|
29
|
+
kind: "boolean",
|
|
30
|
+
enabled: true,
|
|
31
|
+
};
|
|
32
|
+
c.flags.ide = { kind: "boolean", enabled: true };
|
|
33
|
+
expect(renderArgs(c)).toEqual(["--dangerously-skip-permissions", "--ide"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renders tri-state on/off and skips unset", () => {
|
|
37
|
+
const c = defaultAliasConfig();
|
|
38
|
+
c.flags.chrome = { kind: "tri-state", state: "on" };
|
|
39
|
+
expect(renderArgs(c)).toEqual(["--chrome"]);
|
|
40
|
+
c.flags.chrome = { kind: "tri-state", state: "off" };
|
|
41
|
+
expect(renderArgs(c)).toEqual(["--no-chrome"]);
|
|
42
|
+
c.flags.chrome = { kind: "tri-state", state: "unset" };
|
|
43
|
+
expect(renderArgs(c)).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders select with value", () => {
|
|
47
|
+
const c = defaultAliasConfig();
|
|
48
|
+
c.flags.effort = { kind: "select", enabled: true, value: "high" };
|
|
49
|
+
expect(renderArgs(c)).toEqual(["--effort", "high"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("renders select with empty value as bare flag", () => {
|
|
53
|
+
const c = defaultAliasConfig();
|
|
54
|
+
c.flags.worktree = { kind: "boolean", enabled: true };
|
|
55
|
+
c.flags.tmux = { kind: "select", enabled: true, value: "" };
|
|
56
|
+
expect(renderArgs(c)).toEqual(["--worktree", "--tmux"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("enforces requires: --tmux is dropped without --worktree", () => {
|
|
60
|
+
const c = defaultAliasConfig();
|
|
61
|
+
c.flags.tmux = { kind: "select", enabled: true, value: "classic" };
|
|
62
|
+
expect(renderArgs(c)).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("admits --tmux once --worktree is enabled", () => {
|
|
66
|
+
const c = defaultAliasConfig();
|
|
67
|
+
c.flags.worktree = { kind: "boolean", enabled: true };
|
|
68
|
+
c.flags.tmux = { kind: "select", enabled: true, value: "classic" };
|
|
69
|
+
expect(renderArgs(c)).toEqual(["--worktree", "--tmux", "classic"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("enforces xor: --system-prompt wins over --system-prompt-file", () => {
|
|
73
|
+
const c = defaultAliasConfig();
|
|
74
|
+
c.flags["system-prompt"] = {
|
|
75
|
+
kind: "text",
|
|
76
|
+
enabled: true,
|
|
77
|
+
value: "hello",
|
|
78
|
+
};
|
|
79
|
+
c.flags["system-prompt-file"] = {
|
|
80
|
+
kind: "text",
|
|
81
|
+
enabled: true,
|
|
82
|
+
value: "/tmp/p.txt",
|
|
83
|
+
};
|
|
84
|
+
const args = renderArgs(c);
|
|
85
|
+
expect(args).toEqual(["--system-prompt", "hello"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renders text-list as repeated flag/value pairs", () => {
|
|
89
|
+
const c = defaultAliasConfig();
|
|
90
|
+
c.flags["dangerously-load-development-channels"] = {
|
|
91
|
+
kind: "text-list",
|
|
92
|
+
enabled: true,
|
|
93
|
+
values: ["plugin:claudish@magus", "plugin:dev@magus"],
|
|
94
|
+
};
|
|
95
|
+
// Enabling dev-load also forces --channels to emit (the render-time union),
|
|
96
|
+
// since a dev-loaded channel must be on the channels list to run. --channels
|
|
97
|
+
// comes first (catalog order), then the repeated dev-load pairs.
|
|
98
|
+
expect(renderArgs(c)).toEqual([
|
|
99
|
+
"--channels",
|
|
100
|
+
"plugin:claudish@magus",
|
|
101
|
+
"--channels",
|
|
102
|
+
"plugin:dev@magus",
|
|
103
|
+
"--dangerously-load-development-channels",
|
|
104
|
+
"plugin:claudish@magus",
|
|
105
|
+
"--dangerously-load-development-channels",
|
|
106
|
+
"plugin:dev@magus",
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("renders multi-with-custom: picked tokens then custom, deduped, comma-joined", () => {
|
|
111
|
+
const c = defaultAliasConfig();
|
|
112
|
+
c.flags.debug = {
|
|
113
|
+
kind: "multi-with-custom",
|
|
114
|
+
enabled: true,
|
|
115
|
+
picked: ["api", "hooks"],
|
|
116
|
+
custom: ["!1p", "!file", "api"], // duplicate "api"
|
|
117
|
+
};
|
|
118
|
+
expect(renderArgs(c)).toEqual(["--debug", "api,hooks,!1p,!file"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("renders multi-with-custom with no tokens as bare --debug", () => {
|
|
122
|
+
const c = defaultAliasConfig();
|
|
123
|
+
c.flags.debug = {
|
|
124
|
+
kind: "multi-with-custom",
|
|
125
|
+
enabled: true,
|
|
126
|
+
picked: [],
|
|
127
|
+
custom: [],
|
|
128
|
+
};
|
|
129
|
+
expect(renderArgs(c)).toEqual(["--debug"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("renders --remote-control as a boolean toggle", () => {
|
|
133
|
+
const c = defaultAliasConfig();
|
|
134
|
+
c.flags["remote-control"] = { kind: "boolean", enabled: true };
|
|
135
|
+
expect(renderArgs(c)).toEqual(["--remote-control"]);
|
|
136
|
+
c.flags["remote-control"] = { kind: "boolean", enabled: false };
|
|
137
|
+
expect(renderArgs(c)).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("renders --remote-control alongside --remote-control-session-name-prefix", () => {
|
|
141
|
+
const c = defaultAliasConfig();
|
|
142
|
+
c.flags["remote-control"] = { kind: "boolean", enabled: true };
|
|
143
|
+
c.flags["remote-control-session-name-prefix"] = {
|
|
144
|
+
kind: "text",
|
|
145
|
+
enabled: true,
|
|
146
|
+
value: "jack-laptop",
|
|
147
|
+
};
|
|
148
|
+
expect(renderArgs(c)).toEqual([
|
|
149
|
+
"--remote-control",
|
|
150
|
+
"--remote-control-session-name-prefix",
|
|
151
|
+
"jack-laptop",
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
describe("renderAlias — POSIX (zsh/bash)", () => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
157
|
+
it("emits an empty alias when no flags are enabled", () => {
|
|
158
|
+
const r = renderAlias(defaultAliasConfig(), "zsh");
|
|
159
|
+
expect(r.block).toContain("alias c='claude'");
|
|
160
|
+
expect(r.block).toMatch(/^# >>> claudeup managed alias >>>\n/);
|
|
161
|
+
expect(r.block).toMatch(/# <<< claudeup managed alias <<<\n$/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("uses the configured alias name", () => {
|
|
165
|
+
const c = defaultAliasConfig();
|
|
166
|
+
c.aliasName = "ccc";
|
|
167
|
+
c.flags.ide = { kind: "boolean", enabled: true };
|
|
168
|
+
const r = renderAlias(c, "zsh");
|
|
169
|
+
expect(r.block).toContain("alias ccc='claude --ide'");
|
|
170
|
+
const fish = renderAlias(c, "fish");
|
|
171
|
+
expect(fish.block).toContain("alias ccc 'claude");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("emits bare flags without quoting", () => {
|
|
175
|
+
const c = defaultAliasConfig();
|
|
176
|
+
c.flags["dangerously-skip-permissions"] = {
|
|
177
|
+
kind: "boolean",
|
|
178
|
+
enabled: true,
|
|
179
|
+
};
|
|
180
|
+
c.flags.ide = { kind: "boolean", enabled: true };
|
|
181
|
+
const r = renderAlias(c, "zsh");
|
|
182
|
+
expect(r.block).toContain(
|
|
183
|
+
"alias c='claude --dangerously-skip-permissions --ide'",
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("quotes values with spaces using nested single-quote escapes", () => {
|
|
188
|
+
const c = defaultAliasConfig();
|
|
189
|
+
c.flags["append-system-prompt"] = {
|
|
190
|
+
kind: "text",
|
|
191
|
+
enabled: true,
|
|
192
|
+
value: "be terse",
|
|
193
|
+
};
|
|
194
|
+
const r = renderAlias(c, "zsh");
|
|
195
|
+
// The final alias body should contain: --append-system-prompt 'be terse'
|
|
196
|
+
// Encoded inside the outer single-quoted alias as:
|
|
197
|
+
// --append-system-prompt '\''be terse'\''
|
|
198
|
+
expect(r.block).toContain(
|
|
199
|
+
"alias c='claude --append-system-prompt '\\''be terse'\\'''",
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("leaves comma- and slash-bearing simple values unquoted", () => {
|
|
204
|
+
const c = defaultAliasConfig();
|
|
205
|
+
c.flags.debug = {
|
|
206
|
+
kind: "multi-with-custom",
|
|
207
|
+
enabled: true,
|
|
208
|
+
picked: ["api", "hooks"],
|
|
209
|
+
custom: ["!file"],
|
|
210
|
+
};
|
|
211
|
+
c.flags["append-system-prompt-file"] = {
|
|
212
|
+
kind: "text",
|
|
213
|
+
enabled: true,
|
|
214
|
+
value: "/tmp/p.txt",
|
|
215
|
+
};
|
|
216
|
+
const r = renderAlias(c, "zsh");
|
|
217
|
+
expect(r.block).toContain("--debug api,hooks,!file");
|
|
218
|
+
expect(r.block).toContain("--append-system-prompt-file /tmp/p.txt");
|
|
219
|
+
});
|
|
220
220
|
});
|
|
221
221
|
|
|
222
222
|
describe("renderAlias — fish", () => {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
223
|
+
it("uses fish syntax (no = sign)", () => {
|
|
224
|
+
const c = defaultAliasConfig();
|
|
225
|
+
c.flags.ide = { kind: "boolean", enabled: true };
|
|
226
|
+
const r = renderAlias(c, "fish");
|
|
227
|
+
// Outer fish alias: alias c 'claude '--ide'' (each arg fish-quoted)
|
|
228
|
+
expect(r.block).toContain("alias c 'claude");
|
|
229
|
+
expect(r.block).toContain("--ide");
|
|
230
|
+
expect(r.block).not.toContain("alias c='claude");
|
|
231
|
+
});
|
|
232
232
|
});
|
|
233
233
|
|
|
234
234
|
describe("expandTemplateValue", () => {
|
|
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
|
-
|
|
235
|
+
it("returns a single literal when no tokens are present", () => {
|
|
236
|
+
expect(expandTemplateValue("plain-prefix")).toEqual([
|
|
237
|
+
{ kind: "literal", text: "plain-prefix" },
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("splits literal/raw alternations for known tokens", () => {
|
|
242
|
+
expect(expandTemplateValue("dev-{folder}-{day}")).toEqual([
|
|
243
|
+
{ kind: "literal", text: "dev-" },
|
|
244
|
+
{ kind: "raw", posix: '$(basename "$PWD")', fish: "(basename $PWD)" },
|
|
245
|
+
{ kind: "literal", text: "-" },
|
|
246
|
+
{ kind: "raw", posix: "$(date +%d)", fish: "(date +%d)" },
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("ignores unknown {bracketed} sequences (passes through as literal)", () => {
|
|
251
|
+
expect(expandTemplateValue("dev-{unknown}-{folder}")).toEqual([
|
|
252
|
+
{ kind: "literal", text: "dev-{unknown}-" },
|
|
253
|
+
{ kind: "raw", posix: '$(basename "$PWD")', fish: "(basename $PWD)" },
|
|
254
|
+
]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("handles a value that's just a token", () => {
|
|
258
|
+
expect(expandTemplateValue("{year}")).toEqual([
|
|
259
|
+
{ kind: "raw", posix: "$(date +%Y)", fish: "(date +%Y)" },
|
|
260
|
+
]);
|
|
261
|
+
});
|
|
262
262
|
});
|
|
263
263
|
|
|
264
264
|
describe("renderAlias — templated values", () => {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
265
|
+
it("emits a composite POSIX segment with embedded substitutions", () => {
|
|
266
|
+
const c = defaultAliasConfig();
|
|
267
|
+
c.flags["remote-control"] = { kind: "boolean", enabled: true };
|
|
268
|
+
c.flags["remote-control-session-name-prefix"] = {
|
|
269
|
+
kind: "text",
|
|
270
|
+
enabled: true,
|
|
271
|
+
value: "dev-{folder}-{day}",
|
|
272
|
+
};
|
|
273
|
+
const r = renderAlias(c, "zsh");
|
|
274
|
+
// Expected pattern: alias c='claude --remote-control --remote-control-session-name-prefix dev-'"$(basename "$PWD")"'-'"$(date +%d)"''
|
|
275
|
+
expect(r.block).toContain("--remote-control-session-name-prefix");
|
|
276
|
+
expect(r.block).toContain('dev-\'"$(basename "$PWD")"\'-\'"$(date +%d)"\'');
|
|
277
|
+
// Sanity: no raw `{folder}` literal made it through.
|
|
278
|
+
expect(r.block).not.toContain("{folder}");
|
|
279
|
+
expect(r.block).not.toContain("{day}");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("emits a composite fish segment with fish-style substitutions", () => {
|
|
283
|
+
const c = defaultAliasConfig();
|
|
284
|
+
c.flags["remote-control-session-name-prefix"] = {
|
|
285
|
+
kind: "text",
|
|
286
|
+
enabled: true,
|
|
287
|
+
value: "{folder}-{year}",
|
|
288
|
+
};
|
|
289
|
+
const r = renderAlias(c, "fish");
|
|
290
|
+
// Fish treats adjacent quoted strings inside an alias body as one arg,
|
|
291
|
+
// so the literal `-` between the two substitutions stays single-quoted.
|
|
292
|
+
expect(r.block).toContain("'\"(basename $PWD)\"''-''\"(date +%Y)\"'");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("falls back to a plain literal when no tokens are present", () => {
|
|
296
|
+
const c = defaultAliasConfig();
|
|
297
|
+
c.flags["remote-control-session-name-prefix"] = {
|
|
298
|
+
kind: "text",
|
|
299
|
+
enabled: true,
|
|
300
|
+
value: "static-prefix",
|
|
301
|
+
};
|
|
302
|
+
const r = renderAlias(c, "zsh");
|
|
303
|
+
expect(r.block).toContain(
|
|
304
|
+
"--remote-control-session-name-prefix static-prefix",
|
|
305
|
+
);
|
|
306
|
+
expect(r.block).not.toContain('"');
|
|
307
|
+
});
|
|
306
308
|
});
|
|
307
309
|
|
|
308
310
|
describe("findUnquotableTokens", () => {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
311
|
+
it("flags any literal segment text containing a single quote", () => {
|
|
312
|
+
const segs: Segment[] = [
|
|
313
|
+
{ kind: "literal", text: "foo" },
|
|
314
|
+
{ kind: "literal", text: "it's" },
|
|
315
|
+
{ kind: "literal", text: "bar" },
|
|
316
|
+
];
|
|
317
|
+
expect(findUnquotableTokens(segs)).toEqual(["it's"]);
|
|
318
|
+
expect(
|
|
319
|
+
findUnquotableTokens([
|
|
320
|
+
{ kind: "literal", text: "foo" },
|
|
321
|
+
{ kind: "literal", text: "bar" },
|
|
322
|
+
]),
|
|
323
|
+
).toEqual([]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("walks composite segments and flags unquotable literal parts", () => {
|
|
327
|
+
const segs: Segment[] = [
|
|
328
|
+
{
|
|
329
|
+
kind: "composite",
|
|
330
|
+
parts: [
|
|
331
|
+
{ kind: "literal", text: "ok-" },
|
|
332
|
+
{ kind: "raw", posix: "$(date +%d)", fish: "(date +%d)" },
|
|
333
|
+
{ kind: "literal", text: "it's" },
|
|
334
|
+
],
|
|
335
|
+
},
|
|
336
|
+
];
|
|
337
|
+
expect(findUnquotableTokens(segs)).toEqual(["it's"]);
|
|
338
|
+
});
|
|
337
339
|
});
|
|
338
340
|
|
|
339
341
|
describe("spliceManagedBlock", () => {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
342
|
+
const block = `# >>> claudeup managed alias >>>\nalias c='claude --ide'\n# <<< claudeup managed alias <<<\n`;
|
|
343
|
+
|
|
344
|
+
it("appends the block to an empty file", () => {
|
|
345
|
+
expect(spliceManagedBlock("", block)).toBe(block);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("appends the block with separator when existing has content", () => {
|
|
349
|
+
const existing = "export PATH=$PATH:/usr/local/bin\n";
|
|
350
|
+
const result = spliceManagedBlock(existing, block);
|
|
351
|
+
expect(result.startsWith(existing)).toBe(true);
|
|
352
|
+
expect(result.endsWith(block)).toBe(true);
|
|
353
|
+
expect(result).toContain("\n\n# >>> claudeup managed alias >>>");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("replaces an existing block in place without disturbing surrounding lines", () => {
|
|
357
|
+
const before = "alias ll='ls -la'\n";
|
|
358
|
+
const after = "export EDITOR=vim\n";
|
|
359
|
+
const oldBlock = `# >>> claudeup managed alias >>>\nalias c='claude --debug'\n# <<< claudeup managed alias <<<\n`;
|
|
360
|
+
const existing = before + oldBlock + after;
|
|
361
|
+
const result = spliceManagedBlock(existing, block);
|
|
362
|
+
expect(result).toBe(before + block + after);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("is idempotent: writing the same block twice yields the same file", () => {
|
|
366
|
+
const once = spliceManagedBlock("", block);
|
|
367
|
+
const twice = spliceManagedBlock(once, block);
|
|
368
|
+
expect(twice).toBe(once);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("recovers when the END marker is missing (truncated/edited block)", () => {
|
|
372
|
+
const truncated = `# >>> claudeup managed alias >>>\nalias c='claude --broken\n`;
|
|
373
|
+
const result = spliceManagedBlock(truncated, block);
|
|
374
|
+
expect(result).toBe(block);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("preserves a stray hand-edit outside the block", () => {
|
|
378
|
+
const existing = `# user comment\nalias g='git'\n${block}# trailing comment\n`;
|
|
379
|
+
// Re-render with same block — surrounding text is preserved verbatim.
|
|
380
|
+
const result = spliceManagedBlock(existing, block);
|
|
381
|
+
expect(result).toBe(existing);
|
|
382
|
+
});
|
|
381
383
|
});
|
|
382
384
|
|
|
383
385
|
describe("validateConfig", () => {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
386
|
+
it("reports nothing for the default config", () => {
|
|
387
|
+
expect(validateConfig(defaultAliasConfig())).toEqual([]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("flags --tmux as requiring --worktree", () => {
|
|
391
|
+
const c = defaultAliasConfig();
|
|
392
|
+
c.flags.tmux = { kind: "select", enabled: true, value: "classic" };
|
|
393
|
+
const issues = validateConfig(c);
|
|
394
|
+
expect(issues).toEqual([
|
|
395
|
+
{ flagId: "tmux", reason: expect.stringContaining("--worktree") },
|
|
396
|
+
]);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("flags xor losers when both system-prompt fields are set", () => {
|
|
400
|
+
const c = defaultAliasConfig();
|
|
401
|
+
c.flags["system-prompt"] = { kind: "text", enabled: true, value: "a" };
|
|
402
|
+
c.flags["system-prompt-file"] = {
|
|
403
|
+
kind: "text",
|
|
404
|
+
enabled: true,
|
|
405
|
+
value: "/x",
|
|
406
|
+
};
|
|
407
|
+
const issues = validateConfig(c);
|
|
408
|
+
expect(issues).toHaveLength(1);
|
|
409
|
+
expect(issues[0].flagId).toBe("system-prompt-file");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("reports nothing once the dependency is satisfied", () => {
|
|
413
|
+
const c = defaultAliasConfig();
|
|
414
|
+
c.flags.worktree = { kind: "boolean", enabled: true };
|
|
415
|
+
c.flags.tmux = { kind: "select", enabled: true, value: "classic" };
|
|
416
|
+
expect(validateConfig(c)).toEqual([]);
|
|
417
|
+
});
|
|
416
418
|
});
|
|
417
419
|
|
|
418
420
|
describe("detectShells", () => {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
421
|
+
let home: string;
|
|
422
|
+
|
|
423
|
+
beforeEach(async () => {
|
|
424
|
+
home = await mkdtemp(join(tmpdir(), "alias-detect-"));
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
afterEach(async () => {
|
|
428
|
+
await rm(home, { recursive: true, force: true });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("reports no shell as existing when none of the rc files are present", async () => {
|
|
432
|
+
const shells = await detectShells(home, "/bin/zsh");
|
|
433
|
+
expect(shells.map((s) => s.kind)).toEqual(["zsh", "bash", "fish"]);
|
|
434
|
+
expect(shells.every((s) => !s.exists)).toBe(true);
|
|
435
|
+
expect(shells.find((s) => s.kind === "zsh")?.isDefault).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("marks rc files that exist", async () => {
|
|
439
|
+
await writeFile(join(home, ".zshrc"), "# zshrc\n", "utf8");
|
|
440
|
+
await mkdir(join(home, ".config", "fish"), { recursive: true });
|
|
441
|
+
await writeFile(
|
|
442
|
+
join(home, ".config", "fish", "config.fish"),
|
|
443
|
+
"# fish\n",
|
|
444
|
+
"utf8",
|
|
445
|
+
);
|
|
446
|
+
const shells = await detectShells(home, "/usr/local/bin/fish");
|
|
447
|
+
const byKind = Object.fromEntries(shells.map((s) => [s.kind, s]));
|
|
448
|
+
expect(byKind.zsh.exists).toBe(true);
|
|
449
|
+
expect(byKind.bash.exists).toBe(false);
|
|
450
|
+
expect(byKind.fish.exists).toBe(true);
|
|
451
|
+
expect(byKind.fish.isDefault).toBe(true);
|
|
452
|
+
});
|
|
451
453
|
});
|
|
452
454
|
|
|
453
455
|
describe("writeAliasToShell — end-to-end", () => {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
456
|
+
let home: string;
|
|
457
|
+
|
|
458
|
+
beforeEach(async () => {
|
|
459
|
+
home = await mkdtemp(join(tmpdir(), "alias-write-"));
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
afterEach(async () => {
|
|
463
|
+
await rm(home, { recursive: true, force: true });
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("creates the rc file when missing and writes the managed block", async () => {
|
|
467
|
+
const target: ShellTarget = {
|
|
468
|
+
kind: "zsh",
|
|
469
|
+
path: join(home, ".zshrc"),
|
|
470
|
+
exists: false,
|
|
471
|
+
isDefault: true,
|
|
472
|
+
};
|
|
473
|
+
const c = defaultAliasConfig();
|
|
474
|
+
c.flags.ide = { kind: "boolean", enabled: true };
|
|
475
|
+
const result = await writeAliasToShell(c, target);
|
|
476
|
+
expect(result.action).toBe("created");
|
|
477
|
+
const written = await readFile(target.path, "utf8");
|
|
478
|
+
expect(written).toContain("alias c='claude --ide'");
|
|
479
|
+
expect(written).toContain("# >>> claudeup managed alias >>>");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("updates an existing rc file in place without losing surrounding content", async () => {
|
|
483
|
+
const path = join(home, ".zshrc");
|
|
484
|
+
await writeFile(
|
|
485
|
+
path,
|
|
486
|
+
"export PATH=$PATH:/opt/bin\nalias ll='ls -la'\n",
|
|
487
|
+
"utf8",
|
|
488
|
+
);
|
|
489
|
+
const target: ShellTarget = {
|
|
490
|
+
kind: "zsh",
|
|
491
|
+
path,
|
|
492
|
+
exists: true,
|
|
493
|
+
isDefault: true,
|
|
494
|
+
};
|
|
495
|
+
const c = defaultAliasConfig();
|
|
496
|
+
c.flags.ide = { kind: "boolean", enabled: true };
|
|
497
|
+
const result = await writeAliasToShell(c, target);
|
|
498
|
+
expect(result.action).toBe("updated");
|
|
499
|
+
const written = await readFile(path, "utf8");
|
|
500
|
+
expect(written).toContain("export PATH=$PATH:/opt/bin");
|
|
501
|
+
expect(written).toContain("alias ll='ls -la'");
|
|
502
|
+
expect(written).toContain("alias c='claude --ide'");
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("re-writing replaces (not duplicates) the managed block", async () => {
|
|
506
|
+
const path = join(home, ".zshrc");
|
|
507
|
+
const target: ShellTarget = {
|
|
508
|
+
kind: "zsh",
|
|
509
|
+
path,
|
|
510
|
+
exists: false,
|
|
511
|
+
isDefault: true,
|
|
512
|
+
};
|
|
513
|
+
const c = defaultAliasConfig();
|
|
514
|
+
c.flags.ide = { kind: "boolean", enabled: true };
|
|
515
|
+
await writeAliasToShell(c, target);
|
|
516
|
+
target.exists = true;
|
|
517
|
+
c.flags.ide = { kind: "boolean", enabled: false };
|
|
518
|
+
c.flags["dangerously-skip-permissions"] = {
|
|
519
|
+
kind: "boolean",
|
|
520
|
+
enabled: true,
|
|
521
|
+
};
|
|
522
|
+
await writeAliasToShell(c, target);
|
|
523
|
+
const written = await readFile(path, "utf8");
|
|
524
|
+
expect(written).toContain("--dangerously-skip-permissions");
|
|
525
|
+
expect(written).not.toContain("--ide");
|
|
526
|
+
// Block markers appear exactly once
|
|
527
|
+
const beginMatches = written.match(/# >>> claudeup managed alias >>>/g);
|
|
528
|
+
expect(beginMatches?.length).toBe(1);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("rejects writes containing tokens with embedded single quotes", async () => {
|
|
532
|
+
const path = join(home, ".zshrc");
|
|
533
|
+
const target: ShellTarget = {
|
|
534
|
+
kind: "zsh",
|
|
535
|
+
path,
|
|
536
|
+
exists: false,
|
|
537
|
+
isDefault: true,
|
|
538
|
+
};
|
|
539
|
+
const c = defaultAliasConfig();
|
|
540
|
+
c.flags["append-system-prompt"] = {
|
|
541
|
+
kind: "text",
|
|
542
|
+
enabled: true,
|
|
543
|
+
value: "it's tricky",
|
|
544
|
+
};
|
|
545
|
+
await expect(writeAliasToShell(c, target)).rejects.toThrow(/single quote/);
|
|
546
|
+
});
|
|
542
547
|
});
|
|
543
548
|
|
|
544
549
|
/**
|
|
@@ -547,122 +552,130 @@ describe("writeAliasToShell — end-to-end", () => {
|
|
|
547
552
|
* pure byte-equality tests above would miss.
|
|
548
553
|
*/
|
|
549
554
|
describe("end-to-end via bash subprocess", () => {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
555
|
+
let home: string;
|
|
556
|
+
|
|
557
|
+
beforeEach(async () => {
|
|
558
|
+
home = await mkdtemp(join(tmpdir(), "alias-bash-"));
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
afterEach(async () => {
|
|
562
|
+
await rm(home, { recursive: true, force: true });
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("bash sources the emitted block and `alias claude` reports the right body", async () => {
|
|
566
|
+
if (spawnSync("bash", ["-c", "true"]).status !== 0) {
|
|
567
|
+
// No bash on the runner — skip.
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const path = join(home, ".bashrc");
|
|
571
|
+
const target: ShellTarget = {
|
|
572
|
+
kind: "bash",
|
|
573
|
+
path,
|
|
574
|
+
exists: false,
|
|
575
|
+
isDefault: true,
|
|
576
|
+
};
|
|
577
|
+
const c = defaultAliasConfig();
|
|
578
|
+
c.flags["dangerously-skip-permissions"] = {
|
|
579
|
+
kind: "boolean",
|
|
580
|
+
enabled: true,
|
|
581
|
+
};
|
|
582
|
+
c.flags.debug = {
|
|
583
|
+
kind: "multi-with-custom",
|
|
584
|
+
enabled: true,
|
|
585
|
+
picked: ["api", "hooks"],
|
|
586
|
+
custom: ["!file"],
|
|
587
|
+
};
|
|
588
|
+
c.flags["append-system-prompt"] = {
|
|
589
|
+
kind: "text",
|
|
590
|
+
enabled: true,
|
|
591
|
+
value: "be terse",
|
|
592
|
+
};
|
|
593
|
+
await writeAliasToShell(c, target);
|
|
594
|
+
|
|
595
|
+
const result = spawnSync(
|
|
596
|
+
"bash",
|
|
597
|
+
["--noprofile", "--norc", "-c", `source ${path} && alias c`],
|
|
598
|
+
{ encoding: "utf8" },
|
|
599
|
+
);
|
|
600
|
+
expect(result.status).toBe(0);
|
|
601
|
+
// Bash's `alias c` prints: alias c='<body>'
|
|
602
|
+
// We just check the rendered body contains the flag tokens correctly
|
|
603
|
+
// separated and the quoted prompt value preserved.
|
|
604
|
+
expect(result.stdout).toContain("--dangerously-skip-permissions");
|
|
605
|
+
expect(result.stdout).toContain("--debug api,hooks,!file");
|
|
606
|
+
expect(result.stdout).toContain("--append-system-prompt");
|
|
607
|
+
expect(result.stdout).toContain("be terse");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("templated prefix expands at alias-expansion time, not at write time", async () => {
|
|
611
|
+
if (spawnSync("bash", ["-c", "true"]).status !== 0) return;
|
|
612
|
+
|
|
613
|
+
const path = join(home, ".bashrc");
|
|
614
|
+
const target: ShellTarget = {
|
|
615
|
+
kind: "bash",
|
|
616
|
+
path,
|
|
617
|
+
exists: false,
|
|
618
|
+
isDefault: true,
|
|
619
|
+
};
|
|
620
|
+
const c = defaultAliasConfig();
|
|
621
|
+
c.flags["remote-control"] = { kind: "boolean", enabled: true };
|
|
622
|
+
c.flags["remote-control-session-name-prefix"] = {
|
|
623
|
+
kind: "text",
|
|
624
|
+
enabled: true,
|
|
625
|
+
value: "dev-{folder}-{day}",
|
|
626
|
+
};
|
|
627
|
+
await writeAliasToShell(c, target);
|
|
628
|
+
|
|
629
|
+
// Replace the underlying `claude` binary with a stub that echoes its argv,
|
|
630
|
+
// then invoke the alias from inside a directory we control. The captured
|
|
631
|
+
// stdout shows what argv claude would have been called with — including
|
|
632
|
+
// the actual expansion of {folder} and {day} from the surrounding shell.
|
|
633
|
+
const probeDir = await mkdtemp(join(tmpdir(), "alias-probe-"));
|
|
634
|
+
const stubBin = join(probeDir, "bin");
|
|
635
|
+
await mkdir(stubBin, { recursive: true });
|
|
636
|
+
const stubPath = join(stubBin, "claude");
|
|
637
|
+
await writeFile(
|
|
638
|
+
stubPath,
|
|
639
|
+
'#!/usr/bin/env bash\nfor a in "$@"; do echo "ARG:$a"; done\n',
|
|
640
|
+
"utf8",
|
|
641
|
+
);
|
|
642
|
+
spawnSync("chmod", ["+x", stubPath]);
|
|
643
|
+
const cwdNamed = join(probeDir, "myproject");
|
|
644
|
+
await mkdir(cwdNamed, { recursive: true });
|
|
645
|
+
|
|
646
|
+
const result = spawnSync(
|
|
647
|
+
"bash",
|
|
648
|
+
[
|
|
649
|
+
"--noprofile",
|
|
650
|
+
"--norc",
|
|
651
|
+
"-O",
|
|
652
|
+
"expand_aliases",
|
|
653
|
+
"-c",
|
|
654
|
+
// Run with the stub on PATH and cwd inside myproject so {folder}
|
|
655
|
+
// expands to "myproject". `-O expand_aliases` enables alias expansion
|
|
656
|
+
// in non-interactive bash; `eval` defers parsing of `c` so the alias
|
|
657
|
+
// is visible when it's resolved (bash parses the whole -c argument
|
|
658
|
+
// up-front, so a bare `c` would be unknown at parse time).
|
|
659
|
+
`export PATH="${stubBin}:$PATH" && cd "${cwdNamed}" && source ${path} && eval c`,
|
|
660
|
+
],
|
|
661
|
+
{ encoding: "utf8" },
|
|
662
|
+
);
|
|
663
|
+
expect(result.status).toBe(0);
|
|
664
|
+
// The session-name-prefix arg should have been expanded to the literal
|
|
665
|
+
// string `dev-myproject-<two-digit-day>`.
|
|
666
|
+
const argLines = result.stdout
|
|
667
|
+
.split("\n")
|
|
668
|
+
.filter((l) => l.startsWith("ARG:"));
|
|
669
|
+
const prefixArg = argLines.find(
|
|
670
|
+
(l) =>
|
|
671
|
+
argLines.indexOf(l) > 0 &&
|
|
672
|
+
argLines[argLines.indexOf(l) - 1] ===
|
|
673
|
+
"ARG:--remote-control-session-name-prefix",
|
|
674
|
+
);
|
|
675
|
+
expect(prefixArg).toBeDefined();
|
|
676
|
+
// Match `dev-myproject-NN` where NN is a 2-digit day.
|
|
677
|
+
expect(prefixArg).toMatch(/^ARG:dev-myproject-\d{2}$/);
|
|
678
|
+
|
|
679
|
+
await rm(probeDir, { recursive: true, force: true });
|
|
680
|
+
});
|
|
668
681
|
});
|