magus-cli 7.10.2 → 7.10.4

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 (39) 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 +392 -0
  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 -20
  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__/tool-adopt-brew-owner.test.ts +167 -0
  18. package/src/__tests__/tool-probe.test.ts +231 -144
  19. package/src/__tests__/tool-rows-followups.test.ts +315 -0
  20. package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
  21. package/src/__tests__/tool-versions.test.ts +164 -0
  22. package/src/__tests__/toolchain.test.ts +0 -2
  23. package/src/__tests__/update-plan.test.ts +40 -9
  24. package/src/cli/update-view.ts +182 -68
  25. package/src/cli/update.ts +44 -37
  26. package/src/data/cli-tools.ts +5 -10
  27. package/src/data/tool-versions.ts +137 -0
  28. package/src/services/cli-tool-commands.ts +1 -2
  29. package/src/services/cli-tool-version.ts +10 -88
  30. package/src/services/resolver.ts +9 -15
  31. package/src/services/tool-probe.ts +557 -424
  32. package/src/services/toolchain.ts +10 -4
  33. package/src/services/update-engine.ts +9 -15
  34. package/src/services/update-plan.ts +49 -18
  35. package/src/types/index.ts +0 -2
  36. package/src/ui/adapters/cliToolsAdapter.ts +31 -22
  37. package/src/ui/renderers/cliToolRenderers.tsx +15 -10
  38. package/src/ui/state/types.ts +0 -1
  39. package/src/__tests__/blind/c9-cli-tool-version.test.ts +0 -239
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Blind tests, contract C19: a KEPT row names who installed the file, shows its
3
+ * version whenever it can be read, drives a self-updating standalone installer
4
+ * (uv's receipt), and reports a newer copy hidden later on PATH.
5
+ *
6
+ * Written from the spec only, through a scripted fake `ToolProbeIO`.
7
+ *
8
+ * Merged with the tool-rows design (docs/plans/2026-10-08-magus-update-tool-rows.md,
9
+ * decision 7). Kept as written: the uv receipt cases (A1–A5) and the guards.
10
+ * Changed, because they contradict that design's rules:
11
+ * - An `external` row carries no note, so the owner sentences (A6, B7–B10,
12
+ * C11–C12) now expect none. They were read off Cellar, Caskroom and cargo
13
+ * paths, which the design forbids. A pinned tool no installer has (B10) is
14
+ * `external`, not `unknown`.
15
+ * - zsh's version comes from magus's own version command (`zsh --version`,
16
+ * bare name), not from running a file chosen by a path test, so C's fake
17
+ * world answers that command.
18
+ * - A copy later on PATH is counted (`otherCopies`), never described: its
19
+ * owner and version were read off its path, and the warning named a PATH
20
+ * folder with nothing for the user to run. So D expects a count, and an
21
+ * older copy (D14) counts too — telling older from newer would need that
22
+ * same guess.
23
+ */
24
+ import { describe, expect, test } from "bun:test";
25
+ import {
26
+ type ExecResult,
27
+ type FetchJsonResult,
28
+ type ToolProbeIO,
29
+ classifyTool,
30
+ probeTool,
31
+ } from "../../services/tool-probe.js";
32
+ import type { BinRequirement } from "../../types/index.js";
33
+
34
+ interface FakeSetup {
35
+ exec?: Record<string, Partial<ExecResult>>;
36
+ fetch?: Record<string, FetchJsonResult>;
37
+ files?: Record<string, string>;
38
+ real?: Record<string, string>;
39
+ onPath?: Record<string, string>;
40
+ allOnPath?: Record<string, string[]>;
41
+ env?: Record<string, string>;
42
+ noAll?: boolean;
43
+ }
44
+
45
+ const HOME = "/home/u";
46
+
47
+ function fakeIO(
48
+ setup: FakeSetup,
49
+ ): ToolProbeIO & { calls: string[]; reads: string[] } {
50
+ const calls: string[] = [];
51
+ const reads: string[] = [];
52
+ const io: ToolProbeIO & { calls: string[]; reads: string[] } = {
53
+ calls,
54
+ reads,
55
+ exec: async (command) => {
56
+ const key = [command.cmd, ...command.args].join(" ");
57
+ calls.push(key);
58
+ const h = setup.exec?.[key];
59
+ if (h === undefined) {
60
+ return {
61
+ code: null,
62
+ stdout: "",
63
+ stderr: "",
64
+ timedOut: false,
65
+ missing: true,
66
+ };
67
+ }
68
+ return {
69
+ code: 0,
70
+ stdout: "",
71
+ stderr: "",
72
+ timedOut: false,
73
+ missing: false,
74
+ ...h,
75
+ };
76
+ },
77
+ fetchJson: async (url) =>
78
+ setup.fetch?.[url] ?? { ok: false, reason: "returned 404" },
79
+ readJson: async (file) => {
80
+ reads.push(file);
81
+ const t = setup.files?.[file];
82
+ if (t === undefined) return null;
83
+ try {
84
+ return JSON.parse(t);
85
+ } catch {
86
+ return null;
87
+ }
88
+ },
89
+ readText: async (file) => setup.files?.[file] ?? null,
90
+ readlink: async () => null,
91
+ realpath: async (file) => setup.real?.[file] ?? null,
92
+ resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
93
+ env: setup.env ?? {},
94
+ homedir: HOME,
95
+ };
96
+ if (!setup.noAll) {
97
+ io.resolveAllExecutables = async (name: string) =>
98
+ setup.allOnPath?.[name] ??
99
+ (setup.onPath?.[name] !== undefined
100
+ ? [setup.onPath[name] as string]
101
+ : []);
102
+ }
103
+ return io;
104
+ }
105
+
106
+ const PREFIX = "/opt/homebrew";
107
+ const BREW_PREFIX_OK: Record<string, Partial<ExecResult>> = {
108
+ "brew --prefix": { stdout: `${PREFIX}\n` },
109
+ };
110
+ const CELLAR = { "/opt/homebrew/Cellar": "/opt/homebrew/Cellar" };
111
+
112
+ const UV: BinRequirement = { name: "uv", via: "brew", formula: "uv" };
113
+ const UV_FILE = "/home/u/.local/bin/uv";
114
+ const UV_RECEIPT_PATH = "/home/u/.config/uv/uv-receipt.json";
115
+ const uvReceipt = (prefix = "/home/u/.local/bin", version = "0.11.15") =>
116
+ JSON.stringify({
117
+ binaries: ["uv", "uvx"],
118
+ install_prefix: prefix,
119
+ source: { app_name: "uv", name: "uv", owner: "astral-sh" },
120
+ version,
121
+ });
122
+ const pypi = (v: string): Record<string, FetchJsonResult> => ({
123
+ "https://pypi.org/pypi/uv/json": { ok: true, body: { info: { version: v } } },
124
+ });
125
+
126
+ function uvWorld(opts: {
127
+ latest?: string;
128
+ receiptPrefix?: string;
129
+ receiptAt?: string;
130
+ env?: Record<string, string>;
131
+ extra?: Partial<FakeSetup>;
132
+ }): FakeSetup {
133
+ return {
134
+ onPath: { uv: UV_FILE },
135
+ real: { [UV_FILE]: UV_FILE },
136
+ files: {
137
+ [opts.receiptAt ?? UV_RECEIPT_PATH]: uvReceipt(opts.receiptPrefix),
138
+ },
139
+ fetch: pypi(opts.latest ?? "0.13.0"),
140
+ env: opts.env ?? {},
141
+ ...opts.extra,
142
+ };
143
+ }
144
+
145
+ describe("C19 A: uv's own installer is a driven owner", () => {
146
+ test("1. receipt, PyPI newer: managed, upgrade, uv self update", async () => {
147
+ const f = await probeTool(UV, fakeIO(uvWorld({})));
148
+ expect(f.installed).toBe("0.11.15");
149
+ expect(f.managed).toBe(true);
150
+ expect(f.latest).toBe("0.13.0");
151
+ expect(f.upgradeCommand).toEqual({ cmd: "uv", args: ["self", "update"] });
152
+ const s = classifyTool(UV, f);
153
+ expect(s.state).toBe("upgrade");
154
+ expect(s.target).toBe("0.13.0");
155
+ });
156
+
157
+ test("2. PyPI equals installed: current", async () => {
158
+ const f = await probeTool(UV, fakeIO(uvWorld({ latest: "0.11.15" })));
159
+ expect(classifyTool(UV, f).state).toBe("current");
160
+ });
161
+
162
+ test("3. pinned spec: upgrade to the pin with a versioned command", async () => {
163
+ const spec: BinRequirement = { ...UV, version: "0.12.0" };
164
+ const f = await probeTool(spec, fakeIO(uvWorld({})));
165
+ expect(f.upgradeCommand).toEqual({
166
+ cmd: "uv",
167
+ args: ["self", "update", "0.12.0"],
168
+ });
169
+ const s = classifyTool(spec, f);
170
+ expect(s.state).toBe("upgrade");
171
+ expect(s.target).toBe("0.12.0");
172
+ });
173
+
174
+ test("4. receipt for a different install_prefix is ignored", async () => {
175
+ const f = await probeTool(
176
+ UV,
177
+ fakeIO(uvWorld({ receiptPrefix: "/somewhere/else" })),
178
+ );
179
+ expect(f.managed).not.toBe(true);
180
+ expect(f.upgradeCommand).toBeUndefined();
181
+ expect(classifyTool(UV, f).state).not.toBe("upgrade");
182
+ });
183
+
184
+ test("5. XDG_CONFIG_HOME relocates the receipt", async () => {
185
+ const io = fakeIO(
186
+ uvWorld({
187
+ env: { XDG_CONFIG_HOME: "/xdg" },
188
+ extra: {
189
+ // a decoy at the default place must not be the one used
190
+ files: {
191
+ "/xdg/uv/uv-receipt.json": uvReceipt(),
192
+ [UV_RECEIPT_PATH]: uvReceipt("/home/u/.local/bin", "9.9.9"),
193
+ },
194
+ },
195
+ }),
196
+ );
197
+ const f = await probeTool(UV, io);
198
+ expect(f.installed).toBe("0.11.15");
199
+ expect(f.managed).toBe(true);
200
+ expect(io.reads).toContain("/xdg/uv/uv-receipt.json");
201
+ expect(io.reads).not.toContain(UV_RECEIPT_PATH);
202
+ });
203
+
204
+ test("6. receipt of an installer magus cannot update: external, versioned, named", async () => {
205
+ const spec: BinRequirement = { name: "ruff", via: "brew", formula: "ruff" };
206
+ const file = "/home/u/.local/bin/ruff";
207
+ const f = await probeTool(
208
+ spec,
209
+ fakeIO({
210
+ onPath: { ruff: file },
211
+ real: { [file]: file },
212
+ files: {
213
+ "/home/u/.config/ruff/ruff-receipt.json": JSON.stringify({
214
+ binaries: ["ruff"],
215
+ install_prefix: "/home/u/.local/bin",
216
+ source: { app_name: "ruff", name: "ruff", owner: "astral-sh" },
217
+ version: "0.9.1",
218
+ }),
219
+ },
220
+ exec: BREW_PREFIX_OK,
221
+ }),
222
+ );
223
+ expect(f.installed).toBe("0.9.1");
224
+ expect(f.upgradeCommand).toBeUndefined();
225
+ const s = classifyTool(spec, f);
226
+ expect(s.state).toBe("external");
227
+ expect(s.note).toBeUndefined();
228
+ });
229
+ });
230
+
231
+ describe("C19 B: a file no driven installer has is installed elsewhere, with no note", () => {
232
+ const spec = (
233
+ name: string,
234
+ extra: Partial<BinRequirement> = {},
235
+ ): BinRequirement => ({
236
+ name,
237
+ via: "brew",
238
+ formula: name,
239
+ ...extra,
240
+ });
241
+ const world = (
242
+ name: string,
243
+ file: string,
244
+ env: Record<string, string> = {},
245
+ ): FakeSetup => ({
246
+ onPath: { [name]: file },
247
+ real: { [file]: file, ...CELLAR },
248
+ exec: BREW_PREFIX_OK,
249
+ env,
250
+ });
251
+
252
+ test("7. Homebrew cask", async () => {
253
+ const sp = spec("madbench");
254
+ const file = "/opt/homebrew/Caskroom/madbench/0.43.2/madbench";
255
+ const f = await probeTool(sp, fakeIO(world("madbench", file)));
256
+ const s = classifyTool(sp, f);
257
+ expect(s.state).toBe("external");
258
+ expect(s.note).toBeUndefined();
259
+ });
260
+
261
+ test("8a. cargo bin under the home directory", async () => {
262
+ const sp = spec("rg");
263
+ const f = await probeTool(sp, fakeIO(world("rg", "/home/u/.cargo/bin/rg")));
264
+ const s = classifyTool(sp, f);
265
+ expect(s.state).toBe("external");
266
+ expect(s.note).toBeUndefined();
267
+ });
268
+
269
+ test("8b. cargo bin under CARGO_HOME", async () => {
270
+ const sp = spec("rg");
271
+ const f = await probeTool(
272
+ sp,
273
+ fakeIO(world("rg", "/opt/cargo/bin/rg", { CARGO_HOME: "/opt/cargo" })),
274
+ );
275
+ const s = classifyTool(sp, f);
276
+ expect(s.state).toBe("external");
277
+ expect(s.note).toBeUndefined();
278
+ });
279
+
280
+ test("9. unrecognised place, never says 'outside'", async () => {
281
+ const sp = spec("foo");
282
+ const f = await probeTool(
283
+ sp,
284
+ fakeIO(world("foo", "/home/u/tools/bin/foo")),
285
+ );
286
+ const s = classifyTool(sp, f);
287
+ expect(s.state).toBe("external");
288
+ expect(s.note).toBeUndefined();
289
+ });
290
+
291
+ test("10. pinned spec with a cargo file", async () => {
292
+ const sp = spec("rg", { version: "1.0.0" });
293
+ const f = await probeTool(sp, fakeIO(world("rg", "/home/u/.cargo/bin/rg")));
294
+ const s = classifyTool(sp, f);
295
+ expect(s.state).toBe("external");
296
+ expect(s.note).toBeUndefined();
297
+ });
298
+ });
299
+
300
+ describe("C19 C: operating-system programs show their version", () => {
301
+ const zsh: BinRequirement = { name: "zsh", via: "brew", formula: "zsh" };
302
+ const world = (zshExec?: Partial<ExecResult>): FakeSetup => ({
303
+ onPath: { zsh: "/bin/zsh" },
304
+ real: { "/bin/zsh": "/bin/zsh", ...CELLAR },
305
+ exec: {
306
+ ...BREW_PREFIX_OK,
307
+ ...(zshExec ? { "zsh --version": zshExec } : {}),
308
+ },
309
+ });
310
+
311
+ test("11. version read from --version", async () => {
312
+ const f = await probeTool(
313
+ zsh,
314
+ fakeIO(world({ stdout: "zsh 5.9 (arm64-apple-darwin25.0)\n" })),
315
+ );
316
+ expect(f.installed).toBe("5.9");
317
+ const s = classifyTool(zsh, f);
318
+ expect(s.state).toBe("external");
319
+ expect(s.note).toBeUndefined();
320
+ });
321
+
322
+ test("12a. --version exits non-zero: still external, installed null", async () => {
323
+ const f = await probeTool(
324
+ zsh,
325
+ fakeIO(world({ code: 1, stdout: "", stderr: "" })),
326
+ );
327
+ expect(f.installed).toBeNull();
328
+ const s = classifyTool(zsh, f);
329
+ expect(s.state).toBe("external");
330
+ expect(s.note).toBeUndefined();
331
+ });
332
+
333
+ test("12b. --version missing: still external, installed null", async () => {
334
+ const f = await probeTool(zsh, fakeIO(world()));
335
+ expect(f.installed).toBeNull();
336
+ const s = classifyTool(zsh, f);
337
+ expect(s.state).toBe("external");
338
+ expect(s.note).toBeUndefined();
339
+ });
340
+ });
341
+
342
+ describe("C19 D: another copy later on PATH is counted", () => {
343
+ const BREW_BIN = "/opt/homebrew/bin/uv";
344
+ const cellarFile = (v: string) => `/opt/homebrew/Cellar/uv/${v}/bin/uv`;
345
+ const world = (second: string, secondReal: string): FakeSetup =>
346
+ uvWorld({
347
+ extra: {
348
+ allOnPath: { uv: [UV_FILE, second] },
349
+ real: { [UV_FILE]: UV_FILE, [second]: secondReal, ...CELLAR },
350
+ exec: BREW_PREFIX_OK,
351
+ env: { HOMEBREW_PREFIX: PREFIX },
352
+ },
353
+ });
354
+
355
+ test("13. a Homebrew copy later on PATH: counted, no path, no warning", async () => {
356
+ const f = await probeTool(
357
+ UV,
358
+ fakeIO(world(BREW_BIN, cellarFile("0.13.0"))),
359
+ );
360
+ expect(f.otherCopies).toBe(1);
361
+ expect(JSON.stringify(classifyTool(UV, f))).not.toContain("/");
362
+ });
363
+
364
+ test("14. an older later copy is counted the same: no version is guessed", async () => {
365
+ const f = await probeTool(
366
+ UV,
367
+ fakeIO(world(BREW_BIN, cellarFile("0.10.0"))),
368
+ );
369
+ expect(f.otherCopies).toBe(1);
370
+ });
371
+
372
+ test("15. a symlink to the running file is not another copy", async () => {
373
+ const f = await probeTool(UV, fakeIO(world("/usr/local/bin/uv", UV_FILE)));
374
+ expect(f.otherCopies).toBeUndefined();
375
+ });
376
+
377
+ test("16. IO without resolveAllExecutables: nothing reported, nothing throws", async () => {
378
+ const w = world(BREW_BIN, cellarFile("0.13.0"));
379
+ w.noAll = true;
380
+ const f = await probeTool(UV, fakeIO(w));
381
+ expect(f.otherCopies).toBeUndefined();
382
+ });
383
+
384
+ test("17. a current row is counted too", async () => {
385
+ const w = world(BREW_BIN, cellarFile("0.13.0"));
386
+ w.fetch = pypi("0.11.15");
387
+ const f = await probeTool(UV, fakeIO(w));
388
+ const s = classifyTool(UV, f);
389
+ expect(s.state).toBe("current");
390
+ expect(f.otherCopies).toBe(1);
391
+ });
392
+ });
@@ -93,11 +93,12 @@ describe("the catalogue is well-formed", () => {
93
93
  const declared = declaredRequirements();
94
94
  expect(declared.size).toBeGreaterThan(0);
95
95
  for (const entry of catalogue) {
96
+ // No entry carries a version command: that is data/tool-versions.ts.
97
+ expect("checkCommand" in entry).toBe(false);
96
98
  if (isOwnedEntry(entry)) {
97
99
  const [plugin] = entry.ownedBy.split("@");
98
100
  expect(declared.get(entry.name) ?? []).toContain(plugin);
99
101
  expect("packageName" in entry).toBe(false);
100
- expect("checkCommand" in entry).toBe(false);
101
102
  } else {
102
103
  expect(declared.has(entry.name)).toBe(false);
103
104
  }
@@ -124,7 +125,6 @@ describe("the catalogue is well-formed", () => {
124
125
  expect(
125
126
  claudish && !isPluginManagedItem(claudish) && claudish.tool,
126
127
  ).toMatchObject({
127
- checkCommand: "claudish --version",
128
128
  packageManager: "bun",
129
129
  packageName: "claudish",
130
130
  });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * A tool installed another way (`external`), and one held back, on the CLI
3
+ * Tools screen (docs/plans/2026-10-08-magus-update-tool-rows.md, decisions 5
4
+ * and 6).
5
+ *
6
+ * The screen is an inventory, so the row stays — but it is not a warning and
7
+ * not "Up to date": no `?`, a muted icon, "installed elsewhere", a detail
8
+ * status that says the same, and no Enter action. The list icon and the
9
+ * detail Status line are if-chains the compiler does not check, which is why
10
+ * each branch is pinned here. Rendered through OpenTUI's test renderer; no IO.
11
+ */
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+ import { testRender } from "@opentui/react/test-utils";
15
+ import { act } from "react";
16
+ import { cliTools } from "../data/cli-tools.js";
17
+ import type { CliTool } from "../data/cli-tools.js";
18
+ import type { ToolStatus } from "../services/tool-probe.js";
19
+ import type { CliToolRow } from "../ui/adapters/cliToolsAdapter.js";
20
+ import {
21
+ renderCliToolDetail,
22
+ renderCliToolRow,
23
+ } from "../ui/renderers/cliToolRenderers.js";
24
+
25
+ const TOOL = cliTools.find((t) => t.name === "crush") as CliTool;
26
+
27
+ function row(status: Pick<ToolStatus, "state" | "reason">): CliToolRow {
28
+ return {
29
+ tool: TOOL,
30
+ spec: { name: TOOL.name, via: "npm", package: TOOL.packageName },
31
+ checking: false,
32
+ status: {
33
+ name: TOOL.name,
34
+ installed: "0.55.0",
35
+ latest: null,
36
+ target: null,
37
+ ...status,
38
+ },
39
+ updateCommand: { cmd: "npm", args: ["install", "-g", "x@latest"] },
40
+ installCommand: { cmd: "npm", args: ["install", "-g", "x"] },
41
+ };
42
+ }
43
+
44
+ const EXTERNAL = row({ state: "external", reason: "installed-elsewhere" });
45
+ const CURRENT = row({ state: "current", reason: "up-to-date" });
46
+
47
+ async function render(node: React.ReactNode, width = 80) {
48
+ const { captureCharFrame, captureSpans, renderOnce, renderer } =
49
+ await testRender(<box flexDirection="column">{node}</box>, {
50
+ width,
51
+ height: 24,
52
+ });
53
+ await act(async () => {
54
+ await renderOnce();
55
+ await renderOnce();
56
+ });
57
+ const out = { chars: captureCharFrame(), lines: captureSpans().lines };
58
+ await act(async () => renderer.destroy());
59
+ return out;
60
+ }
61
+
62
+ /** The colour of the row's ● icon. */
63
+ async function iconColour(r: CliToolRow): Promise<string> {
64
+ const { lines } = await render(renderCliToolRow(r, 0, false));
65
+ for (const line of lines) {
66
+ const span = line.spans.find((s) => s.text.includes("●"));
67
+ if (span) return JSON.stringify(span.fg);
68
+ }
69
+ throw new Error("no ● icon on the row");
70
+ }
71
+
72
+ describe("an external CLI tool row", () => {
73
+ test("the row says installed elsewhere, with no ?", async () => {
74
+ const { chars } = await render(renderCliToolRow(EXTERNAL, 0, false));
75
+ expect(chars).toContain("● Crush v0.55.0 installed elsewhere");
76
+ expect(chars).not.toContain("?");
77
+ });
78
+
79
+ test("its icon is muted, never the success colour of a current tool", async () => {
80
+ expect(await iconColour(EXTERNAL)).not.toBe(await iconColour(CURRENT));
81
+ });
82
+
83
+ test("the detail Status says Installed elsewhere, never Up to date, and offers no Enter", async () => {
84
+ const { chars } = await render(renderCliToolDetail(EXTERNAL, 70), 70);
85
+ expect(chars).toContain("Status ● Installed elsewhere");
86
+ expect(chars).not.toContain("Up to date");
87
+ expect(chars).not.toContain("Enter");
88
+ });
89
+
90
+ test("control: a current tool's detail still says Up to date", async () => {
91
+ const { chars } = await render(renderCliToolDetail(CURRENT, 70), 70);
92
+ expect(chars).toContain("Up to date");
93
+ });
94
+ });
95
+
96
+ // A held-back tool was checked — brew says it is behind and pinned — so it is
97
+ // "held back" here as in `magus update`: never `?`, never "not checked".
98
+ const HELD = row({ state: "unknown", reason: "held-back" });
99
+
100
+ describe("a held-back CLI tool row", () => {
101
+ test("the row says held back, with no ?", async () => {
102
+ const { chars } = await render(renderCliToolRow(HELD, 0, false));
103
+ expect(chars).toContain("‖ Crush v0.55.0 held back");
104
+ expect(chars).not.toContain("?");
105
+ });
106
+
107
+ test("the detail Status says Held back, never not checked", async () => {
108
+ const { chars } = await render(renderCliToolDetail(HELD, 70), 70);
109
+ expect(chars).toContain("Status ‖ Held back");
110
+ expect(chars).not.toContain("not checked");
111
+ });
112
+ });
@@ -19,6 +19,7 @@ import type { ResolvedBin } from "../types/index.js";
19
19
  import {
20
20
  type PluginManagedRow,
21
21
  buildToolRow,
22
+ cliToolEnterAction,
22
23
  cliToolStatusText,
23
24
  countToolRows,
24
25
  isPluginManagedRow,
@@ -85,22 +86,21 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
85
86
  const spec = specForCatalogTool(codex, [declared], install);
86
87
  expect(spec).toBe(declared);
87
88
 
88
- // Through npm, the bun-installed file is not npm's: not-managed.
89
+ // Through npm, the bun-installed file is not npm's: installed elsewhere.
89
90
  const f = facts("codex", {
90
91
  managed: false,
91
92
  resolvedPath:
92
93
  "/Users/j/.bun/install/global/node_modules/@openai/codex/bin/codex.js",
93
- owner: "npm (root /Users/j/.local/lib/node_modules)",
94
94
  });
95
95
  const row = buildToolRow(codex, spec, f, install);
96
96
  const [cli] = planBinUpdates([declared], [f]);
97
97
  expect(row.status).toMatchObject({
98
98
  state: cli!.action,
99
99
  reason: cli!.reason,
100
- note: cli!.note,
101
100
  target: cli!.target,
102
101
  });
103
- expect(row.status?.reason).toBe("not-managed");
102
+ expect(row.status?.note).toBe(cli!.note);
103
+ expect(row.status?.reason).toBe("installed-elsewhere");
104
104
  });
105
105
 
106
106
  test("pinned and behind: the row's key installs the PIN, exactly what the CLI runs", () => {
@@ -197,7 +197,7 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
197
197
  expect(row.installCommand).toEqual(cliToolInstall(codex));
198
198
  });
199
199
 
200
- test("detected pnpm → the catalogue's via, and a not-managed row in the user's terms", () => {
200
+ test("detected pnpm → the catalogue's via, and an installed-elsewhere row with no path", () => {
201
201
  const opencode = catalogue("opencode");
202
202
  const install = { primary: "pnpm" as const, all: ["pnpm" as const] };
203
203
  const spec = specForCatalogTool(opencode, null, install);
@@ -207,15 +207,17 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
207
207
  managed: false,
208
208
  resolvedPath:
209
209
  "/Users/j/.local/share/pnpm/global/5/node_modules/opencode-ai/bin/opencode",
210
- owner: "npm (root /Users/j/.local/lib/node_modules)",
211
210
  });
212
211
  const row = buildToolRow(opencode, spec, f, install);
213
- expect(row.status?.state).toBe("external");
214
- expect(row.status?.reason).toBe("not-managed");
215
- // No path and no installer directory: the user acts on neither.
216
- expect(row.status?.note).toBe("installed outside npm");
217
- expect(row.status?.note).not.toContain("/");
218
- // Enter still follows how it IS installed (pnpm).
212
+ expect(row.status).toMatchObject({
213
+ state: "external",
214
+ reason: "installed-elsewhere",
215
+ });
216
+ expect(row.status?.note).toBeUndefined();
217
+ // Enter offers nothing for a tool installed another way.
218
+ expect(cliToolEnterAction(row)).toBeNull();
219
+ // The row still builds its update command for how it IS installed
220
+ // (pnpm), even though Enter offers nothing for it.
219
221
  expect(row.updateCommand?.cmd).toBe("pnpm");
220
222
  });
221
223
 
@@ -252,26 +254,30 @@ describe("cliToolStatusText — the row's words", () => {
252
254
  primary: "npm",
253
255
  all: ["npm"],
254
256
  });
255
- const f = facts("crush", { managed: null });
257
+ const f = facts("crush", {
258
+ managed: null,
259
+ failure: "npm timed out after 8s",
260
+ });
256
261
  const row = buildToolRow(tool, spec, f);
257
262
  const text = cliToolStatusText(row);
258
263
  expect(text.tone).toBe("warning");
259
- expect(text.suffix).toBe(" ? owner not known");
264
+ expect(text.suffix).toBe(" ? owner unknown");
260
265
  // The note is for the detail pane's "Why", not the row.
261
266
  expect(row.status?.note).toBe(classifyTool(spec, f).note);
262
267
  expect(text.suffix).not.toContain("/");
263
268
  });
264
269
 
265
- test("external renders muted, naming the installer it is outside of", () => {
270
+ test("external renders muted as `installed elsewhere`, with no ?", () => {
266
271
  const tool = catalogue("crush");
267
272
  const spec = specForCatalogTool(tool, null, {
268
273
  primary: "npm",
269
274
  all: ["npm"],
270
275
  });
271
276
  const row = buildToolRow(tool, spec, facts("crush", { managed: false }));
272
- const text = cliToolStatusText(row);
273
- expect(text.tone).toBe("muted");
274
- expect(text.suffix).toBe(" · outside npm");
277
+ expect(cliToolStatusText(row)).toEqual({
278
+ suffix: " installed elsewhere",
279
+ tone: "muted",
280
+ });
275
281
  });
276
282
 
277
283
  test("upgrade names the target once, with one v", () => {
@@ -303,13 +309,19 @@ describe("countToolRows — header counts from states", () => {
303
309
  buildToolRow(tool, spec, facts("mnemex", { behind: false })),
304
310
  buildToolRow(tool, spec, facts("mnemex", { behind: true })),
305
311
  buildToolRow(tool, spec, facts("mnemex", { managed: null })),
306
- // Another installer's: installed, and not "unverified".
312
+ // Installed elsewhere: installed, but neither an update nor unverified.
307
313
  buildToolRow(tool, spec, facts("mnemex", { managed: false })),
314
+ // Held back: checked (behind and pinned), so not unverified either.
315
+ buildToolRow(
316
+ tool,
317
+ spec,
318
+ facts("mnemex", { behind: true, heldBack: "held by brew pin" }),
319
+ ),
308
320
  buildToolRow(tool, spec, facts("mnemex", { present: false })),
309
321
  buildToolRow(tool, spec, undefined),
310
322
  ];
311
323
  expect(countToolRows(rows)).toEqual({
312
- installed: 4,
324
+ installed: 5,
313
325
  updates: 1,
314
326
  unverified: 1,
315
327
  });