magus-cli 7.7.0 → 7.9.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 (140) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +45 -26
  4. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  5. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  6. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  7. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  8. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  9. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  10. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  11. package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
  12. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  13. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  14. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  15. package/src/__tests__/blind-780-review2.test.ts +1178 -0
  16. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  17. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  18. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  19. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  20. package/src/__tests__/cli-update-view.test.ts +3 -3
  21. package/src/__tests__/deprecated-remove.test.ts +827 -0
  22. package/src/__tests__/deps-adapter.test.ts +191 -0
  23. package/src/__tests__/deps-check-hook.test.ts +220 -0
  24. package/src/__tests__/deps-plan.test.ts +896 -0
  25. package/src/__tests__/deps-report.test.ts +197 -0
  26. package/src/__tests__/doctor-bins.test.ts +134 -36
  27. package/src/__tests__/doctor-json.test.ts +261 -0
  28. package/src/__tests__/doctor.test.ts +159 -1
  29. package/src/__tests__/failure-popup-review2.test.tsx +896 -0
  30. package/src/__tests__/failure-popup.test.tsx +419 -0
  31. package/src/__tests__/final-fixes-780.test.tsx +740 -0
  32. package/src/__tests__/fixtures/bins.ts +55 -0
  33. package/src/__tests__/format-update-outcome.test.ts +6 -32
  34. package/src/__tests__/gitignore-profile.test.ts +1 -9
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/models-screen-state.test.ts +18 -40
  38. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  39. package/src/__tests__/npm-package-name.test.ts +6 -9
  40. package/src/__tests__/plugin-requires.test.ts +138 -153
  41. package/src/__tests__/plugin-resolution.test.ts +52 -13
  42. package/src/__tests__/plugin-row-text.test.ts +30 -37
  43. package/src/__tests__/profile-dependencies.test.ts +7 -0
  44. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  45. package/src/__tests__/profile-startup.test.ts +40 -12
  46. package/src/__tests__/requires-probe.test.ts +858 -0
  47. package/src/__tests__/resolver.test.ts +129 -52
  48. package/src/__tests__/run.test.ts +108 -0
  49. package/src/__tests__/stale-load-repair.test.ts +31 -23
  50. package/src/__tests__/tool-probe.test.ts +1 -1
  51. package/src/__tests__/toolchain.test.ts +123 -52
  52. package/src/__tests__/update-apply.test.ts +13 -29
  53. package/src/__tests__/update-plan.test.ts +24 -18
  54. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  55. package/src/__tests__/version-unknown-state.test.ts +15 -17
  56. package/src/cli/ansi.ts +2 -7
  57. package/src/cli/deps-view.ts +213 -0
  58. package/src/cli/doctor.ts +320 -54
  59. package/src/cli/install.ts +55 -71
  60. package/src/cli/profile.ts +20 -2
  61. package/src/cli/router.ts +6 -2
  62. package/src/cli/update-view.ts +58 -15
  63. package/src/cli/update.ts +55 -12
  64. package/src/data/cli-tools.ts +24 -9
  65. package/src/services/active-closure.ts +3 -3
  66. package/src/services/catalog-cache-store.ts +8 -6
  67. package/src/services/claude-cli.ts +106 -19
  68. package/src/services/claude-settings.ts +88 -384
  69. package/src/services/cli-tool-commands.ts +104 -3
  70. package/src/services/deps-plan.ts +962 -0
  71. package/src/services/deps-report.ts +265 -0
  72. package/src/services/doctor-bins.ts +115 -43
  73. package/src/services/install-plan.ts +11 -1
  74. package/src/services/local-marketplace.ts +12 -206
  75. package/src/services/marketplace-refresh.ts +4 -4
  76. package/src/services/plugin-env-needs.ts +69 -0
  77. package/src/services/plugin-manager.ts +35 -37
  78. package/src/services/plugin-requires.ts +93 -162
  79. package/src/services/plugin-resolution.ts +34 -42
  80. package/src/services/profile-activate.ts +1 -2
  81. package/src/services/profile-startup.ts +18 -43
  82. package/src/services/profile-state.ts +72 -2
  83. package/src/services/resolver.ts +263 -59
  84. package/src/services/tool-probe.ts +18 -25
  85. package/src/services/toolchain.ts +117 -111
  86. package/src/services/update-engine.ts +345 -45
  87. package/src/services/update-plan.ts +296 -36
  88. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  89. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  90. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  91. package/src/types/index.ts +60 -16
  92. package/src/ui/App.tsx +81 -171
  93. package/src/ui/adapters/cliToolsAdapter.ts +252 -12
  94. package/src/ui/adapters/depsAdapter.ts +129 -0
  95. package/src/ui/adapters/failureReport.ts +137 -0
  96. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  97. package/src/ui/adapters/startupReports.ts +260 -0
  98. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  99. package/src/ui/components/modals/MessageModal.tsx +7 -12
  100. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  101. package/src/ui/components/modals/index.ts +1 -0
  102. package/src/ui/hooks/startupQueue.ts +56 -0
  103. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  104. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  105. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  106. package/src/ui/keymap.ts +14 -1
  107. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  108. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  109. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  110. package/src/ui/renderers/pluginRowText.ts +25 -25
  111. package/src/ui/renderers/pluginStatusText.ts +18 -1
  112. package/src/ui/screens/AliasScreen.tsx +38 -45
  113. package/src/ui/screens/CliToolsScreen.tsx +199 -98
  114. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  115. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  116. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  117. package/src/ui/screens/McpScreen.tsx +46 -19
  118. package/src/ui/screens/ModelsScreen.tsx +46 -86
  119. package/src/ui/screens/PluginsScreen.tsx +315 -259
  120. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  121. package/src/ui/screens/SkillsScreen.tsx +83 -80
  122. package/src/ui/screens/StylesScreen.tsx +211 -275
  123. package/src/ui/state/AppContext.tsx +66 -44
  124. package/src/ui/state/DimensionsContext.tsx +3 -22
  125. package/src/ui/state/reducer.ts +12 -100
  126. package/src/ui/state/types.ts +48 -69
  127. package/src/utils/command-utils.ts +4 -5
  128. package/src/utils/run.ts +123 -52
  129. package/src/utils/string-utils.ts +9 -0
  130. package/src/utils/user-path.ts +60 -0
  131. package/src/__tests__/go-module.test.ts +0 -43
  132. package/src/__tests__/notices-rules.test.ts +0 -476
  133. package/src/__tests__/notices.test.tsx +0 -604
  134. package/src/__tests__/plugin-setup.test.ts +0 -1016
  135. package/src/__tests__/shell-script-callers.test.ts +0 -92
  136. package/src/__tests__/styles-status-line.test.ts +0 -72
  137. package/src/__tests__/version-snapshot.test.ts +0 -212
  138. package/src/services/plugin-setup.ts +0 -965
  139. package/src/services/version-snapshot.ts +0 -189
  140. package/src/utils/go-module.ts +0 -36
@@ -2,12 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  PYTHON_CLI_INSTALLERS,
4
4
  type PythonCliInstaller,
5
- binInstallCommand,
6
5
  binUpgradeCommand,
7
- detectToolchains,
6
+ managedBins,
7
+ managedInstallCommand,
8
+ managedOption,
9
+ managedTool,
8
10
  resolvePythonCliInstaller,
9
11
  } from "../services/toolchain.js";
10
- import type { ResolvedBin } from "../types/index.js";
11
12
  import { formatCommand } from "../utils/command-utils.js";
12
13
 
13
14
  /**
@@ -26,21 +27,21 @@ const UV = byProbe("uv");
26
27
  const PIPX = byProbe("pipx");
27
28
  const PY = byProbe("python3");
28
29
 
29
- describe("binInstallCommand", () => {
30
+ describe("managedInstallCommand (rendered by requires-probe's renderInstall)", () => {
30
31
  test("bun with and without a version pin", () => {
31
32
  expect(
32
33
  formatCommand(
33
- binInstallCommand({
34
+ managedInstallCommand({
34
35
  name: "claudish",
35
36
  via: "bun",
36
37
  package: "claudish",
37
38
  version: "1.2.0",
38
39
  }),
39
40
  ),
40
- ).toBe("bun install -g claudish@1.2.0");
41
+ ).toBe("bun add -g claudish@1.2.0");
41
42
  expect(
42
- formatCommand(binInstallCommand({ name: "claudish", via: "bun" })),
43
- ).toBe("bun install -g claudish");
43
+ formatCommand(managedInstallCommand({ name: "claudish", via: "bun" })),
44
+ ).toBe("bun add -g claudish");
44
45
  });
45
46
 
46
47
  test("the package name is ONE argv token, whatever is in it", () => {
@@ -51,18 +52,18 @@ describe("binInstallCommand", () => {
51
52
  // words and a command separator. As argv it cannot be anything but a
52
53
  // (nonexistent) package name, so the install fails instead of running.
53
54
  const evil = "a b; rm -rf /";
54
- const cmd = binInstallCommand({ name: "x", via: "bun", package: evil });
55
+ const cmd = managedInstallCommand({ name: "x", via: "bun", package: evil });
55
56
  expect(cmd.cmd).toBe("bun");
56
- expect(cmd.args).toEqual(["install", "-g", evil]);
57
+ expect(cmd.args).toEqual(["add", "-g", evil]);
57
58
  expect(cmd.args.at(-1)).toBe(evil); // unsplit: still exactly one token
58
59
  // ...and rendering it for display quotes it rather than emitting syntax.
59
- expect(formatCommand(cmd)).toBe("bun install -g 'a b; rm -rf /'");
60
+ expect(formatCommand(cmd)).toBe("bun add -g 'a b; rm -rf /'");
60
61
  });
61
62
 
62
63
  test("a version pin joins the package as one token, not two", () => {
63
64
  // Splitting `pkg@version` would ask the installer for two packages, the
64
65
  // second of which is a version number.
65
- const cmd = binInstallCommand({
66
+ const cmd = managedInstallCommand({
66
67
  name: "claudish",
67
68
  via: "bun",
68
69
  version: "1.2.0",
@@ -76,24 +77,32 @@ describe("binInstallCommand", () => {
76
77
  // quotes nothing and the printed line is byte-identical to the string
77
78
  // these builders returned before they returned argv.
78
79
  expect(
79
- formatCommand(binInstallCommand({ name: "x", via: "npm", package: "x" })),
80
+ formatCommand(
81
+ managedInstallCommand({ name: "x", via: "npm", package: "x" }),
82
+ ),
80
83
  ).toBe("npm install -g x");
81
84
  expect(
82
85
  formatCommand(
83
- binInstallCommand(
84
- { name: "aider", via: "pip", package: "aider", version: "0.1" },
85
- UV,
86
- ),
86
+ managedInstallCommand({
87
+ name: "aider",
88
+ via: "pip",
89
+ package: "aider",
90
+ version: "0.1",
91
+ }),
87
92
  ),
88
93
  ).toBe("uv tool install aider==0.1");
89
94
  expect(
90
95
  formatCommand(
91
- binInstallCommand({ name: "tmux", via: "brew", formula: "tmux" }),
96
+ managedInstallCommand({ name: "tmux", via: "brew", formula: "tmux" }),
92
97
  ),
93
98
  ).toBe("brew install tmux");
94
99
  expect(
95
100
  formatCommand(
96
- binInstallCommand({ name: "t", via: "go", module: "github.com/x/t" }),
101
+ managedInstallCommand({
102
+ name: "t",
103
+ via: "go",
104
+ module: "github.com/x/t",
105
+ }),
97
106
  ),
98
107
  ).toBe("go install github.com/x/t@latest");
99
108
  });
@@ -132,7 +141,7 @@ describe("binUpgradeCommand", () => {
132
141
  // they are distinct references — so comparing them directly would be
133
142
  // an assertion that cannot fail.
134
143
  expect(formatCommand(binUpgradeCommand(bin, python))).not.toBe(
135
- formatCommand(binInstallCommand(bin, python)),
144
+ formatCommand(managedInstallCommand(bin)),
136
145
  );
137
146
  }
138
147
  });
@@ -172,20 +181,11 @@ describe("resolvePythonCliInstaller", () => {
172
181
  });
173
182
 
174
183
  test("a version pin is expressed in the installer's own syntax", () => {
175
- expect(
176
- formatCommand(
177
- binInstallCommand({ name: "a", via: "pip", version: "1.2" }, UV),
178
- ),
179
- ).toBe("uv tool install a==1.2");
180
- expect(
181
- formatCommand(
182
- binInstallCommand({ name: "a", via: "pip", version: "1.2" }, PIPX),
183
- ),
184
- ).toBe("pipx install a==1.2");
184
+ expect(PIPX.install("a", "1.2").args).toEqual(["install", "a==1.2"]);
185
185
  // `a==1.2` is one token. Two would ask uv to install a package called
186
186
  // "1.2".
187
187
  expect(
188
- binInstallCommand({ name: "a", via: "pip", version: "1.2" }, UV).args,
188
+ managedInstallCommand({ name: "a", via: "pip", version: "1.2" }).args,
189
189
  ).toEqual(["tool", "install", "a==1.2"]);
190
190
  });
191
191
 
@@ -207,26 +207,97 @@ describe("resolvePythonCliInstaller", () => {
207
207
  });
208
208
  });
209
209
 
210
- describe("detectToolchains", () => {
211
- test("groups bins by installer and reports requiredBy + presence", async () => {
212
- const bins: ResolvedBin[] = [
213
- { name: "claudish", via: "bun", sources: ["a"] },
214
- { name: "mnemex", via: "bun", sources: ["b"] },
215
- { name: "tmux", via: "brew", sources: ["c"] },
216
- ];
217
- const statuses = await detectToolchains(bins);
218
- const bun = statuses.find((s) => s.name === "bun")!;
219
- const brew = statuses.find((s) => s.name === "brew")!;
220
- expect(bun.requiredBy.sort()).toEqual(["claudish", "mnemex"]);
221
- expect(brew.requiredBy).toEqual(["tmux"]);
222
- // bun/brew are bootstrappable; the field is populated.
223
- expect(bun.bootstrap).toContain("bun.sh");
224
- expect(typeof bun.present).toBe("boolean");
225
- // stable order: bun before brew
226
- expect(statuses.map((s) => s.name)).toEqual(["bun", "brew"]);
227
- });
228
-
229
- test("returns only toolchains actually needed (empty for no bins)", async () => {
230
- expect(await detectToolchains([])).toEqual([]);
210
+ describe("managedTool — the version-managed view of a requirement", () => {
211
+ test("bun/npm/pip/go/brew options map onto the managed shape, pin included", () => {
212
+ expect(
213
+ managedTool({
214
+ name: "claudish",
215
+ check: ["claudish", "--version"],
216
+ install: [],
217
+ chosen: { via: "bun", package: "claudish", version: "1.2.0" },
218
+ }),
219
+ ).toEqual({
220
+ name: "claudish",
221
+ via: "bun",
222
+ package: "claudish",
223
+ check: "claudish --version",
224
+ version: "1.2.0",
225
+ });
226
+ expect(
227
+ managedTool({
228
+ name: "t",
229
+ check: ["t"],
230
+ install: [],
231
+ chosen: { via: "go", package: "github.com/x/t" },
232
+ }),
233
+ ).toMatchObject({ via: "go", module: "github.com/x/t" });
234
+ expect(
235
+ managedTool({
236
+ name: "tmux",
237
+ check: ["tmux", "-V"],
238
+ install: [],
239
+ chosen: { via: "brew", formula: "tmux" },
240
+ }),
241
+ ).toMatchObject({ via: "brew", formula: "tmux" });
242
+ });
243
+
244
+ test("apt, release, bootstrap and the uv kinds have no managed version", () => {
245
+ const base = { name: "x", check: ["x"], install: [] };
246
+ for (const chosen of [
247
+ { via: "apt" as const, packages: ["x"] },
248
+ { via: "bootstrap" as const, tool: "uv" as const },
249
+ { via: "uv-script" as const, script: "s.py", args: [] },
250
+ { via: "uv-module" as const, script: "s.py", module: "m", args: [] },
251
+ ]) {
252
+ expect(managedTool({ ...base, chosen })).toBeNull();
253
+ }
254
+ expect(managedTool({ ...base, chosen: null })).toBeNull();
255
+ });
256
+
257
+ test("managedBins keeps provenance and drops what is not managed", () => {
258
+ const bins = managedBins([
259
+ {
260
+ name: "a",
261
+ check: ["a"],
262
+ install: [],
263
+ chosen: { via: "npm", package: "a" },
264
+ sources: ["plugin:p@m"],
265
+ },
266
+ {
267
+ name: "b",
268
+ check: ["b"],
269
+ install: [],
270
+ chosen: { via: "apt", packages: ["b"] },
271
+ sources: ["plugin:p@m"],
272
+ },
273
+ ]);
274
+ expect(bins).toEqual([
275
+ {
276
+ name: "a",
277
+ via: "npm",
278
+ package: "a",
279
+ check: "a",
280
+ sources: ["plugin:p@m"],
281
+ },
282
+ ]);
283
+ });
284
+
285
+ test("managedOption round-trips through managedTool", () => {
286
+ for (const t of [
287
+ { name: "c", via: "bun" as const, package: "c", version: "1" },
288
+ { name: "t", via: "brew" as const, formula: "tmux" },
289
+ { name: "g", via: "go" as const, module: "github.com/x/g" },
290
+ { name: "p", via: "pip" as const, package: "p" },
291
+ ]) {
292
+ const option = managedOption(t);
293
+ expect(
294
+ managedTool({
295
+ name: t.name,
296
+ check: [t.name],
297
+ install: [option],
298
+ chosen: option,
299
+ }),
300
+ ).toMatchObject(t);
301
+ }
231
302
  });
232
303
  });
@@ -26,7 +26,6 @@ import type {
26
26
  UpdatePlan,
27
27
  } from "../services/update-plan.js";
28
28
  import type { InstalledPluginEntry, ResolvedBin } from "../types/index.js";
29
- import { noMoveReasons } from "../ui/adapters/pluginsAdapter.js";
30
29
 
31
30
  const PROJECT = "/tmp/project";
32
31
 
@@ -776,25 +775,6 @@ describe("applyPlugins — read-back against beforeVersions", () => {
776
775
  expect(result.ok).toBe(1);
777
776
  expect(result.unchanged).toEqual([]);
778
777
  });
779
-
780
- test("the TUI's collector names the same cause the CLI prints", async () => {
781
- // The screen used to branch on `failed.length` alone: a no-op update
782
- // closed with a success and a row that had not moved.
783
- const { deps } = stubDeps("1.0.0");
784
- const reasons = noMoveReasons();
785
- const result = await applyPlugins(
786
- [item({ beforeVersions: { project: "1.0.0" } })],
787
- PROJECT,
788
- deps,
789
- reasons.reporter,
790
- );
791
- expect(result.unchanged).toEqual(["dev@magus"]);
792
- expect(reasons.lines(result.unchanged)).toEqual([
793
- "dev@magus: the installer exited 0 but dev@magus is still 1.0.0",
794
- ]);
795
- // An item with no recorded cause still gets a line.
796
- expect(reasons.lines(["other@magus"])).toEqual(["other@magus"]);
797
- });
798
778
  });
799
779
 
800
780
  // ── B4: installed must mean LOADED ──────────────────────────────────────────
@@ -847,8 +827,7 @@ describe("B4: an install or update ends with the loader on the new version", ()
847
827
  {
848
828
  ok: true,
849
829
  name: "dev@magus",
850
- detail:
851
- "project → 2.0.0 · Claude Code now loads v2.0.0 here (3 older records moved forward)",
830
+ detail: "project → 2.0.0 · Claude Code now loads v2.0.0 in this folder",
852
831
  },
853
832
  ]);
854
833
  });
@@ -876,8 +855,10 @@ describe("B4: an install or update ends with the loader on the new version", ()
876
855
  {
877
856
  ok: false,
878
857
  name: "dev@magus",
858
+ // The version still loading IS the reason; the repair's own message
859
+ // said it a second time, in the mechanism's words.
879
860
  detail:
880
- "installed v2.0.0, but Claude Code still loads v1.0.0 here — the loader still resolves v1.0.0 here, not v2.0.0",
861
+ "installed v2.0.0, but Claude Code still loads v1.0.0 in this folder",
881
862
  },
882
863
  ]);
883
864
  });
@@ -917,7 +898,7 @@ describe("B4: an install or update ends with the loader on the new version", ()
917
898
  await expect(
918
899
  updatePluginInScope("dev@magus", "project", PROJECT, deps),
919
900
  ).rejects.toThrow(
920
- "installed v2.0.0, but Claude Code still loads v1.0.0 here",
901
+ "installed v2.0.0, but Claude Code still loads v1.0.0 in this folder",
921
902
  );
922
903
  });
923
904
 
@@ -938,7 +919,9 @@ describe("B4: an install or update ends with the loader on the new version", ()
938
919
  const { deps } = loaderDeps("1.0.0", false);
939
920
  await expect(
940
921
  updatePluginInScope("dev@magus", "user", PROJECT, deps),
941
- ).rejects.toThrow("the loader still resolves v1.0.0 here, not v2.0.0");
922
+ ).rejects.toThrow(
923
+ "installed v2.0.0, but Claude Code still loads v1.0.0 in this folder",
924
+ );
942
925
  });
943
926
  });
944
927
 
@@ -1067,7 +1050,8 @@ describe("7.5.2: an update Claude Code applied to another project's row", () =>
1067
1050
  {
1068
1051
  ok: true,
1069
1052
  name: "dev@magus",
1070
- detail: `project → 8.1.3 · Claude Code updated ${OTHER} instead of this project — moved this project's record to v8.1.3 too`,
1053
+ detail:
1054
+ "project → 8.1.3 · Claude Code updated another folder of this repository instead of this one — this folder now loads v8.1.3 too",
1071
1055
  },
1072
1056
  ]);
1073
1057
  });
@@ -1084,7 +1068,7 @@ describe("7.5.2: an update Claude Code applied to another project's row", () =>
1084
1068
  // forward by the loader check (B4), and no misdirection is reported.
1085
1069
  expect(repaired).toEqual(["8.1.3"]);
1086
1070
  expect(rows[0]?.detail).toBe(
1087
- "project → 8.1.3 · Claude Code now loads v8.1.3 here (1 older record moved forward)",
1071
+ "project → 8.1.3 · Claude Code now loads v8.1.3 in this folder",
1088
1072
  );
1089
1073
  });
1090
1074
 
@@ -1097,7 +1081,7 @@ describe("7.5.2: an update Claude Code applied to another project's row", () =>
1097
1081
  expect(row(HERE).version).toBe("8.0.0");
1098
1082
  expect(result.unchanged).toEqual(["dev@magus"]);
1099
1083
  expect(rows[0]?.detail).toBe(
1100
- `no change — the installer exited 0 but dev@magus is still 8.0.0 (Claude Code updated ${OTHER} instead of this project, and this project's record could not follow: no cache directory for v8.1.3)`,
1084
+ "no change — the installer exited 0 but dev@magus is still 8.0.0 (Claude Code updated another folder of this repository instead of this one, and this folder could not follow: no cache directory for v8.1.3)",
1101
1085
  );
1102
1086
  });
1103
1087
 
@@ -1125,7 +1109,7 @@ describe("7.5.2: an update Claude Code applied to another project's row", () =>
1125
1109
  expect(repaired).toEqual([]);
1126
1110
  expect(result.unchanged).toEqual(["dev@magus"]);
1127
1111
  expect(rows[0]?.detail).toBe(
1128
- `no change — the installer exited 0 but dev@magus is still 8.0.0 (Claude Code updated ${OTHER} instead of this project, and this project's record could not follow: it went to v8.5.0, not the v8.1.3 this update asked for)`,
1112
+ "no change — the installer exited 0 but dev@magus is still 8.0.0 (Claude Code updated another folder of this repository instead of this one, and this folder could not follow: it went to v8.5.0, not the v8.1.3 this update asked for)",
1129
1113
  );
1130
1114
  });
1131
1115
 
@@ -430,19 +430,23 @@ describe("per-scope decisions — a stale scope is never masked", () => {
430
430
  // No note: there is nothing for the reader to do about it.
431
431
  expect(item.note).toBeUndefined();
432
432
  // And it is not confused with the withdrawn case, which is the next test.
433
- expect(item.reason).not.toBe("not-offered");
433
+ expect(item.reason).not.toBe("deprecated");
434
434
  });
435
435
 
436
- test("an installed plugin ABSENT from every catalog is 'not offered'", () => {
436
+ test("a declared plugin ABSENT from every catalog is deprecated", () => {
437
437
  // The genuinely withdrawn case: no catalog entry at all, the installed
438
438
  // version known only from the registry.
439
439
  const [item] = planPluginUpdates(
440
440
  { "withdrawn@magus": "latest" },
441
441
  catalog(info({ id: "dev@magus", version: "4.6.1" })),
442
442
  );
443
- expect(item.action).toBe("unknown");
444
- expect(item.note).toContain("not offered by any configured marketplace");
445
- expect(item.reason).toBe("not-offered");
443
+ // Its marketplace was read and does not list it: no longer published, so
444
+ // the work is removing it — here from the profile alone, since nothing is
445
+ // installed to uninstall.
446
+ expect(item.action).toBe("remove");
447
+ expect(item.note).toBe("no longer published");
448
+ expect(item.reason).toBe("deprecated");
449
+ expect(item.scopes).toEqual([]);
446
450
  });
447
451
  });
448
452
 
@@ -855,7 +859,7 @@ describe("binNeedsWork — one predicate for apply, the meter and 'has work'", (
855
859
  { ...itemFor("upgrade"), name: "mnemex", target: "1.0.0" },
856
860
  spec,
857
861
  ),
858
- ).toEqual({ cmd: "bun", args: ["install", "-g", "mnemex@1.0.0"] });
862
+ ).toEqual({ cmd: "bun", args: ["add", "-g", "mnemex@1.0.0"] });
859
863
  expect(
860
864
  binCommandFor(
861
865
  { ...itemFor("upgrade"), target: "2.0.0" },
@@ -993,19 +997,20 @@ describe("classifyPlugin — an orphan is never 'current'", () => {
993
997
  info({ id: "dev@magus", version: "4.7.0" }),
994
998
  ),
995
999
  );
996
- expect(item.action).toBe("unknown");
1000
+ expect(item.action).toBe("remove");
997
1001
  expect(item.action).not.toBe("current");
998
1002
  expect(item.available).toBeNull();
999
1003
  expect(item.target).toBeNull();
1000
- expect(item.scopes).toEqual([]);
1004
+ // A removal's scopes are where it is uninstalled: every scope installed here.
1005
+ expect(item.scopes).toEqual(["project"]);
1001
1006
  });
1002
1007
 
1003
1008
  // `moved` is GONE, and `movedTo` with it. It was inferred from a bare name
1004
1009
  // match: a plugin published to two marketplaces — claudish is — reported
1005
1010
  // "moved → magus" when nothing had moved and both copies were installed.
1006
1011
  // The inference cannot tell a move from a dual publication, so it is not made
1007
- // at all. An orphan is `not-offered`, which is a fact rather than a guess.
1008
- test("an orphan is 'not offered', and never guesses where it went", () => {
1012
+ // at all. An orphan is `deprecated`, which is a fact rather than a guess.
1013
+ test("an orphan is deprecated, and never guesses where it went", () => {
1009
1014
  const [item] = planPluginUpdates(
1010
1015
  { "seo@magus": "latest" },
1011
1016
  catalog(
@@ -1014,13 +1019,13 @@ describe("classifyPlugin — an orphan is never 'current'", () => {
1014
1019
  ),
1015
1020
  );
1016
1021
  expect(item).toMatchObject({
1017
- action: "unknown",
1018
- reason: "not-offered",
1022
+ action: "remove",
1023
+ reason: "deprecated",
1019
1024
  });
1020
1025
  expect(item.note).not.toMatch(/moved/i);
1021
1026
  });
1022
1027
 
1023
- test("an orphan whose marketplace we DID read is 'not offered'", () => {
1028
+ test("an orphan whose marketplace we DID read is deprecated", () => {
1024
1029
  const [item] = planPluginUpdates(
1025
1030
  { "seo@magus": "latest" },
1026
1031
  catalog(
@@ -1029,9 +1034,9 @@ describe("classifyPlugin — an orphan is never 'current'", () => {
1029
1034
  ),
1030
1035
  );
1031
1036
  expect(item).toMatchObject({
1032
- action: "unknown",
1033
- reason: "not-offered",
1034
- note: "not offered by any configured marketplace",
1037
+ action: "remove",
1038
+ reason: "deprecated",
1039
+ note: "no longer published",
1035
1040
  });
1036
1041
  });
1037
1042
 
@@ -1062,7 +1067,7 @@ describe("classifyPlugin — an orphan is never 'current'", () => {
1062
1067
  expect(item.reason).toBe("marketplace-unread");
1063
1068
  });
1064
1069
 
1065
- test("an orphan is unknown under an exact pin too", () => {
1070
+ test("an orphan is deprecated under an exact pin too", () => {
1066
1071
  const [item] = planPluginUpdates(
1067
1072
  { "seo@magus": "1.8.0" },
1068
1073
  catalog(
@@ -1070,7 +1075,7 @@ describe("classifyPlugin — an orphan is never 'current'", () => {
1070
1075
  info({ id: "dev@magus", version: "4.7.0" }),
1071
1076
  ),
1072
1077
  );
1073
- expect(item.action).toBe("unknown");
1078
+ expect(item.action).toBe("remove");
1074
1079
  });
1075
1080
  });
1076
1081
 
@@ -1359,6 +1364,7 @@ describe("countPluginStates", () => {
1359
1364
  repair: 1,
1360
1365
  current: 0,
1361
1366
  "stale-load": 0,
1367
+ remove: 0,
1362
1368
  unknown: 1,
1363
1369
  });
1364
1370
  });
@@ -1,8 +1,5 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import {
3
- buildStaleLoadModal,
4
- staleLoadRepairNotices,
5
- } from "../ui/hooks/useStaleLoadModal";
2
+ import { buildStaleLoadModal } from "../ui/hooks/useStaleLoadModal";
6
3
 
7
4
  /**
8
5
  * The startup popup for a broken plugin.
@@ -137,56 +134,3 @@ describe("buildStaleLoadModal", () => {
137
134
  });
138
135
  });
139
136
  });
140
-
141
- describe("staleLoadRepairNotices", () => {
142
- it("says what loads NOW for the repaired plugins, in one line", () => {
143
- expect(
144
- staleLoadRepairNotices([
145
- { pluginId: "madbench@magus", target: "0.5.0", ok: true },
146
- { pluginId: "dev@magus", target: "7.4.0", ok: true },
147
- ]),
148
- ).toEqual([
149
- {
150
- text: "Now loading madbench@magus v0.5.0, dev@magus v7.4.0",
151
- tone: "success",
152
- },
153
- ]);
154
- });
155
-
156
- it("reports a failed repair with its own reason, never as a success", () => {
157
- expect(
158
- staleLoadRepairNotices([
159
- {
160
- pluginId: "madbench@magus",
161
- target: "0.5.0",
162
- ok: false,
163
- error: "the loader still resolves v0.3.0 here, not v0.5.0",
164
- },
165
- ]),
166
- ).toEqual([
167
- {
168
- text: "madbench@magus not repaired: the loader still resolves v0.3.0 here, not v0.5.0",
169
- tone: "warning",
170
- },
171
- ]);
172
- });
173
-
174
- it("falls back to a plain reason rather than an empty line", () => {
175
- expect(
176
- staleLoadRepairNotices([
177
- { pluginId: "a@magus", target: "1.0.0", ok: false },
178
- ]),
179
- ).toEqual([
180
- {
181
- text: "a@magus not repaired: repair did not take effect",
182
- tone: "warning",
183
- },
184
- ]);
185
- });
186
-
187
- it("says so when there was nothing to repair", () => {
188
- expect(staleLoadRepairNotices([])).toEqual([
189
- { text: "Nothing needed repairing", tone: "success" },
190
- ]);
191
- });
192
- });
@@ -178,7 +178,7 @@ const magusCatalog = (...extra: PluginInfo[]) =>
178
178
  );
179
179
 
180
180
  describe("planPluginUpdates — a pin its own marketplace does not offer", () => {
181
- test("declared, not installed, marketplace read, plugin absent → unknown", () => {
181
+ test("declared, not installed, marketplace read, plugin absent → remove", () => {
182
182
  // The bug: the !installed branch ran before any catalog guard, so
183
183
  // `agentdev@magus` (retired) and `seo@magus` (moved to magus-marketing)
184
184
  // planned as `install` with the literal pin as target on every run,
@@ -188,20 +188,20 @@ describe("planPluginUpdates — a pin its own marketplace does not offer", () =>
188
188
  { "agentdev@magus": "latest" },
189
189
  magusCatalog(),
190
190
  );
191
+ // Deprecated now, and the work is dropping the pin from the profile:
192
+ // still never an `install` of something nothing can fetch.
191
193
  expect(items).toHaveLength(1);
192
- expect(items[0]?.action).toBe("unknown");
194
+ expect(items[0]?.action).toBe("remove");
193
195
  expect(items[0]?.target).toBeNull();
194
- expect(items[0]?.note).toContain(
195
- "not offered by any configured marketplace",
196
- );
197
- expect(items[0]?.reason).toBe("not-offered");
196
+ expect(items[0]?.note).toBe("no longer published");
197
+ expect(items[0]?.reason).toBe("deprecated");
198
198
  });
199
199
 
200
- test("an exact pin its marketplace does not offer is also unknown", () => {
200
+ test("an exact pin its marketplace does not offer is also deprecated", () => {
201
201
  const items = planPluginUpdates({ "gone@magus": "3.0.0" }, magusCatalog());
202
- expect(items[0]?.action).toBe("unknown");
202
+ expect(items[0]?.action).toBe("remove");
203
203
  expect(items[0]?.target).toBeNull();
204
- expect(items[0]?.reason).toBe("not-offered");
204
+ expect(items[0]?.reason).toBe("deprecated");
205
205
  });
206
206
 
207
207
  test("declared, not installed, and the catalog does offer it → install", () => {
@@ -345,14 +345,14 @@ describe("installed with no comparable catalog version — two different causes"
345
345
  // The distinction this test exists for is between the two REASONS, and it
346
346
  // still holds. What changed is the verdict: declaring no version is not a
347
347
  // problem, so the row is `current` and says nothing. Being withdrawn IS a
348
- // problem, so the next test's row is `unknown` and says so.
348
+ // problem, so the next test's row is `remove` and says so.
349
349
  expect(items[0]?.reason).toBe("no-published-version");
350
- expect(items[0]?.reason).not.toBe("not-offered");
350
+ expect(items[0]?.reason).not.toBe("deprecated");
351
351
  expect(items[0]?.action).toBe("current");
352
352
  expect(items[0]?.note).toBeUndefined();
353
353
  });
354
354
 
355
- test("absent from the catalog entirely IS 'not offered'", () => {
355
+ test("absent from the catalog entirely IS deprecated", () => {
356
356
  const items = planPluginUpdates(
357
357
  { "gone@magus": "latest" },
358
358
  new Map([
@@ -366,11 +366,9 @@ describe("installed with no comparable catalog version — two different causes"
366
366
  ],
367
367
  ]),
368
368
  );
369
- expect(items[0]?.action).toBe("unknown");
370
- expect(items[0]?.note).toContain(
371
- "not offered by any configured marketplace",
372
- );
373
- expect(items[0]?.reason).toBe("not-offered");
369
+ expect(items[0]?.action).toBe("remove");
370
+ expect(items[0]?.note).toBe("no longer published");
371
+ expect(items[0]?.reason).toBe("deprecated");
374
372
  });
375
373
  });
376
374
 
package/src/cli/ansi.ts CHANGED
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { barTrack } from "../ui/theme-mode.js";
25
25
  import { brand } from "../ui/theme.js";
26
+ import { CSI, stripAnsi } from "../utils/string-utils.js";
26
27
 
27
28
  // -- colour capability -------------------------------------------------------
28
29
 
@@ -192,13 +193,7 @@ export function dim(text: string): string {
192
193
 
193
194
  // -- measuring ---------------------------------------------------------------
194
195
 
195
- /** Every CSI sequence, not only SGR — the live region emits cursor moves too. */
196
- // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
197
- const CSI = /\x1b\[[0-9;?]*[\x20-\x2f]*[\x40-\x7e]/g;
198
-
199
- export function stripAnsi(text: string): string {
200
- return text.replace(CSI, "");
201
- }
196
+ export { stripAnsi };
202
197
 
203
198
  /**
204
199
  * Cells one codepoint occupies: 0, 1 or 2.