magus-cli 7.7.0 → 7.8.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 (83) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +29 -15
  3. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  4. package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
  5. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  6. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  7. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  8. package/src/__tests__/blind-780-review2.test.ts +1203 -0
  9. package/src/__tests__/cli-update-view.test.ts +3 -3
  10. package/src/__tests__/deprecated-remove.test.ts +827 -0
  11. package/src/__tests__/doctor.test.ts +159 -1
  12. package/src/__tests__/failure-popup-review2.test.tsx +920 -0
  13. package/src/__tests__/failure-popup.test.tsx +419 -0
  14. package/src/__tests__/final-fixes-780.test.tsx +724 -0
  15. package/src/__tests__/format-update-outcome.test.ts +6 -32
  16. package/src/__tests__/gitignore-profile.test.ts +1 -9
  17. package/src/__tests__/models-screen-state.test.ts +18 -40
  18. package/src/__tests__/plugin-resolution.test.ts +52 -13
  19. package/src/__tests__/plugin-row-text.test.ts +30 -37
  20. package/src/__tests__/profile-dependencies.test.ts +7 -0
  21. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  22. package/src/__tests__/profile-startup.test.ts +40 -12
  23. package/src/__tests__/stale-load-repair.test.ts +31 -23
  24. package/src/__tests__/update-apply.test.ts +13 -29
  25. package/src/__tests__/update-plan.test.ts +23 -17
  26. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  27. package/src/__tests__/version-unknown-state.test.ts +15 -17
  28. package/src/cli/doctor.ts +151 -30
  29. package/src/cli/install.ts +5 -6
  30. package/src/cli/update-view.ts +58 -15
  31. package/src/cli/update.ts +27 -5
  32. package/src/services/catalog-cache-store.ts +8 -6
  33. package/src/services/claude-cli.ts +106 -19
  34. package/src/services/claude-settings.ts +84 -36
  35. package/src/services/plugin-env-needs.ts +69 -0
  36. package/src/services/plugin-manager.ts +31 -19
  37. package/src/services/plugin-resolution.ts +34 -42
  38. package/src/services/profile-activate.ts +1 -2
  39. package/src/services/profile-startup.ts +18 -43
  40. package/src/services/profile-state.ts +72 -2
  41. package/src/services/resolver.ts +95 -1
  42. package/src/services/update-engine.ts +326 -31
  43. package/src/services/update-plan.ts +293 -33
  44. package/src/types/index.ts +7 -0
  45. package/src/ui/App.tsx +90 -146
  46. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  47. package/src/ui/adapters/failureReport.ts +137 -0
  48. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  49. package/src/ui/adapters/startupReports.ts +282 -0
  50. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  51. package/src/ui/components/modals/MessageModal.tsx +7 -12
  52. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  53. package/src/ui/components/modals/index.ts +1 -0
  54. package/src/ui/hooks/startupQueue.ts +56 -0
  55. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  56. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  57. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  58. package/src/ui/keymap.ts +14 -1
  59. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  60. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  61. package/src/ui/renderers/pluginRowText.ts +25 -25
  62. package/src/ui/renderers/pluginStatusText.ts +18 -1
  63. package/src/ui/screens/AliasScreen.tsx +38 -45
  64. package/src/ui/screens/CliToolsScreen.tsx +65 -54
  65. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  66. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  67. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  68. package/src/ui/screens/McpScreen.tsx +46 -19
  69. package/src/ui/screens/ModelsScreen.tsx +46 -86
  70. package/src/ui/screens/PluginsScreen.tsx +313 -221
  71. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  72. package/src/ui/screens/SkillsScreen.tsx +83 -80
  73. package/src/ui/screens/StylesScreen.tsx +211 -275
  74. package/src/ui/state/AppContext.tsx +66 -44
  75. package/src/ui/state/DimensionsContext.tsx +3 -22
  76. package/src/ui/state/reducer.ts +12 -100
  77. package/src/ui/state/types.ts +48 -69
  78. package/src/utils/user-path.ts +60 -0
  79. package/src/__tests__/notices-rules.test.ts +0 -476
  80. package/src/__tests__/notices.test.tsx +0 -604
  81. package/src/__tests__/styles-status-line.test.ts +0 -72
  82. package/src/__tests__/version-snapshot.test.ts +0 -212
  83. package/src/services/version-snapshot.ts +0 -189
@@ -10,6 +10,8 @@
10
10
  */
11
11
 
12
12
  import { execFile } from "node:child_process";
13
+ import { existsSync } from "node:fs";
14
+ import path from "node:path";
13
15
  import { promisify } from "node:util";
14
16
  import { defaultMarketplaces } from "../data/marketplaces.js";
15
17
  import { which } from "../utils/command-utils.js";
@@ -28,13 +30,12 @@ export type PluginScope = "user" | "project" | "local";
28
30
  * Get the path to the claude CLI binary
29
31
  * @throws Error if claude CLI is not found in PATH
30
32
  */
33
+ const CLAUDE_NOT_FOUND =
34
+ "claude CLI not found in PATH. Install with: npm install -g @anthropic-ai/claude-code";
35
+
31
36
  async function getClaudePath(): Promise<string> {
32
37
  const claudePath = await which("claude");
33
- if (!claudePath) {
34
- throw new Error(
35
- "claude CLI not found in PATH. Install with: npm install -g @anthropic-ai/claude-code",
36
- );
37
- }
38
+ if (!claudePath) throw new Error(CLAUDE_NOT_FOUND);
38
39
  return claudePath;
39
40
  }
40
41
 
@@ -42,29 +43,84 @@ async function getClaudePath(): Promise<string> {
42
43
  * Execute a claude CLI command and return stdout
43
44
  * @param args - Arguments to pass to claude
44
45
  * @param timeoutMs - Timeout in milliseconds (default: 30s)
46
+ * @param cwd - Working directory; Claude Code resolves project and local
47
+ * scope from it. Defaults to this process's.
45
48
  * @returns stdout from the command
46
49
  */
47
- async function execClaude(args: string[], timeoutMs = 30000): Promise<string> {
50
+ async function execClaude(
51
+ args: string[],
52
+ timeoutMs = 30000,
53
+ cwd?: string,
54
+ ): Promise<string> {
48
55
  const claudePath = await getClaudePath();
49
56
  try {
50
57
  const { stdout } = await execFileAsync(claudePath, args, {
51
58
  timeout: timeoutMs,
59
+ ...(cwd ? { cwd } : {}),
52
60
  });
53
61
  return stdout.trim();
54
62
  } catch (error: unknown) {
55
- const execError = error as {
56
- stderr?: string;
57
- stdout?: string;
58
- message?: string;
59
- code?: number;
60
- };
61
- const msg =
62
- execError.stderr?.trim() ||
63
- execError.stdout?.trim() ||
64
- execError.message ||
65
- "claude command failed";
66
- throw new Error(msg);
63
+ throw new Error(claudeFailureMessage(error, args, timeoutMs, cwd));
64
+ }
65
+ }
66
+
67
+ /**
68
+ * What a failed `claude` command says, in the words a user reads.
69
+ *
70
+ * Claude Code's own output first — stderr, then stdout — since that is the
71
+ * reason. Only when it printed nothing does Node's error message stand in, and
72
+ * that one opens with `Command failed: /absolute/path/to/claude plugin …`:
73
+ * the path and the argument list are the mechanism, not the reason, so that
74
+ * line is dropped and what follows it kept. A run that was killed for taking
75
+ * too long says so.
76
+ *
77
+ * A spawn that fails with ENOENT is not always a missing `claude`: Node and
78
+ * Bun both report a working directory that does not exist the same way,
79
+ * naming the binary. So when the run was given a `cwd` that is gone, that is
80
+ * what is said — by the folder's name, never its absolute path — rather than
81
+ * telling someone with Claude Code installed to install it.
82
+ */
83
+ export function claudeFailureMessage(
84
+ error: unknown,
85
+ args: readonly string[],
86
+ timeoutMs: number,
87
+ cwd?: string,
88
+ ): string {
89
+ const execError = error as {
90
+ code?: string | number;
91
+ stderr?: string;
92
+ stdout?: string;
93
+ message?: string;
94
+ killed?: boolean;
95
+ signal?: string | null;
96
+ };
97
+ const output = execError.stderr?.trim() || execError.stdout?.trim();
98
+ if (output) return output;
99
+ if (execError.killed) {
100
+ return `claude ${args.slice(0, 2).join(" ")} did not finish within ${Math.round(timeoutMs / 1000)}s`;
101
+ }
102
+ const message = execError.message ?? "";
103
+ // The binary vanished between the PATH lookup and the spawn (Node says
104
+ // `spawn <path> ENOENT`, Bun `Executable not found in $PATH`). Same fact as
105
+ // the lookup failing, so the same words — and never the absolute path.
106
+ if (
107
+ execError.code === "ENOENT" ||
108
+ /\bENOENT\b|Executable not found in \$PATH/.test(message)
109
+ ) {
110
+ if (cwd && !existsSync(cwd)) {
111
+ return `the folder ${path.basename(cwd) || cwd} no longer exists`;
112
+ }
113
+ return CLAUDE_NOT_FOUND;
114
+ }
115
+ if (execError.code === "EACCES" || /\bEACCES\b/.test(message)) {
116
+ return "claude could not be started: permission denied";
67
117
  }
118
+ const rest = message
119
+ .replace(/^Command failed: [^\n]*\n?/, "")
120
+ // Any leftover mention of the binary by absolute path is mechanism.
121
+ .replace(/(?:\/[^\s/:]+)+\/claude\b/g, "claude")
122
+ .trim();
123
+ return rest || `claude ${args.slice(0, 2).join(" ")} failed`;
68
124
  }
69
125
 
70
126
  /**
@@ -186,6 +242,32 @@ export async function uninstallPlugin(
186
242
  }
187
243
  }
188
244
 
245
+ /**
246
+ * Uninstall through `claude plugin uninstall` and NOTHING else: its error is
247
+ * thrown, never absorbed.
248
+ *
249
+ * {@link uninstallPlugin} falls back to editing the settings files directly
250
+ * when the command fails. That fallback is a direct writer of Claude Code's
251
+ * files, and it turns every failure into an apparent success — so a caller
252
+ * that reads the result back, as removing a deprecated plugin does, could
253
+ * never see why the plugin was still there. This one lets the command's own
254
+ * words reach the caller.
255
+ *
256
+ * Runs in `projectPath` when given, because Claude Code resolves project and
257
+ * local scope from its working directory.
258
+ */
259
+ export async function uninstallPluginViaCli(
260
+ pluginId: string,
261
+ scope: PluginScope,
262
+ projectPath?: string,
263
+ ): Promise<void> {
264
+ await execClaude(
265
+ ["plugin", "uninstall", pluginId, "--scope", scope],
266
+ 30000,
267
+ projectPath,
268
+ );
269
+ }
270
+
189
271
  /**
190
272
  * Repair a plugin whose files drifted from the marketplace under an unchanged
191
273
  * version — uninstall, then install again at the same scope.
@@ -270,7 +352,12 @@ export async function disablePlugin(
270
352
  * {@link installPlugin}, and content drift under an unchanged version still
271
353
  * needs {@link repairPlugin} — `update` has no version bump to act on there.
272
354
  *
273
- * Retries with marketplace update on "not found" errors (same as installPlugin).
355
+ * On a "not found in marketplace" error it recovers once and retries, exactly
356
+ * as {@link installPlugin} does (see `recoverMissingMarketplace`): a registered
357
+ * marketplace has its source repaired and its clone fast-forwarded with
358
+ * `git pull --ff-only` — never `claude plugin marketplace update`, which
359
+ * deletes the clone before re-cloning — and a missing clone or an unregistered
360
+ * default marketplace is added with `claude plugin marketplace add`.
274
361
  */
275
362
  export async function updatePlugin(
276
363
  pluginId: string,
@@ -573,6 +573,58 @@ export async function removeLocalInstalledPluginVersion(
573
573
  );
574
574
  }
575
575
 
576
+ /**
577
+ * Take `pluginId` out of `installedPluginVersions` at `scope`, and touch
578
+ * nothing else.
579
+ *
580
+ * `installedPluginVersions` is magus's own field — Claude Code never writes it
581
+ * — so a plugin Claude Code uninstalled still has a claim there until magus
582
+ * clears it, and a stale claim is how an uninstalled plugin kept reading as
583
+ * installed. Unlike the `remove*InstalledPluginVersion` fallbacks above, this
584
+ * leaves `enabledPlugins` and the registry alone: those are Claude Code's, and
585
+ * the uninstall that just ran is what changed them. A file with no claim for
586
+ * the plugin is not rewritten.
587
+ */
588
+ export async function forgetInstalledPluginVersion(
589
+ pluginId: string,
590
+ scope: "user" | "project" | "local",
591
+ projectPath?: string,
592
+ ): Promise<void> {
593
+ if (scope === "user") {
594
+ const settings = await readGlobalSettings();
595
+ const versions = writableMap(
596
+ settings,
597
+ "installedPluginVersions",
598
+ getGlobalSettingsPath(),
599
+ );
600
+ if (!versions || !Object.hasOwn(versions, pluginId)) return;
601
+ delete versions[pluginId];
602
+ await writeGlobalSettings(settings);
603
+ return;
604
+ }
605
+ if (scope === "local") {
606
+ const settings = await readLocalSettings(projectPath);
607
+ const versions = writableMap(
608
+ settings,
609
+ "installedPluginVersions",
610
+ getLocalSettingsPath(projectPath),
611
+ );
612
+ if (!versions || !Object.hasOwn(versions, pluginId)) return;
613
+ delete versions[pluginId];
614
+ await writeLocalSettings(settings, projectPath);
615
+ return;
616
+ }
617
+ const settings = await readSettings(projectPath);
618
+ const versions = writableMap(
619
+ settings,
620
+ "installedPluginVersions",
621
+ getSettingsPath(projectPath),
622
+ );
623
+ if (!versions || !Object.hasOwn(versions, pluginId)) return;
624
+ delete versions[pluginId];
625
+ await writeSettings(settings, projectPath);
626
+ }
627
+
576
628
  // Status line management
577
629
  // Claude Code expects statusLine as an object: { type: "template", template: "..." }
578
630
  // or { type: "command", command: "..." }. Writing a bare string causes a validation error
@@ -1992,9 +2044,8 @@ export async function removeFromInstalledPluginsRegistry(
1992
2044
  * the reach it states, so those rows are never written here. When such a row is
1993
2045
  * the one that WINS, the repair refuses outright and names the scope — silently
1994
2046
  * rewriting nothing would be the worse answer of the two.
1995
- * - A null repository root matches null in the loader (two rootless directories
1996
- * are relevant to each other there, and this port now says so). It must not
1997
- * match here: "the same repository" cannot be true of two directories that are
2047
+ * - A null repository root matches nothing, here and (since Claude Code
2048
+ * 2.1.284) in the loader too: "the same repository" cannot be true of two directories that are
1998
2049
  * in no repository at all, and treating it as true would have a repair run
1999
2050
  * from a scratch directory rewrite another scratch directory's rows. Only the
2000
2051
  * exact same directory qualifies without a root.
@@ -2209,6 +2260,15 @@ export async function loadedVersionHere(
2209
2260
  }
2210
2261
  }
2211
2262
 
2263
+ /**
2264
+ * {@link repairStaleLoad}'s refusal when `target` has no downloaded copy to
2265
+ * point Claude Code at. Exported so a caller that already names the version —
2266
+ * "installed v1.3.0, but …" — can say it without repeating it.
2267
+ */
2268
+ export function notDownloadedReason(target: string): string {
2269
+ return `v${target} is not downloaded — run magus install`;
2270
+ }
2271
+
2212
2272
  /**
2213
2273
  * What one {@link repairStaleLoad} attempt did, and whether it worked.
2214
2274
  *
@@ -2326,9 +2386,7 @@ export async function repairStaleLoad(
2326
2386
  root,
2327
2387
  );
2328
2388
  if (!(await fs.pathExists(cacheDir))) {
2329
- return fail(
2330
- `no cache directory for v${target} — ${cacheDir} does not exist`,
2331
- );
2389
+ return fail(notDownloadedReason(target));
2332
2390
  }
2333
2391
 
2334
2392
  const file = installedPluginsFile(root);
@@ -2336,22 +2394,22 @@ export async function repairStaleLoad(
2336
2394
  // precondition the write is checked against, so they have to describe one
2337
2395
  // moment — see `writeInstalledPluginsRegistry`.
2338
2396
  const before = await readRegistryBytes(file);
2339
- if (before === null) return fail(`${file} does not exist`);
2397
+ if (before === null) return fail("no plugin is installed on this machine");
2340
2398
  let registry: InstalledPluginsRegistry;
2341
2399
  try {
2342
2400
  registry = requireRegistry(file, before);
2343
- } catch (error) {
2401
+ } catch {
2344
2402
  // The same check every other reader makes; a repair reports it as a result
2345
2403
  // rather than throwing, because it is one item in a plan of many.
2404
+ // Why it does not parse is `magus doctor`'s to show; here it is only the
2405
+ // reason nothing was changed.
2346
2406
  return fail(
2347
- `${file} is not a readable registry — refusing to write over it: ${
2348
- error instanceof Error ? error.message : String(error)
2349
- }`,
2407
+ "Claude Code's list of installed plugins cannot be read, so nothing was changed — run magus doctor",
2350
2408
  );
2351
2409
  }
2352
2410
  const rows = registry.plugins[pluginId];
2353
2411
  if (!Array.isArray(rows) || rows.length === 0) {
2354
- return fail(`${pluginId} has no rows in ${file}`);
2412
+ return fail("it is not installed on this machine");
2355
2413
  }
2356
2414
 
2357
2415
  // One cache for the whole pass: the same project paths repeat across rows,
@@ -2374,13 +2432,9 @@ export async function repairStaleLoad(
2374
2432
  // `LoaderScope` in plugin-resolution.ts.
2375
2433
  const scope: string = winner.scope;
2376
2434
  return fail(
2377
- `the row Claude Code loads here is the machine-wide ${scope}-scope record at v${
2378
- winner.version
2379
- }, which every project on this machine resolves through. This repair only rewrites THIS repository's records, so it will not move it. ${
2380
- scope === "managed"
2381
- ? "A managed record is administrator policy — raise it with whoever set that policy."
2382
- : `Update that scope directly instead: claude plugin install ${pluginId} --scope user`
2383
- }`,
2435
+ scope === "managed"
2436
+ ? "Claude Code loads the install your administrator manages here — ask them to update it"
2437
+ : "Claude Code loads the USER install of it here — update it at USER scope",
2384
2438
  );
2385
2439
  }
2386
2440
 
@@ -2398,11 +2452,9 @@ export async function repairStaleLoad(
2398
2452
  try {
2399
2453
  backup = await registryBackupPath(file, pluginId);
2400
2454
  await fs.copy(file, backup);
2401
- } catch (error) {
2455
+ } catch {
2402
2456
  return fail(
2403
- `could not back up ${file}: ${
2404
- error instanceof Error ? error.message : String(error)
2405
- }`,
2457
+ "could not save a backup before changing anything, so nothing was changed",
2406
2458
  );
2407
2459
  }
2408
2460
 
@@ -2428,10 +2480,8 @@ export async function repairStaleLoad(
2428
2480
  if (backup) await fs.remove(backup).catch(() => {});
2429
2481
  return fail(
2430
2482
  error instanceof RegistryChangedError
2431
- ? error.message
2432
- : `could not write ${file}: ${
2433
- error instanceof Error ? error.message : String(error)
2434
- }`,
2483
+ ? "another Claude Code session changed its plugins at the same moment, so nothing was changed — run it again"
2484
+ : "the change could not be saved, so nothing was changed",
2435
2485
  );
2436
2486
  }
2437
2487
  await pruneRegistryBackups(file);
@@ -2444,12 +2494,8 @@ export async function repairStaleLoad(
2444
2494
  let after: InstalledPluginsRegistry;
2445
2495
  try {
2446
2496
  after = await readInstalledPluginsRegistry(root);
2447
- } catch (error) {
2448
- return fail(
2449
- `could not re-read ${file} to confirm the repair: ${
2450
- error instanceof Error ? error.message : String(error)
2451
- }`,
2452
- );
2497
+ } catch {
2498
+ return fail("the change was saved, but could not be checked afterwards");
2453
2499
  }
2454
2500
  const loaded =
2455
2501
  pickLoadedEntry(after.plugins[pluginId], projectPath)?.version ?? null;
@@ -2470,8 +2516,10 @@ export async function repairStaleLoad(
2470
2516
  backup,
2471
2517
  loaded,
2472
2518
  ok: false,
2473
- error: `the loader still resolves ${
2474
- loaded ? `v${loaded}` : "nothing"
2475
- } here, not v${target}`,
2519
+ // The user's words, not the mechanism's: this reaches both front ends
2520
+ // verbatim as the reason an item failed, grouped by that reason.
2521
+ error: loaded
2522
+ ? `Claude Code still loads v${loaded} in this folder`
2523
+ : "Claude Code loads no version of it in this folder",
2476
2524
  };
2477
2525
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Which variables a plugin's MCP server needs and does not have yet.
3
+ *
4
+ * DERIVED FROM CURRENT CONFIG, every time: the plugin's own MCP config names
5
+ * the `${VAR}` references, and a variable counts as set when this project's
6
+ * MCP variables hold it or magus's environment carries it. Nothing records
7
+ * that the user declined to set them — so the Plugins screen's "needs N
8
+ * settings" state disappears on its own the moment they are set, however
9
+ * they were set.
10
+ *
11
+ * One function for both the install flow (which asks for exactly these) and
12
+ * the detail panel (which states them), so the two cannot disagree.
13
+ */
14
+
15
+ import { getMcpEnvVars } from "./claude-settings.js";
16
+ import {
17
+ type PluginEnvRequirement,
18
+ getPluginEnvRequirements,
19
+ getPluginSourcePath,
20
+ } from "./plugin-mcp-config.js";
21
+
22
+ /** The reads, injectable so a test needs no marketplace clone on disk. */
23
+ export interface PluginEnvNeedsDeps {
24
+ sourcePath: (
25
+ marketplace: string,
26
+ pluginName: string,
27
+ ) => Promise<string | undefined>;
28
+ requirements: (
29
+ marketplace: string,
30
+ pluginSource: string,
31
+ ) => Promise<PluginEnvRequirement[]>;
32
+ configured: (projectPath?: string) => Promise<Record<string, string>>;
33
+ }
34
+
35
+ const REAL_DEPS: PluginEnvNeedsDeps = {
36
+ sourcePath: getPluginSourcePath,
37
+ requirements: getPluginEnvRequirements,
38
+ configured: getMcpEnvVars,
39
+ };
40
+
41
+ /**
42
+ * The variables `pluginName`'s MCP servers reference that are set nowhere —
43
+ * empty when it has no MCP server, needs nothing, or has everything.
44
+ *
45
+ * Throws when the project's variables cannot be read (a settings file that
46
+ * does not parse): "needs nothing" would be a guess.
47
+ */
48
+ export async function missingPluginEnvVars(
49
+ marketplace: string,
50
+ pluginName: string,
51
+ projectPath?: string,
52
+ env: Readonly<Record<string, string | undefined>> = process.env,
53
+ deps: PluginEnvNeedsDeps = REAL_DEPS,
54
+ ): Promise<PluginEnvRequirement[]> {
55
+ const source = await deps.sourcePath(marketplace, pluginName);
56
+ if (!source) return [];
57
+ const requirements = await deps.requirements(marketplace, source);
58
+ if (requirements.length === 0) return [];
59
+ const configured = await deps.configured(projectPath);
60
+ return requirements.filter((req) => !configured[req.name] && !env[req.name]);
61
+ }
62
+
63
+ /**
64
+ * The detail panel's words for a plugin whose MCP server lacks variables:
65
+ * what is missing, then the one key that sets them.
66
+ */
67
+ export function mcpSettingsNeededText(count: number): string {
68
+ return `its MCP server needs ${count} ${count === 1 ? "setting" : "settings"} — press e to set ${count === 1 ? "it" : "them"}`;
69
+ }
@@ -162,17 +162,6 @@ export interface PluginInfo {
162
162
  * a reinstall delivers the current content. See content-drift.ts.
163
163
  */
164
164
  contentStale?: boolean;
165
- /**
166
- * Set when this plugin's installed version changed since magus last
167
- * rendered it — including updates made by Claude Code or a direct CLI call
168
- * rather than by magus itself. Drives the "updated" badge.
169
- */
170
- recentlyUpdatedFrom?: string;
171
- /**
172
- * Set when magus is seeing this plugin installed for the first time.
173
- * Never set on the very first run, when there is no baseline to compare to.
174
- */
175
- recentlyInstalled?: boolean;
176
165
  /**
177
166
  * Which catalog supplied `version` — i.e. the number this plugin's installed
178
167
  * version was compared against.
@@ -389,6 +378,28 @@ export function resolveScopeAction(
389
378
  return behind ? "update" : "uninstall";
390
379
  }
391
380
 
381
+ /**
382
+ * The plugin ids the Plugins list may show as installed-but-unlisted rows.
383
+ *
384
+ * Installed means a key in this project's `enabledPlugins`, or registry rows.
385
+ * A version in `installedPluginVersions` with neither behind it is not an
386
+ * install: Claude Code never writes that field, so such a claim is a leftover
387
+ * of magus's own, and building a row from it showed a plugin nothing had
388
+ * installed — the settings key treated as authority, which it never is.
389
+ */
390
+ export function orphanCandidateIds(
391
+ enabledPlugins: Readonly<Record<string, boolean>>,
392
+ installedVersions: Readonly<Record<string, string>>,
393
+ registryPlugins: InstalledPluginsRegistry["plugins"] | undefined,
394
+ ): Set<string> {
395
+ return new Set([
396
+ ...Object.keys(enabledPlugins),
397
+ ...Object.keys(installedVersions).filter(
398
+ (id) => (registryPlugins?.[id]?.length ?? 0) > 0,
399
+ ),
400
+ ]);
401
+ }
402
+
392
403
  export async function getAvailablePlugins(
393
404
  projectPath?: string,
394
405
  ): Promise<PluginInfo[]> {
@@ -402,8 +413,9 @@ export async function getAvailablePlugins(
402
413
  // ported in services/plugin-resolution.ts. Resolved once for the whole
403
414
  // registry and carried alongside `installedVersion` on every row below; where
404
415
  // the two disagree, the classifier calls it `stale-load`.
416
+ const registry = await readInstalledPluginsRegistry();
405
417
  const loadedVersions = resolveLoadedVersions(
406
- await readInstalledPluginsRegistry(),
418
+ registry,
407
419
  projectPath ?? process.cwd(),
408
420
  );
409
421
  const loadedVersionOf = (pluginId: string): string | null =>
@@ -568,13 +580,13 @@ export async function getAvailablePlugins(
568
580
  }
569
581
  }
570
582
 
571
- // Add orphaned plugins (in this project's settings or registry, but not in any cache)
572
- const allPluginIds = new Set([
573
- ...Object.keys(enabledPlugins),
574
- ...Object.keys(installedVersions),
575
- ]);
576
-
577
- for (const pluginId of allPluginIds) {
583
+ // Add orphaned plugins (in this project's settings or registry, but not in
584
+ // any cache) — see `orphanCandidateIds` for what counts as installed.
585
+ for (const pluginId of orphanCandidateIds(
586
+ enabledPlugins,
587
+ installedVersions,
588
+ registry.plugins,
589
+ )) {
578
590
  if (seenPluginIds.has(pluginId)) continue;
579
591
 
580
592
  const parsed = parsePluginId(pluginId);
@@ -4,7 +4,12 @@
4
4
  * Ported from Claude Code 2.1.274, decompiled: the relevance filter `SF`
5
5
  * (byte 178070986) and the selection function `Bls` (byte 178140984). The port
6
6
  * was validated against the real loader over 123/123 plugin resolutions across
7
- * 12 repositories and worktrees. RE-VALIDATE IT WHEN CLAUDE CODE UPDATES —
7
+ * 12 repositories and worktrees. Re-derived from 2.1.284 (`Aw` at byte
8
+ * 188165906, `t5t` at 188274660): the picker is unchanged, and the relevance
9
+ * filter now rejects a row with no `projectPath` and matches nothing by root when
10
+ * the current folder is in no repository (see {@link isRelevantInstallation}).
11
+ * Checked against `--debug-file` in a rootless folder (7/7) and in a repository
12
+ * (11/11). RE-VALIDATE IT WHEN CLAUDE CODE UPDATES —
8
13
  * this file describes someone else's algorithm, so a new release can move it
9
14
  * without anything here failing.
10
15
  *
@@ -21,26 +26,14 @@
21
26
  * This module holds NO magus policy. It answers the second question only, so
22
27
  * that a disagreement between the two is visible rather than averaged away.
23
28
  *
24
- * KNOWN DIVERGENCES from the decompiled original, both of which only ever REMOVE
25
- * candidates — so each can make this module name an entry the loader does not
26
- * use, and neither can make it miss one the loader does use:
27
- *
28
- * 1. The binary additionally rejects an entry whose `installPath` fails a
29
- * path-safety check (`suspect(installPath)`). Not ported — every
30
- * `installPath` is treated as non-suspect here.
31
- * 2. The binary's relevance filter reads `entry.projectPath === originalCwd ||
32
- * repoRoot(entry.projectPath) === repoRoot(originalCwd)` with no guard on
33
- * `projectPath` being absent at all. A project-scope row carrying no
34
- * `projectPath` is rejected outright here (`isRelevantInstallation`), because
35
- * what `repoRoot(undefined)` answers inside the binary was not measured and
36
- * guessing it would put a row into the selection on a guess. Claude Code
37
- * writes `projectPath` for every project-scope install, so the case is
38
- * theoretical rather than observed.
39
- *
40
- * A THIRD divergence was removed rather than documented: two rootless paths used
41
- * to compare unequal here, so in a directory belonging to no repository every
42
- * rootless row was irrelevant to magus while the loader still counted it. See
43
- * {@link isRelevantInstallation}.
29
+ * ONE KNOWN DIVERGENCE from the decompiled original: the binary additionally
30
+ * rejects an entry whose `installPath` fails a path-safety check
31
+ * (`suspect(installPath)`). Not ported — every `installPath` is treated as
32
+ * non-suspect here. That check only ever removes candidates, so skipping it can
33
+ * make this module name an entry the loader does not use, never miss one the
34
+ * loader does use. A row's `projectPath` is compared as the raw string the
35
+ * binary compares — normalising it once added candidates the loader rejects —
36
+ * so path handling is not a second divergence.
44
37
  */
45
38
 
46
39
  import fs from "node:fs";
@@ -193,28 +186,27 @@ export function isRelevantInstallation(
193
186
  if (isMachineWideScope(entry.scope)) return true;
194
187
  if (!entry.projectPath) return false;
195
188
 
196
- // The binary compares the two paths as raw strings. They are normalised here
197
- // first, which can only turn a spelling difference of the same directory
198
- // ("/p/a/" vs "/p/a") into a match — and that same pair already matches on
199
- // the repository-root comparison below, which normalises on the way through.
200
- if (path.resolve(entry.projectPath) === path.resolve(cwd)) return true;
201
-
202
- // NULL MATCHES NULL, because the original's `repoRoot(entry.projectPath) ===
203
- // repoRoot(originalCwd)` does: two directories in no repository at all compare
204
- // equal there, so every rootless row competes when Claude Code runs from a
205
- // scratch directory, a home directory, or any plain non-git folder.
189
+ // Raw strings, exactly as the binary compares them. Normalising first looked
190
+ // harmless — "/p/a/" and "/p/a" are the same directory — but it is not a
191
+ // no-op: in a folder outside any repository the root comparison below
192
+ // matches nothing, so normalisation was the ONLY thing that could make that
193
+ // pair relevant, and it added a candidate the loader rejects.
206
194
  //
207
- // This used to `return false` on a null `cwdRoot`, on the reasoning that two
208
- // unrelated rootless directories are not the same project. That reasoning is
209
- // magus policy, and this module holds none: with it, magus could name a
210
- // `loadedVersion` the loader would not pick, and a repair would skip the very
211
- // rows the loader treats as relevant — the same class of wrong answer the port
212
- // exists to eliminate.
213
- //
214
- // The policy did not disappear, it moved to where it belongs: `repairStaleLoad`
215
- // rewrites a row only when it belongs to THIS repository, which requires either
216
- // the exact same directory or a non-null shared root. So a rootless row from an
217
- // unrelated directory is reported as relevant (true) and never written (correct).
195
+ // Only `cwd` is resolved, because it stands in for the binary's own working
196
+ // directory, which is always absolute and has no trailing slash; a caller
197
+ // handing over "./x" or "/p/a/" means that directory. The row's path is
198
+ // compared exactly as written, as the binary compares it.
199
+ if (entry.projectPath === path.resolve(cwd)) return true;
200
+
201
+ // A directory in no repository matches NOTHING by root. Claude Code 2.1.284
202
+ // guards the comparison (`Aw` at byte 188165906:
203
+ // `let r=Zx(n);return r!==null&&Zx(e.projectPath)===r`), so in a rootless
204
+ // folder only its own rows and the machine-wide ones count. 2.1.274 had no
205
+ // guard and two nulls compared equal, which this port used to copy — and so
206
+ // named, in every rootless folder, versions from strangers' rows that the
207
+ // loader never looks at. Measured on 2.1.284 with `--debug-file` in a rootless
208
+ // folder: 7 of 7 plugins matched this rule, 2 of 7 matched the old one.
209
+ if (cwdRoot === null) return false;
218
210
  return repoRootForPath(entry.projectPath, cache) === cwdRoot;
219
211
  }
220
212
 
@@ -209,8 +209,7 @@ export function releasedCommittedSkillNote(folder: string): string {
209
209
 
210
210
  /**
211
211
  * Lines describing what activation did beyond the switch itself, phrased for
212
- * the terminal. The TUI words its own, shorter set (`activationNotices` in
213
- * ui/hooks/useProfileStartupModal.ts).
212
+ * the terminal.
214
213
  *
215
214
  * `tracked: false` leaves the tracked-path notices out, for the CLI, which
216
215
  * prints them itself as a warning with the fix beneath it