magus-cli 7.10.3 → 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 +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__/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 +180 -76
  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 +537 -691
  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 -21
  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 -21
  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,164 @@
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
+ * The magus-owned version table (docs/plans/2026-10-08-magus-update-tool-rows.md,
4
+ * decision 2): tool name -> argv and an anchored parse. A version is read only
5
+ * from the installer's own record or from this table — never guessed.
6
+ *
7
+ * Every line below is a first line of real `--version` output captured on the
8
+ * reporting machine (session root-cause.md, localization.md). A tool whose
9
+ * output was never captured (qwen, cline, aider) must have NO entry.
10
+ */
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
14
+ import path from "node:path";
15
+ import {
16
+ parseToolVersion,
17
+ presenceArgv,
18
+ toolVersionCommand,
19
+ } from "../data/tool-versions.js";
20
+
21
+ function parse(tool: string, stdout: string, stderr = ""): string | null {
22
+ const cmd = toolVersionCommand(tool);
23
+ if (!cmd) throw new Error(`no version command for ${tool}`);
24
+ return parseToolVersion(cmd, stdout, stderr);
25
+ }
26
+
27
+ describe("parseToolVersion against captured output", () => {
28
+ const cases: Array<[tool: string, output: string, version: string]> = [
29
+ ["claudish", "claudish version 10.4.1\n", "10.4.1"],
30
+ ["jq", "jq-1.8.2\n", "1.8.2"],
31
+ ["mnemex", "mnemex v0.36.1\nUpdate available: v0.36.2\n", "0.36.1"],
32
+ ["tmux", "tmux 3.7c\n", "3.7c"],
33
+ [
34
+ "uv",
35
+ "uv 0.11.15 (3cffe97c2 2026-05-18 aarch64-apple-darwin)\n",
36
+ "0.11.15",
37
+ ],
38
+ // /opt/homebrew/bin/uv --version, captured 2026-10-08.
39
+ [
40
+ "uv",
41
+ "uv 0.12.23 (Homebrew 2026-10-03 aarch64-apple-darwin)\n",
42
+ "0.12.23",
43
+ ],
44
+ ["zsh", "zsh 5.9 (arm64-apple-darwin26.0)\n", "5.9"],
45
+ ["magus", "magus v7.9.0\n", "7.9.0"],
46
+ ["claude", "2.1.293 (Claude Code)\n", "2.1.293"],
47
+ ["codex", "codex-cli 0.153.2\n", "0.153.2"],
48
+ ["gemini", "0.46.0\n", "0.46.0"],
49
+ ["opencode", "1.17.11\n", "1.17.11"],
50
+ ["crush", "crush version v0.55.0\n", "0.55.0"],
51
+ ];
52
+
53
+ for (const [tool, output, version] of cases) {
54
+ test(`${tool}: ${JSON.stringify(output.split("\n")[0])} -> ${version}`, () => {
55
+ expect(parse(tool, output)).toBe(version);
56
+ });
57
+ }
58
+
59
+ test("only the first non-empty line is read (mnemex's update notice is ignored)", () => {
60
+ expect(
61
+ parse("mnemex", "\n\nmnemex v0.36.1\nUpdate available: v0.36.2\n"),
62
+ ).toBe("0.36.1");
63
+ });
64
+
65
+ test("stderr is read when stdout is empty", () => {
66
+ expect(parse("tmux", "", "tmux 3.7c\n")).toBe("3.7c");
67
+ });
68
+
69
+ test("a line the pattern does not anchor gives null, never a guess", () => {
70
+ expect(parse("tmux", "tmux master\n")).toBeNull();
71
+ expect(parse("tmux", "tmux next-3.7\n")).toBeNull();
72
+ expect(parse("jq", "jq version 1.8.2 is great\n")).toBeNull();
73
+ expect(parse("claudish", "")).toBeNull();
74
+ });
75
+ });
76
+
77
+ describe("toolVersionCommand", () => {
78
+ test("tmux uses -V", () => {
79
+ expect([...(toolVersionCommand("tmux")?.args ?? [])]).toEqual(["-V"]);
80
+ });
81
+
82
+ test("an Object.prototype key is not a tool", () => {
83
+ expect(toolVersionCommand("constructor")).toBeUndefined();
84
+ expect(toolVersionCommand("toString")).toBeUndefined();
85
+ expect(toolVersionCommand("__proto__")).toBeUndefined();
86
+ });
87
+
88
+ test("tools whose output was never captured have no entry", () => {
89
+ for (const tool of ["qwen", "cline", "aider"]) {
90
+ expect(toolVersionCommand(tool)).toBeUndefined();
91
+ }
92
+ });
93
+ });
94
+
95
+ describe("presenceArgv", () => {
96
+ test("a known tool uses its own args", () => {
97
+ expect(presenceArgv("tmux")).toEqual(["tmux", "-V"]);
98
+ });
99
+
100
+ test("an unknown tool falls back to --version", () => {
101
+ expect(presenceArgv("sometool")).toEqual(["sometool", "--version"]);
102
+ });
103
+ });
104
+
105
+ // ── coverage: every plugin bin a version-managed installer owns ─────────────
106
+
107
+ interface InstallOption {
108
+ via?: string;
109
+ os?: string[];
110
+ formula?: string;
111
+ cask?: string;
112
+ }
113
+ interface BinDecl {
114
+ name: string;
115
+ install?: InstallOption[];
116
+ }
117
+
118
+ const PLUGINS = path.resolve(
119
+ import.meta.dir,
120
+ "..",
121
+ "..",
122
+ "..",
123
+ "..",
124
+ "plugins",
125
+ );
126
+ const VERSION_MANAGED = new Set(["npm", "bun", "pip", "go"]);
127
+
128
+ /** Bins whose darwin install option is brew (formula, not cask), npm, bun, pip or go. */
129
+ function versionManagedBins(): Map<string, string> {
130
+ const found = new Map<string, string>();
131
+ for (const dir of readdirSync(PLUGINS)) {
132
+ const manifest = path.join(PLUGINS, dir, ".claude-plugin", "plugin.json");
133
+ if (!existsSync(manifest)) continue;
134
+ const json = JSON.parse(readFileSync(manifest, "utf8")) as {
135
+ requires?: { bin?: BinDecl[] };
136
+ };
137
+ for (const b of json.requires?.bin ?? []) {
138
+ const darwin = (b.install ?? []).find(
139
+ (o) => !o.os || o.os.includes("darwin"),
140
+ );
141
+ if (!darwin?.via) continue;
142
+ const brewFormula = darwin.via === "brew" && !darwin.cask;
143
+ if (brewFormula || VERSION_MANAGED.has(darwin.via))
144
+ found.set(b.name, dir);
145
+ }
146
+ }
147
+ return found;
148
+ }
149
+
150
+ describe("the table covers every plugin-declared, version-managed bin", () => {
151
+ test("the scan finds the known set (not vacuous)", () => {
152
+ const names = [...versionManagedBins().keys()];
153
+ for (const known of ["claudish", "jq", "mnemex", "tmux", "uv", "zsh"]) {
154
+ expect(names).toContain(known);
155
+ }
156
+ });
157
+
158
+ test("each has a table entry", () => {
159
+ const missing = [...versionManagedBins()]
160
+ .filter(([name]) => toolVersionCommand(name) === undefined)
161
+ .map(([name, plugin]) => `${name} (${plugin})`);
162
+ expect(missing).toEqual([]);
163
+ });
164
+ });
@@ -220,7 +220,6 @@ describe("managedTool — the version-managed view of a requirement", () => {
220
220
  name: "claudish",
221
221
  via: "bun",
222
222
  package: "claudish",
223
- check: "claudish --version",
224
223
  version: "1.2.0",
225
224
  });
226
225
  expect(
@@ -276,7 +275,6 @@ describe("managedTool — the version-managed view of a requirement", () => {
276
275
  name: "a",
277
276
  via: "npm",
278
277
  package: "a",
279
- check: "a",
280
278
  sources: ["plugin:p@m"],
281
279
  },
282
280
  ]);
@@ -497,8 +497,9 @@ describe("planIsBehind — the --check gate", () => {
497
497
  });
498
498
 
499
499
  test("an UNKNOWN tool does not fail the gate, and is no work", () => {
500
- // Tool unknowns are often structural and permanent (another npm owns the
501
- // file, go is absent) — counting them makes a gate that never passes.
500
+ // Tool unknowns are often structural and permanent (the installer cannot
501
+ // say who owns the file, go is absent) — counting them makes a gate that
502
+ // never passes.
502
503
  const p = plan({
503
504
  bins: planBinUpdates(
504
505
  [{ name: "crush", via: "npm", sources: [] }],
@@ -510,17 +511,22 @@ describe("planIsBehind — the --check gate", () => {
510
511
  expect(planIsBehind(p)).toBe(false);
511
512
  });
512
513
 
513
- test("an EXTERNAL tool (another installer's) is no work, and not unknown", () => {
514
+ test("a tool installed ELSEWHERE does not fail the gate, and is no work", () => {
515
+ // Another npm owns the file: found and correctly left alone.
514
516
  const p = plan({
515
517
  bins: planBinUpdates(
516
518
  [{ name: "crush", via: "npm", sources: [] }],
517
- [toolFacts("crush", { managed: false, behind: true })],
519
+ [toolFacts("crush", { managed: false, behind: true, latest: "9.9.9" })],
518
520
  ),
519
521
  });
520
522
  expect(p.bins[0]!.action).toBe("external");
521
523
  expect(planHasWork(p)).toBe(false);
522
524
  expect(planIsBehind(p)).toBe(false);
523
- expect(summarizePlan(p)).toMatchObject({ unknownTools: 0, kept: 1 });
525
+ expect(summarizePlan(p)).toMatchObject({
526
+ unknownTools: 0,
527
+ kept: 0,
528
+ external: 1,
529
+ });
524
530
  });
525
531
 
526
532
  test("a MISSING binary does fail the gate", () => {
@@ -796,11 +802,12 @@ describe("planBinUpdates", () => {
796
802
 
797
803
  test("the note is classifyTool's note, verbatim (the TUI shows the same string)", () => {
798
804
  const f = toolFacts("crush", {
799
- managed: false,
805
+ managed: null,
800
806
  resolvedPath: "/n/crush.js",
801
- owner: "npm (root /l)",
807
+ failure: "npm timed out after 8s",
802
808
  });
803
809
  const [item] = planBinUpdates([bin({ name: "crush", via: "npm" })], [f]);
810
+ expect(item.note).toBeTruthy();
804
811
  expect(item.note).toBe(
805
812
  classifyTool(bin({ name: "crush", via: "npm" }), f).note,
806
813
  );
@@ -813,6 +820,7 @@ describe("binNeedsWork — one predicate for apply, the meter and 'has work'", (
813
820
  "upgrade",
814
821
  "current",
815
822
  "unknown",
823
+ "external",
816
824
  ];
817
825
  const itemFor = (action: BinUpdateAction): BinUpdateItem => ({
818
826
  name: `t-${action}`,
@@ -824,12 +832,13 @@ describe("binNeedsWork — one predicate for apply, the meter and 'has work'", (
824
832
  sources: [],
825
833
  });
826
834
 
827
- test("install and upgrade are work; current and unknown are not", () => {
835
+ test("install and upgrade are work; current, unknown and external are not", () => {
828
836
  expect(actions.map((a) => binNeedsWork(itemFor(a)))).toEqual([
829
837
  true,
830
838
  true,
831
839
  false,
832
840
  false,
841
+ false,
833
842
  ]);
834
843
  });
835
844
 
@@ -974,7 +983,29 @@ describe("planHasWork / summarizePlan", () => {
974
983
  unknownTools: 2,
975
984
  unknown: 3,
976
985
  current: 1,
977
- kept: 1,
986
+ kept: 0,
987
+ external: 1,
988
+ });
989
+ });
990
+
991
+ test("a tool installed elsewhere is counted apart, never as unknown", () => {
992
+ const p = plan({
993
+ bins: planBinUpdates(
994
+ [
995
+ { name: "crush", via: "npm", sources: [] },
996
+ { name: "tmux", via: "brew", sources: [] },
997
+ ],
998
+ [
999
+ toolFacts("crush", { managed: false }),
1000
+ toolFacts("tmux", { behind: false }),
1001
+ ],
1002
+ ),
1003
+ });
1004
+ expect(summarizePlan(p)).toMatchObject({
1005
+ unknownTools: 0,
1006
+ unknown: 0,
1007
+ current: 1,
1008
+ external: 1,
978
1009
  });
979
1010
  });
980
1011
  });
@@ -26,12 +26,14 @@
26
26
  * own word, so the report survives NO_COLOR, a pipe, and colour blindness.
27
27
  */
28
28
 
29
+ import { brewUnpinCommand } from "../services/toolchain.js";
29
30
  import { binCommandFor } from "../services/update-engine.js";
30
31
  import {
31
32
  type BinUpdateItem,
32
33
  type PluginUpdateItem,
33
34
  type SkillUpdateItem,
34
35
  type UpdatePlan,
36
+ binHeldBack,
35
37
  isKeptDeprecated,
36
38
  } from "../services/update-plan.js";
37
39
  import type {
@@ -90,7 +92,7 @@ type ActionKey =
90
92
  | "refresh"
91
93
  | "stale-load"
92
94
  | "remove"
93
- | "external"
95
+ | "held"
94
96
  | "unknown";
95
97
 
96
98
  const ACTIONS: Record<ActionKey, ActionStyle> = {
@@ -112,9 +114,10 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
112
114
  // dead weight Claude Code refuses to load, and the row is work, not a shrug.
113
115
  // The plugin table shows the group's DEPRECATED chip, not this label.
114
116
  remove: { glyph: "✗", label: "REMOVE", color: brand.danger },
115
- // A tool another installer put on PATH. Settled, so chrome like CURRENT,
116
- // and the same word the summary already prints for settled rows.
117
- external: { glyph: "=", label: "KEPT", color: brand.muted, chrome: true },
117
+ // A tool brew reports behind but holds with a pin: checked, behind, and the
118
+ // user's to release (`brew unpin`). Warning, like a decision waiting. Four
119
+ // characters, so CHIP_WIDTH is unchanged.
120
+ held: { glyph: "‖", label: "HELD", color: brand.warning },
118
121
  // Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
119
122
  // as "broken". The summary line and the TUI already use warning for it.
120
123
  unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
@@ -428,57 +431,125 @@ function pluginDetail(item: PluginUpdateItem): string {
428
431
  }
429
432
 
430
433
  /**
431
- * What a tool row says on its right.
434
+ * How the CLI section treats one tool: a ROW kind, which the user can act on,
435
+ * or a COUNTED kind, which only the header tallies. One word per kind, shared
436
+ * by the header and the row.
432
437
  *
433
- * A `current` row used to print `pinned —` unconditionally, which was true only
434
- * while "current" implied "pinned". An unpinned tool can be current now, so the
435
- * pin is shown only when there is one.
438
+ * The counted kinds have no {@link ACTIONS} entry on purpose: they are never a
439
+ * row, and a long label there ("INSTALLED ELSEWHERE") would widen CHIP_WIDTH —
440
+ * and with it every chip in every section.
436
441
  */
437
- function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
438
- const detail = binStateDetail(item, plan);
439
- // A newer copy later on PATH is the one thing here only the user can settle.
440
- return item.shadowed
441
- ? [detail, fg(brand.warning, `⚠ ${item.shadowed}`)]
442
- .filter(Boolean)
443
- .join(" ")
444
- : detail;
442
+ type BinRowKind = "install" | "upgrade" | "held";
443
+ type BinCountedKind = "current" | "elsewhere" | "unchecked";
444
+ type BinKind = BinRowKind | BinCountedKind;
445
+
446
+ function binKind(item: BinUpdateItem): BinKind {
447
+ switch (item.action) {
448
+ case "install":
449
+ return "install";
450
+ case "upgrade":
451
+ return "upgrade";
452
+ case "current":
453
+ return "current";
454
+ case "external":
455
+ return "elsewhere";
456
+ case "unknown":
457
+ return binHeldBack(item) ? "held" : "unchecked";
458
+ }
459
+ }
460
+
461
+ const isRowKind = (kind: BinKind): kind is BinRowKind =>
462
+ kind === "install" || kind === "upgrade" || kind === "held";
463
+
464
+ /** The header's words for `n` tools of one kind. */
465
+ function binLegend(kind: BinKind, n: number): string {
466
+ switch (kind) {
467
+ case "install":
468
+ case "upgrade":
469
+ case "current":
470
+ return fg(
471
+ ACTIONS[kind].color,
472
+ `${n} ${ACTIONS[kind].label.toLowerCase()}`,
473
+ );
474
+ case "held":
475
+ return fg(ACTIONS.held.color, `${n} held back`);
476
+ case "elsewhere":
477
+ return dim(`${n} installed elsewhere`);
478
+ case "unchecked":
479
+ return dim(`${n} not checked`);
480
+ }
445
481
  }
446
482
 
447
- function binStateDetail(item: BinUpdateItem, plan: UpdatePlan): string {
483
+ /** What a tool row says on its right: the command the user would run. */
484
+ function binDetail(
485
+ kind: BinRowKind,
486
+ item: BinUpdateItem,
487
+ plan: UpdatePlan,
488
+ ): string {
448
489
  const spec = plan.binSpecs.get(item.name);
490
+ if (kind === "held") {
491
+ // magus never runs it: releasing a pin is the user's decision.
492
+ return `${item.installed ?? "—"} ${dim(
493
+ formatCommand(brewUnpinCommand(spec ?? { name: item.name, via: "brew" })),
494
+ )}`;
495
+ }
449
496
  // Rendered for the reader's eyes only — this row says what WILL run, while
450
497
  // applyBins runs the argv itself. Same builder, so they cannot differ.
451
498
  const cmd = spec ? binCommandFor(item, spec) : null;
452
499
  const command = cmd ? dim(formatCommand(cmd)) : "";
453
- switch (item.action) {
454
- case "current":
455
- return `${item.installed ?? "—"}${
456
- item.version ? ` ${dim(`pinned ${item.version}`)}` : ""
457
- }`;
458
- case "upgrade":
459
- return `${dim(item.installed ?? "—")} ${fg(
460
- ACTIONS.upgrade.color,
461
- "→",
462
- )} ${fg(
463
- ACTIONS.upgrade.color,
464
- item.target ?? "?",
465
- )}${command ? ` ${command}` : ""}${
466
- // An upgrade that also changes the tool's owner says so here; a
467
- // plain one reads identically to before.
468
- item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
469
- }`;
470
- case "external":
471
- // Kept as is: a missing version is not a finding, so no "—" stands in.
472
- return [item.installed, item.note ? dim(`(${item.note})`) : ""]
473
- .filter(Boolean)
474
- .join(" ");
475
- case "unknown":
476
- return `${item.installed ?? "—"}${
477
- item.note ? ` ${dim(`(${item.note})`)}` : ""
478
- }`;
479
- case "install":
480
- return command;
500
+ if (kind === "install") return command;
501
+ return `${dim(item.installed ?? "—")} ${fg(
502
+ ACTIONS.upgrade.color,
503
+ "→",
504
+ )} ${fg(ACTIONS.upgrade.color, item.target ?? "?")}${
505
+ command ? ` ${command}` : ""
506
+ }${
507
+ // An upgrade that also changes the tool's owner says so here; a plain
508
+ // one reads identically to before.
509
+ item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
510
+ }`;
511
+ }
512
+
513
+ /**
514
+ * The CLI section: a header that counts every tool by kind, then a row for
515
+ * each tool the user can act on — an install, an upgrade, or a held-back tool
516
+ * whose `brew unpin` is theirs to run.
517
+ *
518
+ * A tool the user cannot act on — current, installed elsewhere, not checked —
519
+ * gets no row, and is never dropped: the header counts it, so the total
520
+ * matches the tools the profile declares. A pinned current tool's pin is not
521
+ * shown either; the profile states it.
522
+ */
523
+ function binSection(plan: UpdatePlan, nameWidth: number): string[] {
524
+ const items = plan.bins;
525
+ if (items.length === 0) return [];
526
+ const counts = new Map<BinKind, number>();
527
+ const rows: string[] = [];
528
+ for (const item of items) {
529
+ const kind = binKind(item);
530
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
531
+ if (!isRowKind(kind)) continue;
532
+ rows.push(
533
+ `${PAD}${glyph(kind)} ${rowText(
534
+ kind,
535
+ padEnd(item.name, nameWidth),
536
+ )} ${chip(kind)} ${binDetail(kind, item, plan)}`,
537
+ );
481
538
  }
539
+ // Tools with another copy later on PATH: counted, never described. Which
540
+ // copy to keep is the user's call, and nothing magus could run settles it.
541
+ const shadowed = items.filter((item) => (item.otherCopies ?? 0) > 0).length;
542
+ const legend = [
543
+ ...[...counts].map(([kind, n]) => binLegend(kind, n)),
544
+ ...(shadowed > 0 ? [dim(`${shadowed} with another copy on PATH`)] : []),
545
+ ].join(dim(" · "));
546
+ return [
547
+ "",
548
+ `${PAD}${bold(padEnd("CLI", 9))} ${dim(
549
+ padStart(String(items.length), 3),
550
+ )} ${legend}`,
551
+ ...(rows.length > 0 ? ["", ...rows] : []),
552
+ ];
482
553
  }
483
554
 
484
555
  /**
@@ -718,8 +789,8 @@ export function planReport(plan: UpdatePlan): string[] {
718
789
  ...plan.skills.map((s) => width(s.name)),
719
790
  );
720
791
 
721
- // EVERY planned plugin gets a row. There is no filter here and there must
722
- // not be one.
792
+ // EVERY planned plugin gets a row. There is no filter on plugins here and
793
+ // there must not be one.
723
794
  //
724
795
  // A previous revision dropped unlisted, not-installed rows
725
796
  // — a pin naming a plugin no marketplace offers — on the grounds that a
@@ -728,21 +799,14 @@ export function planReport(plan: UpdatePlan): string[] {
728
799
  // notice, while the closing lines still counted it and said "Reasons are
729
800
  // shown above", pointing at nothing.
730
801
  //
731
- // A report may never silently drop a row. That is the whole defect this
732
- // file's grouping was written to end, and a filter here reintroduces it in a
733
- // form that is harder to see, because the header count agrees with the rows
734
- // and only the plan knows something is missing.
802
+ // A report may never silently drop an item. Tools are the one section that
803
+ // hides rows — a tool the user cannot act on (current, installed elsewhere,
804
+ // unknown with no fix) gets none — and it is not silent: `binSection`'s
805
+ // header counts EVERY tool by state, so the total always matches the plan
806
+ // and every hidden tool is accounted for by its state.
735
807
  return [
736
808
  ...pluginSection(plan.plugins, nameWidth),
737
- ...section(
738
- "CLI",
739
- plan.bins.map((item: BinUpdateItem) => ({
740
- action: item.action as ActionKey,
741
- name: item.name,
742
- detail: binDetail(item, plan),
743
- })),
744
- nameWidth,
745
- ),
809
+ ...binSection(plan, nameWidth),
746
810
  ...section(
747
811
  "Skills",
748
812
  plan.skills.map((item: SkillUpdateItem) => ({
@@ -819,11 +883,13 @@ export function summaryLine(
819
883
  ms: number,
820
884
  unchanged = 0,
821
885
  kept = 0,
886
+ elsewhere = 0,
887
+ held = 0,
822
888
  ): string[] {
823
889
  const bar = stackedBar(BAR * 2, [
824
890
  { value: changed, color: brand.success },
825
- { value: current + kept, color: brand.muted },
826
- { value: unchanged + unknown, color: brand.warning },
891
+ { value: current + kept + elsewhere, color: brand.muted },
892
+ { value: unchanged + held + unknown, color: brand.warning },
827
893
  { value: failed, color: brand.danger },
828
894
  ]);
829
895
  const parts = [
@@ -833,6 +899,10 @@ export function summaryLine(
833
899
  // Deprecated but kept on purpose (moved, still required): settled, so
834
900
  // grey beside `current`, and never folded into "unverified".
835
901
  kept > 0 ? dim(`${kept} kept`) : "",
902
+ // Tools installed another way: found and left alone, so settled too.
903
+ elsewhere > 0 ? dim(`${elsewhere} installed elsewhere`) : "",
904
+ // Behind but pinned by the user: checked, so never "unverified".
905
+ held > 0 ? fg(brand.warning, `${held} held back`) : "",
836
906
  unknown > 0 ? fg(brand.warning, `${unknown} unverified`) : "",
837
907
  failed > 0 ? fg(brand.danger, `${failed} failed`) : "",
838
908
  ].filter(Boolean);
@@ -875,29 +945,63 @@ export function note(text: string): string {
875
945
  // false for a plugin whose marketplace publishes no version: re-running changes
876
946
  // nothing, and the reader went looking for a network fault that did not exist.
877
947
 
878
- /** Unverified tools, under the plan. */
879
- export function unknownToolsNotice(count: number, names: string[]): string {
880
- return warn(
881
- `${bold(String(count))} tool(s) could not be checked: ${names.join(", ")} — not applied. Reasons are shown above.`,
882
- );
883
- }
884
-
885
948
  /**
886
949
  * `--check` is about to exit 0 while N items went unchecked.
887
950
  *
888
951
  * The gate does not fail on an unknown — plugin or tool — because most unknowns
889
- * are permanent (a marketplace that publishes no version, a tool another npm
890
- * owns), and a gate that can never pass protects nothing. This line is what
891
- * keeps the exit 0 honest: it is "nothing is KNOWN to be behind", not
892
- * "everything was verified". It counts plugins and tools together because the
893
- * two per-kind notices above already name which is which.
952
+ * are permanent (a marketplace that publishes no version, a private registry),
953
+ * and a gate that can never pass protects nothing. This line is what keeps the
954
+ * exit 0 honest: it is "nothing is KNOWN to be behind", not "everything was
955
+ * verified".
956
+ *
957
+ * Each kind is named with what the report above holds for it, and no more: a
958
+ * plugin's reason is on its row, while a tool that could not be checked has
959
+ * no row, only a count in the CLI header. A tool installed another way or held
960
+ * back was checked, and is never counted here.
894
961
  */
895
- export function uncheckedGateNotice(count: number): string {
962
+ export function uncheckedGateNotice(plugins: number, tools: number): string {
963
+ const what = [
964
+ plugins > 0 ? `${bold(String(plugins))} plugin(s)` : "",
965
+ tools > 0 ? `${bold(String(tools))} tool(s)` : "",
966
+ ]
967
+ .filter(Boolean)
968
+ .join(" and ");
896
969
  return warn(
897
- `${bold(String(count))} item(s) could not be checked — they do not fail this gate. They are NOT reported as up to date. Reasons are shown above.`,
970
+ `${what} could not be checked — they do not fail this gate. They are NOT reported as up to date.${
971
+ plugins > 0 ? " Each plugin's reason is shown above." : ""
972
+ }`,
898
973
  );
899
974
  }
900
975
 
976
+ /**
977
+ * The run's closing line when there is nothing to apply.
978
+ *
979
+ * "Everything is up to date." only when it is TRUE: nothing unchecked and
980
+ * nothing held back. A plugin that could not be checked keeps its warning; a
981
+ * tool that could not be checked, or one held back, gets a dim note — it is
982
+ * not a failure, and it is not "up to date" either.
983
+ */
984
+ export function nothingToApplyLines(counts: {
985
+ unknownPlugins: number;
986
+ unknownTools: number;
987
+ heldBack: number;
988
+ }): string[] {
989
+ const tools = [
990
+ counts.unknownTools > 0 ? `${counts.unknownTools} tool(s) not checked` : "",
991
+ counts.heldBack > 0 ? `${counts.heldBack} held back` : "",
992
+ ]
993
+ .filter(Boolean)
994
+ .join(" · ");
995
+ if (counts.unknownPlugins > 0) {
996
+ return [
997
+ nothingToApplyNotice(counts.unknownPlugins),
998
+ ...(tools ? [note(`${tools}.`)] : []),
999
+ ];
1000
+ }
1001
+ if (tools) return [note(`Nothing to apply. ${tools}.`)];
1002
+ return [ok("Everything is up to date.")];
1003
+ }
1004
+
901
1005
  /** The run ends with nothing to apply because every remaining plugin is unverified. */
902
1006
  export function nothingToApplyNotice(unknownPlugins: number): string {
903
1007
  return warn(