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