claudeup 4.18.0 → 4.19.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-adopt.test.ts +364 -0
- package/src/__tests__/alias-parser.test.ts +92 -0
- package/src/__tests__/alias-shell-writer.test.ts +7 -0
- package/src/__tests__/alias-store.test.ts +77 -0
- package/src/__tests__/plugin-setup.test.ts +111 -0
- package/src/data/alias-flags.js +10 -1
- package/src/data/alias-flags.ts +11 -1
- package/src/services/alias-shell-writer.js +262 -8
- package/src/services/alias-shell-writer.ts +382 -8
- package/src/services/alias-store.js +52 -0
- package/src/services/alias-store.ts +60 -0
- package/src/services/plugin-setup.js +59 -4
- package/src/services/plugin-setup.ts +61 -4
- package/src/ui/App.js +16 -7
- package/src/ui/App.tsx +16 -7
- package/src/ui/components/FlagDetailEditor.js +0 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/modals/ConfirmModal.js +1 -1
- package/src/ui/components/modals/ConfirmModal.tsx +1 -1
- package/src/ui/screens/AliasScreen.js +380 -277
- package/src/ui/screens/AliasScreen.tsx +491 -359
- package/src/ui/screens/PluginsScreen.js +4 -1
- package/src/ui/screens/PluginsScreen.tsx +3 -1
- package/src/ui/state/reducer.js +5 -1
- package/src/ui/state/reducer.ts +6 -1
- package/src/ui/state/types.ts +8 -0
package/package.json
CHANGED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
tokenizeGeneralAliasBody,
|
|
7
|
+
findAdoptableAlias,
|
|
8
|
+
spliceManagedBlockAtRange,
|
|
9
|
+
renderAlias,
|
|
10
|
+
writeAliasToShell,
|
|
11
|
+
argsToFlagValuesWithLeftovers,
|
|
12
|
+
type AdoptableAlias,
|
|
13
|
+
type ShellTarget,
|
|
14
|
+
} from "../services/alias-shell-writer";
|
|
15
|
+
import { defaultAliasConfig } from "../services/alias-store";
|
|
16
|
+
|
|
17
|
+
// ─── tokenizeGeneralAliasBody ──────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
describe("tokenizeGeneralAliasBody", () => {
|
|
20
|
+
it("splits bare words on whitespace", () => {
|
|
21
|
+
expect(tokenizeGeneralAliasBody("claude --ide --effort high")).toEqual([
|
|
22
|
+
"claude",
|
|
23
|
+
"--ide",
|
|
24
|
+
"--effort",
|
|
25
|
+
"high",
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("treats single-quoted runs as literal (spaces preserved)", () => {
|
|
30
|
+
expect(
|
|
31
|
+
tokenizeGeneralAliasBody("claude --append-system-prompt 'be terse'"),
|
|
32
|
+
).toEqual(["claude", "--append-system-prompt", "be terse"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("treats double-quoted runs as literal", () => {
|
|
36
|
+
expect(
|
|
37
|
+
tokenizeGeneralAliasBody('claude --append-system-prompt "be terse"'),
|
|
38
|
+
).toEqual(["claude", "--append-system-prompt", "be terse"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("joins adjacent quoted + bare pieces into one token", () => {
|
|
42
|
+
// `foo' bar'baz` → one shell word `foo barbaz`
|
|
43
|
+
expect(tokenizeGeneralAliasBody("claude foo' bar'baz")).toEqual([
|
|
44
|
+
"claude",
|
|
45
|
+
"foo barbaz",
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("bails (null) on unterminated quotes", () => {
|
|
50
|
+
expect(tokenizeGeneralAliasBody("claude 'unterminated")).toBeNull();
|
|
51
|
+
expect(tokenizeGeneralAliasBody('claude "unterminated')).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("bails (null) on shell substitutions and variables", () => {
|
|
55
|
+
expect(tokenizeGeneralAliasBody("claude --foo $(date)")).toBeNull();
|
|
56
|
+
expect(tokenizeGeneralAliasBody("claude --foo `date`")).toBeNull();
|
|
57
|
+
expect(tokenizeGeneralAliasBody("claude --foo $HOME")).toBeNull();
|
|
58
|
+
// Even inside double quotes — we don't expand, so we can't round-trip it.
|
|
59
|
+
expect(tokenizeGeneralAliasBody('claude --foo "$HOME/x"')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ─── argsToFlagValuesWithLeftovers ─────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe("argsToFlagValuesWithLeftovers", () => {
|
|
66
|
+
it("reports no leftovers when every token is recognized", () => {
|
|
67
|
+
const { leftovers } = argsToFlagValuesWithLeftovers([
|
|
68
|
+
"--dangerously-skip-permissions",
|
|
69
|
+
"--effort",
|
|
70
|
+
"high",
|
|
71
|
+
]);
|
|
72
|
+
expect(leftovers).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("records an unknown bare flag as a leftover", () => {
|
|
76
|
+
const { leftovers } = argsToFlagValuesWithLeftovers(["--ide", "--unknown"]);
|
|
77
|
+
expect(leftovers).toEqual(["--unknown"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("records an unknown flag AND its consumed value", () => {
|
|
81
|
+
const { leftovers } = argsToFlagValuesWithLeftovers([
|
|
82
|
+
"--model",
|
|
83
|
+
"opus",
|
|
84
|
+
"--ide",
|
|
85
|
+
]);
|
|
86
|
+
expect(leftovers).toEqual(["--model", "opus"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("recognizes order-independently (no false leftovers on reorder)", () => {
|
|
90
|
+
// catalog order is ide(154) before effort(173); user wrote them reversed
|
|
91
|
+
const { flags, leftovers } = argsToFlagValuesWithLeftovers([
|
|
92
|
+
"--effort",
|
|
93
|
+
"high",
|
|
94
|
+
"--ide",
|
|
95
|
+
]);
|
|
96
|
+
expect(leftovers).toEqual([]);
|
|
97
|
+
expect(flags["ide"]).toEqual({ kind: "boolean", enabled: true });
|
|
98
|
+
expect(flags["effort"]).toEqual({
|
|
99
|
+
kind: "select",
|
|
100
|
+
enabled: true,
|
|
101
|
+
value: "high",
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ─── findAdoptableAlias ────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe("findAdoptableAlias", () => {
|
|
109
|
+
it("returns null when there's no claude-wrapping alias", () => {
|
|
110
|
+
const rc = "export PATH=$PATH:/usr/local/bin\nalias ll='ls -la'\n";
|
|
111
|
+
expect(findAdoptableAlias(rc)).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("detects a single-quoted hand-written claude alias and parses its flags", () => {
|
|
115
|
+
const rc =
|
|
116
|
+
"# rc\nalias c='claude --dangerously-skip-permissions'\nexport X=1\n";
|
|
117
|
+
const a = findAdoptableAlias(rc);
|
|
118
|
+
expect(a).not.toBeNull();
|
|
119
|
+
expect(a!.name).toBe("c");
|
|
120
|
+
expect(a!.flags["dangerously-skip-permissions"]).toEqual({
|
|
121
|
+
kind: "boolean",
|
|
122
|
+
enabled: true,
|
|
123
|
+
});
|
|
124
|
+
expect(a!.lineRange).toEqual([1, 1]);
|
|
125
|
+
expect(a!.leftovers).toEqual([]);
|
|
126
|
+
expect(a!.lossless).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("detects a double-quoted hand-written claude alias", () => {
|
|
130
|
+
const rc = 'alias cc="claude --ide"\n';
|
|
131
|
+
const a = findAdoptableAlias(rc);
|
|
132
|
+
expect(a).not.toBeNull();
|
|
133
|
+
expect(a!.name).toBe("cc");
|
|
134
|
+
expect(a!.flags["ide"]).toEqual({ kind: "boolean", enabled: true });
|
|
135
|
+
expect(a!.lossless).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("marks an alias with an uncatalogued flag as NOT lossless and lists the leftover", () => {
|
|
139
|
+
const rc = "alias c='claude --ide --model opus'\n";
|
|
140
|
+
const a = findAdoptableAlias(rc);
|
|
141
|
+
expect(a).not.toBeNull();
|
|
142
|
+
// The flags we DID recognize are imported for the user to see.
|
|
143
|
+
expect(a!.flags["ide"]).toEqual({ kind: "boolean", enabled: true });
|
|
144
|
+
// But the alias is not safe to absorb — its original line must be kept.
|
|
145
|
+
expect(a!.lossless).toBe(false);
|
|
146
|
+
expect(a!.leftovers).toEqual(["--model", "opus"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("is lossless regardless of flag ordering or quote style (round-trip-safe)", () => {
|
|
150
|
+
const rc = "alias c='claude --effort high --ide'\n";
|
|
151
|
+
const a = findAdoptableAlias(rc);
|
|
152
|
+
expect(a!.lossless).toBe(true);
|
|
153
|
+
expect(a!.leftovers).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("skips indented (non-top-level) alias lines", () => {
|
|
157
|
+
const rc = "my_func() {\n alias c='claude --ide'\n}\n";
|
|
158
|
+
expect(findAdoptableAlias(rc)).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("ignores an alias inside the managed block", () => {
|
|
162
|
+
const rc =
|
|
163
|
+
"# >>> claudeup managed alias >>>\nalias c='claude --ide'\n# <<< claudeup managed alias <<<\n";
|
|
164
|
+
expect(findAdoptableAlias(rc)).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("picks the first top-level claude alias and notes the others", () => {
|
|
168
|
+
const rc =
|
|
169
|
+
"alias c='claude --ide'\nalias cc='claude --worktree'\n";
|
|
170
|
+
const a = findAdoptableAlias(rc);
|
|
171
|
+
expect(a!.name).toBe("c");
|
|
172
|
+
expect(a!.others).toEqual(["alias cc='claude --worktree'"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("bails on a claude alias whose body contains a substitution", () => {
|
|
176
|
+
// Can't round-trip a $(...) — not adoptable at all.
|
|
177
|
+
const rc = "alias c='claude --append-system-prompt $(cat ~/p.txt)'\n";
|
|
178
|
+
expect(findAdoptableAlias(rc)).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ─── spliceManagedBlockAtRange ─────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe("spliceManagedBlockAtRange", () => {
|
|
185
|
+
const block =
|
|
186
|
+
"# >>> claudeup managed alias >>>\nalias c='claude --ide'\n# <<< claudeup managed alias <<<\n";
|
|
187
|
+
|
|
188
|
+
it("replaces the adopted line in place (no duplicate alias)", () => {
|
|
189
|
+
const rc = "# header\nalias c='claude --dangerously-skip-permissions'\nexport X=1\n";
|
|
190
|
+
const result = spliceManagedBlockAtRange(rc, block, [1, 1]);
|
|
191
|
+
// The old hand-written line is gone; the block sits where it was.
|
|
192
|
+
expect(result).not.toContain(
|
|
193
|
+
"alias c='claude --dangerously-skip-permissions'",
|
|
194
|
+
);
|
|
195
|
+
expect(result).toContain("# >>> claudeup managed alias >>>");
|
|
196
|
+
expect(result).toContain("# header");
|
|
197
|
+
expect(result).toContain("export X=1");
|
|
198
|
+
// Exactly one `alias c=` line.
|
|
199
|
+
const count = (result.match(/^alias c=/gm) ?? []).length;
|
|
200
|
+
expect(count).toBe(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("falls back to append when the range is out of bounds", () => {
|
|
204
|
+
const rc = "alias g='git'\n";
|
|
205
|
+
const result = spliceManagedBlockAtRange(rc, block, [99, 99]);
|
|
206
|
+
expect(result).toContain("alias g='git'");
|
|
207
|
+
expect(result).toContain("# >>> claudeup managed alias >>>");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ─── End-to-end: adopt → write yields a single, clean managed block ─────────
|
|
212
|
+
|
|
213
|
+
describe("adoption end-to-end", () => {
|
|
214
|
+
it("lossless adoption: parse → render → splice-at-range = one alias, original gone", () => {
|
|
215
|
+
const rc =
|
|
216
|
+
"# my zshrc\nexport EDITOR=vim\nalias c='claude --dangerously-skip-permissions'\n# eof\n";
|
|
217
|
+
const a = findAdoptableAlias(rc) as AdoptableAlias;
|
|
218
|
+
expect(a.lossless).toBe(true);
|
|
219
|
+
|
|
220
|
+
// Build a config from the adopted name + flags and render the block.
|
|
221
|
+
const config = defaultAliasConfig();
|
|
222
|
+
config.aliasName = a.name;
|
|
223
|
+
config.flags = { ...config.flags, ...a.flags };
|
|
224
|
+
const rendered = renderAlias(config, "zsh");
|
|
225
|
+
|
|
226
|
+
const next = spliceManagedBlockAtRange(rc, rendered.block, a.lineRange);
|
|
227
|
+
|
|
228
|
+
// Original hand-written line removed.
|
|
229
|
+
expect(next).not.toContain(
|
|
230
|
+
"alias c='claude --dangerously-skip-permissions'\n# eof",
|
|
231
|
+
);
|
|
232
|
+
// Managed block present with the same flag.
|
|
233
|
+
expect(next).toContain("# >>> claudeup managed alias >>>");
|
|
234
|
+
expect(next).toContain("--dangerously-skip-permissions");
|
|
235
|
+
// Exactly one `alias c=` and surrounding content intact.
|
|
236
|
+
expect((next.match(/^alias c=/gm) ?? []).length).toBe(1);
|
|
237
|
+
expect(next).toContain("export EDITOR=vim");
|
|
238
|
+
expect(next).toContain("# eof");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ─── writeAliasToShell honors adoptLineRange (file I/O) ─────────────────────
|
|
243
|
+
|
|
244
|
+
describe("writeAliasToShell — adoption path", () => {
|
|
245
|
+
let home: string;
|
|
246
|
+
|
|
247
|
+
beforeEach(async () => {
|
|
248
|
+
home = await mkdtemp(join(tmpdir(), "alias-adopt-write-"));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
afterEach(async () => {
|
|
252
|
+
await rm(home, { recursive: true, force: true });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("replaces the adopted line in place — never leaves a duplicate alias", async () => {
|
|
256
|
+
const path = join(home, ".zshrc");
|
|
257
|
+
// A real-world rc: hand-written `alias c=` plus surrounding content.
|
|
258
|
+
await writeFile(
|
|
259
|
+
path,
|
|
260
|
+
"export EDITOR=vim\nalias c='claude --dangerously-skip-permissions'\nalias g='git'\n",
|
|
261
|
+
"utf8",
|
|
262
|
+
);
|
|
263
|
+
const rc = await readFile(path, "utf8");
|
|
264
|
+
const a = findAdoptableAlias(rc) as AdoptableAlias;
|
|
265
|
+
expect(a.lossless).toBe(true);
|
|
266
|
+
|
|
267
|
+
const config = defaultAliasConfig();
|
|
268
|
+
config.aliasName = a.name;
|
|
269
|
+
config.flags = { ...config.flags, ...a.flags };
|
|
270
|
+
|
|
271
|
+
const target: ShellTarget = {
|
|
272
|
+
kind: "zsh",
|
|
273
|
+
path,
|
|
274
|
+
exists: true,
|
|
275
|
+
isDefault: true,
|
|
276
|
+
};
|
|
277
|
+
await writeAliasToShell(config, target, { adoptLineRange: a.lineRange });
|
|
278
|
+
|
|
279
|
+
const written = await readFile(path, "utf8");
|
|
280
|
+
// Original hand-written line removed.
|
|
281
|
+
expect(written).not.toContain(
|
|
282
|
+
"alias c='claude --dangerously-skip-permissions'\nalias g=",
|
|
283
|
+
);
|
|
284
|
+
// Managed block now owns the alias.
|
|
285
|
+
expect(written).toContain("# >>> claudeup managed alias >>>");
|
|
286
|
+
expect(written).toContain("--dangerously-skip-permissions");
|
|
287
|
+
// Exactly ONE `alias c=` definition (the whole point).
|
|
288
|
+
expect((written.match(/^alias c=/gm) ?? []).length).toBe(1);
|
|
289
|
+
// Surrounding lines preserved.
|
|
290
|
+
expect(written).toContain("export EDITOR=vim");
|
|
291
|
+
expect(written).toContain("alias g='git'");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("not-lossless: managed block under a DISTINCT name never shadows the surviving original", async () => {
|
|
295
|
+
// The not-lossless UI path keeps the original line and writes the managed
|
|
296
|
+
// block under a name distinct from the original (no adoptLineRange). This
|
|
297
|
+
// test pins the no-shadow outcome at the service layer: two differently-
|
|
298
|
+
// named aliases coexist, and the original keeps ALL its tokens.
|
|
299
|
+
const path = join(home, ".zshrc");
|
|
300
|
+
await writeFile(
|
|
301
|
+
path,
|
|
302
|
+
"export EDITOR=vim\nalias c='claude --dangerously-skip-permissions --model opus'\nalias g='git'\n",
|
|
303
|
+
"utf8",
|
|
304
|
+
);
|
|
305
|
+
const a = findAdoptableAlias(await readFile(path, "utf8")) as AdoptableAlias;
|
|
306
|
+
expect(a.lossless).toBe(false);
|
|
307
|
+
expect(a.leftovers).toEqual(["--model", "opus"]);
|
|
308
|
+
|
|
309
|
+
// Managed block uses a DIFFERENT name (mirrors pickDistinctName → "cm").
|
|
310
|
+
const config = defaultAliasConfig();
|
|
311
|
+
config.aliasName = "cm";
|
|
312
|
+
config.flags = { ...config.flags, ...a.flags };
|
|
313
|
+
const target: ShellTarget = {
|
|
314
|
+
kind: "zsh",
|
|
315
|
+
path,
|
|
316
|
+
exists: true,
|
|
317
|
+
isDefault: true,
|
|
318
|
+
};
|
|
319
|
+
// No adoptLineRange — the original line must be left untouched.
|
|
320
|
+
await writeAliasToShell(config, target, {});
|
|
321
|
+
|
|
322
|
+
const written = await readFile(path, "utf8");
|
|
323
|
+
// The original `alias c=` survives, complete with --model opus.
|
|
324
|
+
expect(written).toContain(
|
|
325
|
+
"alias c='claude --dangerously-skip-permissions --model opus'",
|
|
326
|
+
);
|
|
327
|
+
// The managed block uses the distinct name.
|
|
328
|
+
expect(written).toContain("alias cm='claude --dangerously-skip-permissions'");
|
|
329
|
+
// Exactly one of each name — no duplicate, no same-name shadow.
|
|
330
|
+
expect((written.match(/^alias c=/gm) ?? []).length).toBe(1);
|
|
331
|
+
expect((written.match(/^alias cm=/gm) ?? []).length).toBe(1);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("a SECOND write (range still passed) doesn't double the block — markers win", async () => {
|
|
335
|
+
const path = join(home, ".zshrc");
|
|
336
|
+
await writeFile(
|
|
337
|
+
path,
|
|
338
|
+
"alias c='claude --ide'\n",
|
|
339
|
+
"utf8",
|
|
340
|
+
);
|
|
341
|
+
const a = findAdoptableAlias(await readFile(path, "utf8")) as AdoptableAlias;
|
|
342
|
+
const config = defaultAliasConfig();
|
|
343
|
+
config.aliasName = a.name;
|
|
344
|
+
config.flags = { ...config.flags, ...a.flags };
|
|
345
|
+
const target: ShellTarget = {
|
|
346
|
+
kind: "zsh",
|
|
347
|
+
path,
|
|
348
|
+
exists: true,
|
|
349
|
+
isDefault: true,
|
|
350
|
+
};
|
|
351
|
+
// First write absorbs the line.
|
|
352
|
+
await writeAliasToShell(config, target, { adoptLineRange: a.lineRange });
|
|
353
|
+
// Second write with the SAME stale range — a managed block now exists, so
|
|
354
|
+
// the writer must ignore the range and splice by markers (idempotent).
|
|
355
|
+
await writeAliasToShell(config, target, { adoptLineRange: a.lineRange });
|
|
356
|
+
|
|
357
|
+
const written = await readFile(path, "utf8");
|
|
358
|
+
const beginCount = (
|
|
359
|
+
written.match(/# >>> claudeup managed alias >>>/g) ?? []
|
|
360
|
+
).length;
|
|
361
|
+
expect(beginCount).toBe(1);
|
|
362
|
+
expect((written.match(/^alias c=/gm) ?? []).length).toBe(1);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
tokenizePosixAliasBody,
|
|
7
7
|
argsToFlagValues,
|
|
8
8
|
renderAlias,
|
|
9
|
+
renderArgsAsTokens,
|
|
9
10
|
} from "../services/alias-shell-writer";
|
|
10
11
|
import { defaultAliasConfig } from "../services/alias-store";
|
|
11
12
|
|
|
@@ -295,6 +296,97 @@ describe("round-trip: render → parse → enabled subset matches", () => {
|
|
|
295
296
|
});
|
|
296
297
|
});
|
|
297
298
|
|
|
299
|
+
describe("channels: render-time union + parse-time provenance split", () => {
|
|
300
|
+
// The model: channels.values holds OWN channels only; dev-load values are
|
|
301
|
+
// unioned into --channels at render time and subtracted back out on parse,
|
|
302
|
+
// so the two lists stay distinct in storage but merge on the command line.
|
|
303
|
+
|
|
304
|
+
it("emits --channels as (own ∪ enabled dev-load), deduped, own-first", () => {
|
|
305
|
+
const c = defaultAliasConfig();
|
|
306
|
+
c.flags["channels"] = {
|
|
307
|
+
kind: "text-list",
|
|
308
|
+
enabled: true,
|
|
309
|
+
values: ["plugin:own@m"],
|
|
310
|
+
};
|
|
311
|
+
c.flags["dangerously-load-development-channels"] = {
|
|
312
|
+
kind: "text-list",
|
|
313
|
+
enabled: true,
|
|
314
|
+
values: ["plugin:dev@m", "plugin:own@m"], // overlap dedupes
|
|
315
|
+
};
|
|
316
|
+
const tokens = renderArgsAsTokens(c);
|
|
317
|
+
// --channels carries own + derived (deduped); dev-load still emits its own.
|
|
318
|
+
const channelsArgs = tokens
|
|
319
|
+
.map((t, i) => (tokens[i - 1] === "--channels" ? t : null))
|
|
320
|
+
.filter(Boolean);
|
|
321
|
+
expect(channelsArgs).toEqual(["plugin:own@m", "plugin:dev@m"]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("forces --channels to emit derived values even when own channels is OFF", () => {
|
|
325
|
+
const c = defaultAliasConfig();
|
|
326
|
+
c.flags["channels"] = { kind: "text-list", enabled: false, values: [] };
|
|
327
|
+
c.flags["dangerously-load-development-channels"] = {
|
|
328
|
+
kind: "text-list",
|
|
329
|
+
enabled: true,
|
|
330
|
+
values: ["plugin:dev@m"],
|
|
331
|
+
};
|
|
332
|
+
const tokens = renderArgsAsTokens(c);
|
|
333
|
+
expect(tokens).toContain("--channels");
|
|
334
|
+
const idx = tokens.indexOf("--channels");
|
|
335
|
+
expect(tokens[idx + 1]).toBe("plugin:dev@m");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("does NOT emit --channels when both own and dev-load are empty/off", () => {
|
|
339
|
+
const c = defaultAliasConfig(); // both channels + dev-load default off
|
|
340
|
+
expect(renderArgsAsTokens(c)).not.toContain("--channels");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("round-trips: derived values do NOT leak into parsed channels.values", () => {
|
|
344
|
+
const c = defaultAliasConfig();
|
|
345
|
+
c.aliasName = "c";
|
|
346
|
+
c.flags["channels"] = {
|
|
347
|
+
kind: "text-list",
|
|
348
|
+
enabled: true,
|
|
349
|
+
values: ["plugin:own@m"],
|
|
350
|
+
};
|
|
351
|
+
c.flags["dangerously-load-development-channels"] = {
|
|
352
|
+
kind: "text-list",
|
|
353
|
+
enabled: true,
|
|
354
|
+
values: ["plugin:dev@m"],
|
|
355
|
+
};
|
|
356
|
+
const rendered = renderAlias(c, "zsh").block;
|
|
357
|
+
const parsed = parseAliasFromRc(rendered);
|
|
358
|
+
// channels.values comes back as OWN ONLY — derived was subtracted.
|
|
359
|
+
expect(parsed!.flags["channels"]).toEqual({
|
|
360
|
+
kind: "text-list",
|
|
361
|
+
enabled: true,
|
|
362
|
+
values: ["plugin:own@m"],
|
|
363
|
+
});
|
|
364
|
+
// dev-load round-trips intact (it's the source of truth for derived).
|
|
365
|
+
expect(parsed!.flags["dangerously-load-development-channels"]).toEqual({
|
|
366
|
+
kind: "text-list",
|
|
367
|
+
enabled: true,
|
|
368
|
+
values: ["plugin:dev@m"],
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("round-trips a purely-derived channels list to disabled+empty own", () => {
|
|
373
|
+
const c = defaultAliasConfig();
|
|
374
|
+
c.aliasName = "c";
|
|
375
|
+
// No own channels; only dev-load.
|
|
376
|
+
c.flags["dangerously-load-development-channels"] = {
|
|
377
|
+
kind: "text-list",
|
|
378
|
+
enabled: true,
|
|
379
|
+
values: ["plugin:dev@m"],
|
|
380
|
+
};
|
|
381
|
+
const parsed = parseAliasFromRc(renderAlias(c, "zsh").block);
|
|
382
|
+
expect(parsed!.flags["channels"]).toEqual({
|
|
383
|
+
kind: "text-list",
|
|
384
|
+
enabled: false,
|
|
385
|
+
values: [],
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
298
390
|
describe("parseAliasFromRc — entry point", () => {
|
|
299
391
|
it("returns null on text with no managed block", () => {
|
|
300
392
|
expect(parseAliasFromRc("export PATH=...\n")).toBeNull();
|
|
@@ -92,7 +92,14 @@ describe("renderArgs", () => {
|
|
|
92
92
|
enabled: true,
|
|
93
93
|
values: ["plugin:claudish@magus", "plugin:dev@magus"],
|
|
94
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.
|
|
95
98
|
expect(renderArgs(c)).toEqual([
|
|
99
|
+
"--channels",
|
|
100
|
+
"plugin:claudish@magus",
|
|
101
|
+
"--channels",
|
|
102
|
+
"plugin:dev@magus",
|
|
96
103
|
"--dangerously-load-development-channels",
|
|
97
104
|
"plugin:claudish@magus",
|
|
98
105
|
"--dangerously-load-development-channels",
|
|
@@ -3,10 +3,20 @@ import {
|
|
|
3
3
|
defaultAliasConfig,
|
|
4
4
|
defaultValueFor,
|
|
5
5
|
validateAliasName,
|
|
6
|
+
derivedChannelValues,
|
|
7
|
+
withoutDerivedChannel,
|
|
8
|
+
listValues,
|
|
9
|
+
CHANNELS_FLAG_ID,
|
|
10
|
+
DEVLOAD_FLAG_ID,
|
|
6
11
|
DEFAULT_ALIAS_NAME,
|
|
12
|
+
type FlagValue,
|
|
7
13
|
} from "../services/alias-store";
|
|
8
14
|
import { ALIAS_FLAGS } from "../data/alias-flags";
|
|
9
15
|
|
|
16
|
+
function textList(values: string[], enabled = true): FlagValue {
|
|
17
|
+
return { kind: "text-list", enabled, values };
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
describe("defaultAliasConfig", () => {
|
|
11
21
|
it("seeds every known flag with a sane default and the default alias name", () => {
|
|
12
22
|
const c = defaultAliasConfig();
|
|
@@ -64,6 +74,73 @@ describe("defaultValueFor", () => {
|
|
|
64
74
|
});
|
|
65
75
|
});
|
|
66
76
|
|
|
77
|
+
describe("derivedChannelValues (derive-on-display model)", () => {
|
|
78
|
+
it("returns dev-load values when the dev-load flag is enabled", () => {
|
|
79
|
+
const flags: Record<string, FlagValue> = {
|
|
80
|
+
[DEVLOAD_FLAG_ID]: textList(["plugin:claudish@magus"], true),
|
|
81
|
+
[CHANNELS_FLAG_ID]: textList([], false),
|
|
82
|
+
};
|
|
83
|
+
expect(derivedChannelValues(flags)).toEqual(["plugin:claudish@magus"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns nothing when the dev-load flag is DISABLED", () => {
|
|
87
|
+
const flags: Record<string, FlagValue> = {
|
|
88
|
+
[DEVLOAD_FLAG_ID]: textList(["plugin:claudish@magus"], false),
|
|
89
|
+
[CHANNELS_FLAG_ID]: textList([], false),
|
|
90
|
+
};
|
|
91
|
+
expect(derivedChannelValues(flags)).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns nothing when the dev-load flag is absent", () => {
|
|
95
|
+
expect(derivedChannelValues({})).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does NOT read from channels.values (provenance stays separate)", () => {
|
|
99
|
+
// channels holds OWN values; dev-load is the only derived source.
|
|
100
|
+
const flags: Record<string, FlagValue> = {
|
|
101
|
+
[CHANNELS_FLAG_ID]: textList(["plugin:own@m"], true),
|
|
102
|
+
[DEVLOAD_FLAG_ID]: textList(["plugin:dev@m"], true),
|
|
103
|
+
};
|
|
104
|
+
expect(derivedChannelValues(flags)).toEqual(["plugin:dev@m"]);
|
|
105
|
+
// channels.values is untouched — own only.
|
|
106
|
+
expect(listValues(flags[CHANNELS_FLAG_ID])).toEqual(["plugin:own@m"]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("withoutDerivedChannel (delete-through to dev-load)", () => {
|
|
111
|
+
it("removes the value from dev-load, NOT from channels.values", () => {
|
|
112
|
+
const flags: Record<string, FlagValue> = {
|
|
113
|
+
[CHANNELS_FLAG_ID]: textList(["plugin:own@m"], true),
|
|
114
|
+
[DEVLOAD_FLAG_ID]: textList(["plugin:dev@m", "plugin:keep@m"], true),
|
|
115
|
+
};
|
|
116
|
+
const next = withoutDerivedChannel(flags, "plugin:dev@m");
|
|
117
|
+
expect(listValues(next[DEVLOAD_FLAG_ID])).toEqual(["plugin:keep@m"]);
|
|
118
|
+
// channels.values untouched — the deletion landed on the source flag.
|
|
119
|
+
expect(listValues(next[CHANNELS_FLAG_ID])).toEqual(["plugin:own@m"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("disables dev-load when its last value is removed", () => {
|
|
123
|
+
const flags: Record<string, FlagValue> = {
|
|
124
|
+
[DEVLOAD_FLAG_ID]: textList(["plugin:only@m"], true),
|
|
125
|
+
};
|
|
126
|
+
const next = withoutDerivedChannel(flags, "plugin:only@m");
|
|
127
|
+
expect(next[DEVLOAD_FLAG_ID]).toEqual({
|
|
128
|
+
kind: "text-list",
|
|
129
|
+
enabled: false,
|
|
130
|
+
values: [],
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("is a no-op when the value isn't present", () => {
|
|
135
|
+
const flags: Record<string, FlagValue> = {
|
|
136
|
+
[DEVLOAD_FLAG_ID]: textList(["plugin:a@m"], true),
|
|
137
|
+
};
|
|
138
|
+
expect(listValues(withoutDerivedChannel(flags, "nope")[DEVLOAD_FLAG_ID])).toEqual([
|
|
139
|
+
"plugin:a@m",
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
67
144
|
describe("validateAliasName", () => {
|
|
68
145
|
it("accepts safe identifiers", () => {
|
|
69
146
|
expect(validateAliasName("c")).toBeNull();
|