claudeup 5.0.1 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +4 -4
  2. package/scripts/test-isolated.ts +7 -1
  3. package/src/__tests__/cli-ansi.test.ts +297 -0
  4. package/src/__tests__/cli-apply-seams.test.ts +281 -0
  5. package/src/__tests__/cli-live.test.ts +384 -0
  6. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  7. package/src/__tests__/cli-update-view.test.ts +286 -0
  8. package/src/__tests__/format-command.test.ts +78 -0
  9. package/src/__tests__/gitignore-fixer.test.ts +20 -1
  10. package/src/__tests__/marketplace-refresh.test.ts +63 -0
  11. package/src/__tests__/shell-script-callers.test.ts +92 -0
  12. package/src/__tests__/toolchain.test.ts +176 -31
  13. package/src/__tests__/ui-version-writers.test.ts +88 -0
  14. package/src/__tests__/update-apply.test.ts +46 -1
  15. package/src/cli/ansi.ts +512 -0
  16. package/src/cli/bootstrap.ts +0 -6
  17. package/src/cli/install.ts +15 -6
  18. package/src/cli/live.ts +374 -0
  19. package/src/cli/profile.ts +1 -1
  20. package/src/cli/prompt.ts +0 -9
  21. package/src/cli/router.ts +7 -3
  22. package/src/cli/update-view.ts +441 -0
  23. package/src/cli/update.ts +357 -298
  24. package/src/data/cli-tools.ts +20 -13
  25. package/src/services/cli-tool-commands.ts +133 -0
  26. package/src/services/doctor-bins.ts +18 -4
  27. package/src/services/marketplace-refresh.ts +39 -1
  28. package/src/services/toolchain.ts +235 -33
  29. package/src/services/update-engine.ts +412 -0
  30. package/src/ui/renderers/cliToolRenderers.tsx +40 -39
  31. package/src/ui/screens/CliToolsScreen.tsx +56 -64
  32. package/src/ui/screens/PluginsScreen.tsx +119 -64
  33. package/src/utils/command-utils.ts +102 -1
  34. package/src/utils/run.ts +67 -0
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Tests for the live region.
3
+ *
4
+ * The invariant worth defending is the ORDER of writes around a permanent line.
5
+ * `note()` must erase the animated block, write, then repaint. Writing first
6
+ * puts the line inside the block, where the next repaint's cursor-up counts it
7
+ * as a frame row and starts eating the scrollback record — a data-loss bug that
8
+ * looks like a flicker.
9
+ *
10
+ * The console-capture suite exists because that ordering was documented as a
11
+ * rule and the rule was broken in production by code two call layers away
12
+ * (`plugin-manager`'s stale-marketplace advisory, reached through
13
+ * `getAvailablePlugins`). A rule spanning a call graph this module does not own
14
+ * has to be a mechanism, so these tests pin the mechanism.
15
+ *
16
+ * The clamps are the other half. A frame line that reaches the terminal's last
17
+ * column auto-wraps into two rows while the repainter still counts it as one,
18
+ * so the block walks down the screen a row per frame.
19
+ */
20
+
21
+ import { afterEach, describe, expect, test } from "bun:test";
22
+ import { LiveRegion, withPaused } from "../cli/live.js";
23
+
24
+ /** A write stream that records instead of drawing. */
25
+ function fakeStream(
26
+ opts: { isTTY?: boolean; columns?: number; rows?: number } = {},
27
+ ) {
28
+ const writes: string[] = [];
29
+ const listeners = new Map<string, Array<() => void>>();
30
+ const stream = {
31
+ write: (chunk: string) => {
32
+ writes.push(chunk);
33
+ return true;
34
+ },
35
+ on(event: string, fn: () => void) {
36
+ const list = listeners.get(event) ?? [];
37
+ list.push(fn);
38
+ listeners.set(event, list);
39
+ return this;
40
+ },
41
+ off(event: string, fn: () => void) {
42
+ const list = (listeners.get(event) ?? []).filter((f) => f !== fn);
43
+ listeners.set(event, list);
44
+ return this;
45
+ },
46
+ isTTY: opts.isTTY ?? true,
47
+ columns: opts.columns ?? 80,
48
+ rows: opts.rows ?? 24,
49
+ } as unknown as NodeJS.WriteStream & {
50
+ columns: number;
51
+ rows: number;
52
+ };
53
+ const emit = (event: string) => {
54
+ for (const fn of listeners.get(event) ?? []) fn();
55
+ };
56
+ return { stream, writes, emit, all: () => writes.join("") };
57
+ }
58
+
59
+ /**
60
+ * An animated region that needs no timer: paint via `refresh()`.
61
+ *
62
+ * `captureConsole` is OFF here. These tests assert on stream writes, and a
63
+ * region that hijacks the global console would also hijack the test runner's
64
+ * own reporting. The capture suite below opts back in deliberately.
65
+ */
66
+ function region(opts: Parameters<typeof fakeStream>[0] = {}) {
67
+ const fake = fakeStream(opts);
68
+ const live = new LiveRegion({
69
+ stream: fake.stream,
70
+ animate: true,
71
+ fps: 1000,
72
+ captureConsole: false,
73
+ });
74
+ return { ...fake, live };
75
+ }
76
+
77
+ describe("non-animated mode — a pipe, CI, or NO_COLOR", () => {
78
+ test("the transient block is never painted, but the record still prints", () => {
79
+ const fake = fakeStream({ isTTY: false });
80
+ const live = new LiveRegion({ stream: fake.stream, animate: false });
81
+ live.start(() => ["spinning"]);
82
+ live.refresh();
83
+ live.note("permanent one");
84
+ live.stop(["final"]);
85
+
86
+ expect(fake.writes).toEqual(["permanent one\n", "final\n"]);
87
+ });
88
+
89
+ test("no escape sequence of any kind reaches a pipe", () => {
90
+ const fake = fakeStream({ isTTY: false });
91
+ const live = new LiveRegion({ stream: fake.stream, animate: false });
92
+ live.start(() => ["frame"]);
93
+ live.note("line");
94
+ live.pause();
95
+ live.resume();
96
+ live.stop(["done"]);
97
+ expect(fake.all()).not.toContain("\x1b");
98
+ });
99
+
100
+ test("console is never captured when nothing is being painted", () => {
101
+ const fake = fakeStream({ isTTY: false });
102
+ const original = console.log;
103
+ const live = new LiveRegion({ stream: fake.stream, animate: false });
104
+ live.start(() => ["frame"]);
105
+ try {
106
+ expect(console.log).toBe(original);
107
+ } finally {
108
+ live.stop();
109
+ }
110
+ });
111
+ });
112
+
113
+ describe("animated mode", () => {
114
+ test("start hides the cursor and paints the frame", () => {
115
+ const { live, all } = region();
116
+ live.start(() => ["row one", "row two"]);
117
+ expect(all()).toContain("\x1b[?25l");
118
+ expect(all()).toContain("row one\nrow two\n");
119
+ live.stop();
120
+ });
121
+
122
+ test("a repaint erases exactly the rows it painted", () => {
123
+ const { live, writes } = region();
124
+ live.start(() => ["a", "b", "c"]);
125
+ writes.length = 0;
126
+ live.refresh();
127
+ // Three rows painted, so three rows up and erase below.
128
+ expect(writes[0]).toBe("\x1b[3A\x1b[0J");
129
+ live.stop();
130
+ });
131
+
132
+ test("note erases BEFORE writing, then repaints", () => {
133
+ const { live, writes } = region();
134
+ live.start(() => ["frame"]);
135
+ writes.length = 0;
136
+ live.note("kept");
137
+
138
+ expect(writes[0]).toBe("\x1b[1A\x1b[0J");
139
+ expect(writes[1]).toBe("kept\n");
140
+ expect(writes[2]).toBe("frame\n");
141
+ });
142
+
143
+ test("a frame that shrinks does not leave orphaned rows", () => {
144
+ let rows = ["a", "b", "c"];
145
+ const { live, writes } = region();
146
+ live.start(() => rows);
147
+ rows = ["a"];
148
+ writes.length = 0;
149
+ live.refresh();
150
+ // Erase-below clears the two rows the shorter frame no longer covers.
151
+ expect(writes[0]).toBe("\x1b[3A\x1b[0J");
152
+ expect(writes[1]).toBe("a\n");
153
+ live.stop();
154
+ });
155
+
156
+ test("a line is clipped one column short of the terminal width", () => {
157
+ const { live, writes } = region({ columns: 20 });
158
+ live.start(() => ["x".repeat(40)]);
159
+ const painted = writes.at(-1) ?? "";
160
+ expect(painted.replace(/\n$/, "")).toHaveLength(19);
161
+ live.stop();
162
+ });
163
+
164
+ test("the block is capped one row short of the terminal height", () => {
165
+ const { live, writes } = region({ rows: 6 });
166
+ live.start(() => Array.from({ length: 20 }, (_, i) => `row ${i}`));
167
+ const painted = (writes.at(-1) ?? "").split("\n").filter(Boolean);
168
+ expect(painted).toHaveLength(5);
169
+ live.stop();
170
+ });
171
+
172
+ test("stop erases the frame and restores the cursor", () => {
173
+ const { live, writes } = region();
174
+ live.start(() => ["frame"]);
175
+ writes.length = 0;
176
+ live.stop(["record"]);
177
+ expect(writes[0]).toBe("\x1b[1A\x1b[0J");
178
+ expect(writes[1]).toBe("record\n");
179
+ expect(writes[2]).toBe("\x1b[?25h");
180
+ });
181
+
182
+ test("stop with nothing painted still shows the cursor", () => {
183
+ const { live, all } = region();
184
+ live.start(() => []);
185
+ live.stop();
186
+ expect(all()).toContain("\x1b[?25h");
187
+ });
188
+ });
189
+
190
+ describe("resize", () => {
191
+ test("a resize abandons the old block instead of mis-erasing it", () => {
192
+ // `painted` is a row count measured at the OLD width. After a narrowing,
193
+ // the block occupies more rows than it says, so a cursor-up of `painted`
194
+ // lands inside it and smears. The only safe move is to stop claiming
195
+ // ownership of those rows.
196
+ const { live, writes, emit, stream } = region({ columns: 80 });
197
+ live.start(() => ["x".repeat(70)]);
198
+ writes.length = 0;
199
+ (stream as unknown as { columns: number }).columns = 40;
200
+ emit("resize");
201
+
202
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
203
+ const CURSOR_UP = /\x1b\[\d+A/;
204
+ expect(writes.some((w) => CURSOR_UP.test(w))).toBe(false);
205
+ expect(writes.at(-1)?.replace(/\n$/, "")).toHaveLength(39);
206
+ live.stop();
207
+ });
208
+
209
+ test("the resize listener is removed on stop", () => {
210
+ const { live, writes, emit } = region();
211
+ live.start(() => ["frame"]);
212
+ live.stop();
213
+ writes.length = 0;
214
+ emit("resize");
215
+ expect(writes).toEqual([]);
216
+ });
217
+ });
218
+
219
+ describe("handing the terminal over", () => {
220
+ test("pause erases the block and shows the cursor before the subprocess", () => {
221
+ const { live, writes } = region();
222
+ live.start(() => ["frame"]);
223
+ writes.length = 0;
224
+ live.pause();
225
+ expect(writes).toEqual(["\x1b[1A\x1b[0J", "\x1b[?25h"]);
226
+ });
227
+
228
+ test("note does NOT repaint while paused", () => {
229
+ // A repaint here draws the block underneath output from whatever now owns
230
+ // the terminal — corrupt, and in the way of a readline prompt.
231
+ const { live, writes } = region();
232
+ live.start(() => ["FRAME"]);
233
+ live.pause();
234
+ writes.length = 0;
235
+ live.note("subprocess said something");
236
+ expect(writes).toEqual(["subprocess said something\n"]);
237
+ });
238
+
239
+ test("resume repaints and re-hides", () => {
240
+ const { live, writes } = region();
241
+ live.start(() => ["frame"]);
242
+ live.pause();
243
+ writes.length = 0;
244
+ live.resume();
245
+ expect(writes[0]).toBe("\x1b[?25l");
246
+ expect(writes[1]).toBe("frame\n");
247
+ live.stop();
248
+ });
249
+
250
+ test("withPaused resumes even when the work throws", async () => {
251
+ const { live, writes } = region();
252
+ live.start(() => ["frame"]);
253
+ await expect(
254
+ withPaused(live, async () => {
255
+ throw new Error("installer failed");
256
+ }),
257
+ ).rejects.toThrow("installer failed");
258
+ // A resume repaint after the throw proves the region was handed back.
259
+ expect(writes.at(-1)).toBe("frame\n");
260
+ live.stop();
261
+ });
262
+
263
+ test("withPaused returns the work's value", async () => {
264
+ const { live } = region();
265
+ live.start(() => ["frame"]);
266
+ expect(await withPaused(live, async () => 42)).toBe(42);
267
+ live.stop();
268
+ });
269
+ });
270
+
271
+ describe("console capture — the invariant as a mechanism", () => {
272
+ /** Console is process-global: every test here must put it back. */
273
+ let restore: (() => void) | null = null;
274
+ afterEach(() => {
275
+ restore?.();
276
+ restore = null;
277
+ });
278
+
279
+ function capturing(opts: Parameters<typeof fakeStream>[0] = {}) {
280
+ const out = fakeStream(opts);
281
+ const err = fakeStream(opts);
282
+ const live = new LiveRegion({
283
+ stream: out.stream,
284
+ errorStream: err.stream,
285
+ animate: true,
286
+ fps: 1000,
287
+ });
288
+ const original = {
289
+ log: console.log,
290
+ info: console.info,
291
+ warn: console.warn,
292
+ error: console.error,
293
+ };
294
+ restore = () => {
295
+ live.stop();
296
+ Object.assign(console, original);
297
+ };
298
+ return { live, out, err };
299
+ }
300
+
301
+ test("a bare console.log from anywhere is routed, not left inside the block", () => {
302
+ // This is the production bug: plugin-manager's stale-marketplace advisory
303
+ // prints from two call layers below the catalog step. Without capture it
304
+ // lands under the block, the next cursor-up is short by a row, and the
305
+ // advisory is erased unread.
306
+ const { live, out } = capturing();
307
+ live.start(() => ["FRAME"]);
308
+ out.writes.length = 0;
309
+ console.log("ℹ Marketplace magus is stale");
310
+
311
+ expect(out.writes).toEqual([
312
+ "\x1b[1A\x1b[0J",
313
+ "ℹ Marketplace magus is stale\n",
314
+ "FRAME\n",
315
+ ]);
316
+ });
317
+
318
+ test("without capture the same call corrupts the block", () => {
319
+ // The negative control. This is what the code did before the fix, and it
320
+ // is why documenting the rule was not enough.
321
+ const out = fakeStream();
322
+ const live = new LiveRegion({
323
+ stream: out.stream,
324
+ animate: true,
325
+ fps: 1000,
326
+ captureConsole: false,
327
+ });
328
+ live.start(() => ["FRAME"]);
329
+ out.writes.length = 0;
330
+ out.stream.write("ℹ Marketplace magus is stale\n"); // an uncaptured write
331
+ live.refresh();
332
+ // One row up from a cursor now two rows below the block's start: the
333
+ // erase begins inside the frame rather than at its top.
334
+ expect(out.writes[1]).toBe("\x1b[1A\x1b[0J");
335
+ live.stop();
336
+ });
337
+
338
+ test("stderr stays on stderr", () => {
339
+ // Silently moving warn/error to stdout would break `2>err.log`.
340
+ const { live, out, err } = capturing();
341
+ live.start(() => ["FRAME"]);
342
+ out.writes.length = 0;
343
+ err.writes.length = 0;
344
+ console.warn("a warning");
345
+ console.error("an error");
346
+
347
+ expect(err.writes.filter((w) => !w.startsWith("\x1b"))).toEqual([
348
+ "a warning\n",
349
+ "an error\n",
350
+ ]);
351
+ expect(out.writes.some((w) => w.includes("a warning"))).toBe(false);
352
+ });
353
+
354
+ test("format specifiers and multi-line writes survive", () => {
355
+ // A newline inside one write is a row the erase arithmetic cannot see,
356
+ // so it has to be split into separate notes.
357
+ const { live, out } = capturing();
358
+ live.start(() => ["FRAME"]);
359
+ out.writes.length = 0;
360
+ console.log("%s has %d plugins\nsecond line", "magus", 12);
361
+
362
+ expect(out.writes).toContain("magus has 12 plugins\n");
363
+ expect(out.writes).toContain("second line\n");
364
+ });
365
+
366
+ test("console is released on stop", () => {
367
+ const original = console.log;
368
+ const { live } = capturing();
369
+ live.start(() => ["FRAME"]);
370
+ expect(console.log).not.toBe(original);
371
+ live.stop();
372
+ expect(console.log).toBe(original);
373
+ });
374
+
375
+ test("console is released while paused, so a subprocess writes directly", () => {
376
+ const original = console.log;
377
+ const { live } = capturing();
378
+ live.start(() => ["FRAME"]);
379
+ live.pause();
380
+ expect(console.log).toBe(original);
381
+ live.resume();
382
+ expect(console.log).not.toBe(original);
383
+ });
384
+ });
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The CLI-tools catalogue can no longer emit a bare `pip`, or shell syntax.
3
+ *
4
+ * Two defects lived in this screen, and both were data problems dressed as code
5
+ * problems:
6
+ *
7
+ * 1. **Bare `pip`.** `getUpdateCommand`'s `pip` and `default` arms returned the
8
+ * catalogue's `installCommand` verbatim, and `getUninstallCommand` emitted
9
+ * `pip uninstall -y`. `pip` is frequently a zsh alias rather than an
10
+ * executable, and a machine that manages Python tools with uv or pipx may
11
+ * have no `pip` at all. `services/toolchain.ts` already knew how to answer
12
+ * "how does Python work here"; this screen was not asking it.
13
+ * 2. **Shell syntax in data.** `aider`'s entry was
14
+ * `pip install aider-install && aider-install` — a `&&` chain in a catalogue
15
+ * field that got handed to `/bin/bash`.
16
+ *
17
+ * Both are now unrepresentable: the field is gone and the command is derived.
18
+ * These tests assert that over the whole catalogue rather than over an example,
19
+ * so a new entry cannot reintroduce either.
20
+ *
21
+ * Pure — no React, no network, no PATH dependency. Every Python installer is
22
+ * pinned explicitly, per the convention `toolchain.test.ts` established: the
23
+ * production default is resolved from PATH, so an unpinned assertion would
24
+ * encode whatever happens to be installed on the machine running the suite.
25
+ */
26
+
27
+ import { describe, expect, test } from "bun:test";
28
+ import { cliTools } from "../data/cli-tools.js";
29
+ import {
30
+ type InstallMethod,
31
+ cliToolInstall,
32
+ cliToolUninstall,
33
+ cliToolUpdate,
34
+ } from "../services/cli-tool-commands.js";
35
+ import {
36
+ PYTHON_CLI_INSTALLERS,
37
+ type PythonCliInstaller,
38
+ resolvePythonCliInstaller,
39
+ } from "../services/toolchain.js";
40
+ import { versionFromPypiJson } from "../ui/screens/CliToolsScreen.js";
41
+ import { type Command, formatCommand } from "../utils/command-utils.js";
42
+
43
+ const byProbe = (probe: string): PythonCliInstaller => {
44
+ const found = PYTHON_CLI_INSTALLERS.find((i) => i.probe === probe);
45
+ if (!found) throw new Error(`no python installer with probe ${probe}`);
46
+ return found;
47
+ };
48
+ const PYTHON_PROBES = PYTHON_CLI_INSTALLERS.map((i) => i.probe);
49
+
50
+ /** The methods the resolver models, i.e. the ones that reach toolchain.ts. */
51
+ const RESOLVED_METHODS: InstallMethod[] = ["npm", "bun", "brew", "pip"];
52
+
53
+ describe("the catalogue is well-formed", () => {
54
+ test("it has entries, and each names a package manager and a package", () => {
55
+ // Guard: every assertion below iterates the catalogue, so an empty or
56
+ // malformed one would make them all pass vacuously.
57
+ expect(cliTools.length).toBeGreaterThan(5);
58
+ for (const tool of cliTools) {
59
+ expect(typeof tool.packageName).toBe("string");
60
+ expect(tool.packageName.length).toBeGreaterThan(0);
61
+ expect(["bun", "npm", "pip", "brew", "go"]).toContain(
62
+ tool.packageManager,
63
+ );
64
+ }
65
+ });
66
+ });
67
+
68
+ describe("no builder ever emits a bare `pip`", () => {
69
+ test("install: cmd is the resolver's probe, never `pip`", () => {
70
+ for (const tool of cliTools) {
71
+ const cmd = cliToolInstall(tool);
72
+ expect(cmd.cmd).not.toBe("pip");
73
+ if (tool.packageManager === "pip") {
74
+ // Whatever the machine running this resolved to, it must be one of
75
+ // the three modelled installers — and `cmd` must BE that probe,
76
+ // since that is the executable the syscall looks up.
77
+ expect(PYTHON_PROBES).toContain(cmd.cmd);
78
+ expect(cmd.cmd).toBe(resolvePythonCliInstaller().probe);
79
+ }
80
+ }
81
+ });
82
+
83
+ test("update and uninstall: cmd is never `pip`, for any method", () => {
84
+ for (const tool of cliTools) {
85
+ for (const method of RESOLVED_METHODS) {
86
+ for (const cmd of [
87
+ cliToolUpdate(tool, method),
88
+ cliToolUninstall(tool, method),
89
+ ]) {
90
+ if (!cmd) continue;
91
+ expect(cmd.cmd).not.toBe("pip");
92
+ }
93
+ }
94
+ }
95
+ });
96
+
97
+ test("each pinned Python installer drives all three of its own commands", () => {
98
+ // Pinned explicitly so this holds on a runner with no uv. The `pip`
99
+ // method must route to the SAME executable for install, update and
100
+ // uninstall — installing with uv and uninstalling with python3 would look
101
+ // like it worked and leave the tool on disk.
102
+ for (const probe of PYTHON_PROBES) {
103
+ const python = byProbe(probe);
104
+ expect(python.install("x").cmd).toBe(probe);
105
+ expect(python.upgrade("x").cmd).toBe(probe);
106
+ expect(python.uninstall("x").cmd).toBe(probe);
107
+ }
108
+ });
109
+ });
110
+
111
+ describe("no builder can emit shell syntax", () => {
112
+ test("no argv element of any command contains &&, | or ;", () => {
113
+ // The catalogue's only `&&` chain was aider's installCommand. With the
114
+ // field gone there is nowhere left for shell syntax to enter, and this
115
+ // asserts that over every entry and every method rather than over that
116
+ // one case.
117
+ const all: Command[] = [];
118
+ for (const tool of cliTools) {
119
+ all.push(cliToolInstall(tool));
120
+ for (const method of [
121
+ ...RESOLVED_METHODS,
122
+ "pnpm" as const,
123
+ "yarn" as const,
124
+ ]) {
125
+ const u = cliToolUpdate(tool, method);
126
+ const r = cliToolUninstall(tool, method);
127
+ if (u) all.push(u);
128
+ if (r) all.push(r);
129
+ }
130
+ }
131
+ expect(all.length).toBeGreaterThan(0);
132
+
133
+ for (const cmd of all) {
134
+ for (const token of [cmd.cmd, ...cmd.args]) {
135
+ expect(token).not.toContain("&&");
136
+ expect(token).not.toContain("|");
137
+ expect(token).not.toContain(";");
138
+ }
139
+ }
140
+ });
141
+ });
142
+
143
+ describe("the detected method is followed, not the catalogue's", () => {
144
+ test("pnpm and yarn keep their own shapes", () => {
145
+ // A tool the catalogue calls npm can still have been installed with
146
+ // pnpm. Updating it as npm would install a second copy beside the first,
147
+ // which is the conflict this screen exists to report.
148
+ const tool = cliTools.find((t) => t.packageManager === "npm")!;
149
+ expect(cliToolUpdate(tool, "pnpm")).toEqual({
150
+ cmd: "pnpm",
151
+ args: ["install", "-g", tool.packageName],
152
+ });
153
+ expect(cliToolUpdate(tool, "yarn")).toEqual({
154
+ cmd: "yarn",
155
+ args: ["global", "add", tool.packageName],
156
+ });
157
+ expect(cliToolUninstall(tool, "pnpm")).toEqual({
158
+ cmd: "pnpm",
159
+ args: ["remove", "-g", tool.packageName],
160
+ });
161
+ expect(cliToolUninstall(tool, "yarn")).toEqual({
162
+ cmd: "yarn",
163
+ args: ["global", "remove", tool.packageName],
164
+ });
165
+ });
166
+
167
+ test("`unknown` returns null rather than guessing", () => {
168
+ // Preserves the observable behaviour: handleInstall reads
169
+ // `installed && updateCommand ? updateCommand : install…`, so null falls
170
+ // back to a plain install — which is what the old `default:` arm did by
171
+ // returning `tool.installCommand`.
172
+ const tool = cliTools[0]!;
173
+ expect(cliToolUpdate(tool, "unknown")).toBeNull();
174
+ expect(cliToolUninstall(tool, "unknown")).toBeNull();
175
+ });
176
+
177
+ test("brew and go arms are pinned, since no catalogue entry exercises them", () => {
178
+ // Every entry today is bun/npm/pip, so these two ship untouched by real
179
+ // data. `go` has no uninstaller — `go install` writes a binary and
180
+ // records nothing — so null is correct rather than an invented `rm`.
181
+ const tool = cliTools[0]!;
182
+ expect(cliToolUpdate(tool, "brew", "some-formula")).toEqual({
183
+ cmd: "brew",
184
+ args: ["upgrade", "some-formula"],
185
+ });
186
+ expect(cliToolUninstall(tool, "brew", "some-formula")).toEqual({
187
+ cmd: "brew",
188
+ args: ["uninstall", "some-formula"],
189
+ });
190
+ });
191
+ });
192
+
193
+ describe("derived commands still read as they did", () => {
194
+ test("claude renders exactly the literal the catalogue used to carry", () => {
195
+ // The entry whose command SHOULD not change. Its old
196
+ // `installCommand: "npm install -g @anthropic-ai/claude-code"` is now
197
+ // derived from packageManager + packageName, and must render identically.
198
+ const claude = cliTools.find((t) => t.name === "claude")!;
199
+ expect(formatCommand(cliToolInstall(claude))).toBe(
200
+ "npm install -g @anthropic-ai/claude-code",
201
+ );
202
+ });
203
+
204
+ test("aider now installs the package its own fields already named", () => {
205
+ // The one deliberate behaviour change. The old command installed
206
+ // `aider-install`, a bootstrapper whose version nothing here ever read,
207
+ // while `packageName`, `checkCommand` and the PyPI query all said
208
+ // `aider-chat`. The installCommand was the field that was out of step.
209
+ const aider = cliTools.find((t) => t.name === "aider")!;
210
+ expect(aider.packageName).toBe("aider-chat");
211
+ expect(cliToolInstall(aider).args).toContain("aider-chat");
212
+ expect(formatCommand(cliToolInstall(aider))).not.toContain("aider-install");
213
+ });
214
+ });
215
+
216
+ describe("versionFromPypiJson", () => {
217
+ // The shape of a real https://pypi.org/pypi/<pkg>/json body, trimmed to the
218
+ // two fields that matter. Captured rather than mocked so the parser is
219
+ // tested against what the endpoint actually returns.
220
+ const body = {
221
+ info: { name: "aider-chat", version: "0.86.1" },
222
+ releases: {},
223
+ };
224
+
225
+ test("reads info.version", () => {
226
+ expect(versionFromPypiJson(body)).toBe("0.86.1");
227
+ });
228
+
229
+ test("anything unexpected yields undefined, never a throw", () => {
230
+ // A version check is advisory: it must degrade to "no update badge",
231
+ // never take the screen down. PyPI returning an error object, a 404 body,
232
+ // or nothing at all all land here.
233
+ expect(versionFromPypiJson({})).toBeUndefined();
234
+ expect(versionFromPypiJson({ info: {} })).toBeUndefined();
235
+ expect(versionFromPypiJson({ info: { version: 42 } })).toBeUndefined();
236
+ expect(versionFromPypiJson(null)).toBeUndefined();
237
+ expect(versionFromPypiJson("not json at all")).toBeUndefined();
238
+ });
239
+ });