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
@@ -1,21 +1,28 @@
1
1
  /**
2
- * Read plugin-declared binary dependencies from plugin.json `requires.bin`.
2
+ * What a plugin's plugin.json says it needs: its `dependencies` (other plugins)
3
+ * and its `requires` (binaries on the machine).
3
4
  *
4
- * A plugin declares the binaries it needs on PATH (e.g. terminal@magus needs
5
- * `tmux` and `tmux-mcp`). `magus install` resolves these transitively so a
6
- * teammate who enables a plugin also gets its binaries — closing the class of
7
- * bug where a plugin's MCP server silently fails because its binary is missing.
5
+ * `requires` is parsed by `shared/plugin/hooks/lib/requires-probe.ts` and by
6
+ * nothing else — the same file every declaring plugin carries for its
7
+ * SessionStart deps check, so the hook and magus read one declaration one way.
8
+ * This module only locates the manifest Claude Code loads and hands it over.
8
9
  */
9
10
 
10
11
  import os from "node:os";
11
12
  import path from "node:path";
12
13
  import fs from "fs-extra";
13
- import type {
14
- BinInstaller,
15
- BinRequirement,
16
- PluginRequires,
17
- } from "../types/index.js";
18
- import { goBinaryName } from "../utils/go-module.js";
14
+ import {
15
+ type Arch,
16
+ type Os,
17
+ type ParseResult,
18
+ type Requirement,
19
+ applicable,
20
+ currentPlatform,
21
+ onPath,
22
+ parseRequires,
23
+ selectOption,
24
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
25
+ import type { SelectedBin } from "../types/index.js";
19
26
  import { parsePluginId } from "../utils/string-utils.js";
20
27
  import { readInstalledPluginsRegistry } from "./claude-settings.js";
21
28
  import { getLocalMarketplacesInfo } from "./plugin-manager.js";
@@ -24,144 +31,6 @@ import { pickLoadedEntry } from "./plugin-resolution.js";
24
31
  const MARKETPLACES_DIR = (): string =>
25
32
  path.join(os.homedir(), ".claude", "plugins", "marketplaces");
26
33
 
27
- const VALID_INSTALLERS: readonly BinInstaller[] = [
28
- "bun",
29
- "brew",
30
- "npm",
31
- "pip",
32
- "go",
33
- ];
34
-
35
- /**
36
- * Normalize a raw `requires.bin` entry into a complete BinRequirement,
37
- * filling in defaults. Returns null for entries that can't be made valid
38
- * (missing name, or an unrecognized installer).
39
- */
40
- function normalizeBin(raw: unknown): BinRequirement | null {
41
- if (!raw || typeof raw !== "object") return null;
42
- const o = raw as Record<string, unknown>;
43
- const name = typeof o.name === "string" ? o.name.trim() : "";
44
- if (!name) return null;
45
-
46
- const via = o.via as BinInstaller;
47
- if (!VALID_INSTALLERS.includes(via)) return null;
48
-
49
- const bin: BinRequirement = {
50
- name,
51
- via,
52
- check:
53
- typeof o.check === "string" && o.check.trim()
54
- ? o.check.trim()
55
- : `${name} --version`,
56
- };
57
- if (typeof o.package === "string") bin.package = o.package;
58
- if (typeof o.formula === "string") bin.formula = o.formula;
59
- if (typeof o.module === "string") bin.module = o.module;
60
- if (typeof o.version === "string") bin.version = o.version;
61
- return bin;
62
- }
63
-
64
- /** Split a package/module spec into its base and optional version. Handles
65
- * scoped npm (@scope/pkg@1.2.0) and go modules (host/path@latest). */
66
- function splitVersion(spec: string): { base: string; version?: string } {
67
- const at = spec.lastIndexOf("@");
68
- // at > 0 avoids treating a leading npm scope "@scope/..." as a version.
69
- if (at > 0) return { base: spec.slice(0, at), version: spec.slice(at + 1) };
70
- return { base: spec };
71
- }
72
-
73
- const basename = (p: string): string => p.split("/").pop() ?? p;
74
-
75
- /**
76
- * Map a plugin.json `setup` block (the established convention: browser-use uses
77
- * `pip`, terminal uses `go` + `required`) to BinRequirements.
78
- * setup: { bun|npm|pip: string[], go: string[], brew: string[], required: string[] }
79
- */
80
- function parseSetup(setup: unknown): BinRequirement[] {
81
- if (!setup || typeof setup !== "object") return [];
82
- const s = setup as Record<string, unknown>;
83
- const bins: BinRequirement[] = [];
84
-
85
- const pkgManager = (key: "bun" | "npm" | "pip"): void => {
86
- for (const spec of (s[key] as string[] | undefined) ?? []) {
87
- if (typeof spec !== "string") continue;
88
- const { base, version } = splitVersion(spec);
89
- bins.push({
90
- name: basename(base),
91
- via: key,
92
- package: base,
93
- version,
94
- check: `${basename(base)} --version`,
95
- });
96
- }
97
- };
98
- pkgManager("bun");
99
- pkgManager("npm");
100
- pkgManager("pip");
101
-
102
- // Not basename(): a Go module's trailing /vN is its major version, not the
103
- // binary. `tmux-mcp/v2` installs `tmux-mcp`; naming it `v2` here made every
104
- // downstream presence check look for a binary that never exists.
105
- for (const spec of (s.go as string[] | undefined) ?? []) {
106
- if (typeof spec !== "string") continue;
107
- const { base, version } = splitVersion(spec);
108
- const name = goBinaryName(base);
109
- bins.push({
110
- name,
111
- via: "go",
112
- module: base,
113
- version,
114
- check: `${name} --version`,
115
- });
116
- }
117
- for (const spec of (s.brew as string[] | undefined) ?? []) {
118
- if (typeof spec !== "string") continue;
119
- bins.push({
120
- name: spec,
121
- via: "brew",
122
- formula: spec,
123
- check: `${spec} --version`,
124
- });
125
- }
126
- // `required` names a system tool that must be present; brew is the assumed
127
- // installer (matches the "system tools via brew" decision).
128
- for (const name of (s.required as string[] | undefined) ?? []) {
129
- if (typeof name !== "string") continue;
130
- bins.push({ name, via: "brew", formula: name, check: `${name} --version` });
131
- }
132
-
133
- return bins;
134
- }
135
-
136
- /**
137
- * Parse a plugin.json's binary requirements from BOTH the established `setup`
138
- * block and the richer explicit `requires.bin` form. On a name collision the
139
- * explicit `requires.bin` entry wins (it can override installer/check/version).
140
- */
141
- export function parsePluginRequires(pluginJson: unknown): PluginRequires {
142
- if (!pluginJson || typeof pluginJson !== "object") return {};
143
- const obj = pluginJson as Record<string, unknown>;
144
-
145
- const fromSetup = parseSetup(obj.setup);
146
-
147
- const requires = obj.requires;
148
- const binRaw =
149
- requires && typeof requires === "object"
150
- ? (requires as Record<string, unknown>).bin
151
- : undefined;
152
- const fromRequires = Array.isArray(binRaw)
153
- ? binRaw.map(normalizeBin).filter((b): b is BinRequirement => b !== null)
154
- : [];
155
-
156
- // Merge, requires.bin winning by name.
157
- const byName = new Map<string, BinRequirement>();
158
- for (const b of fromSetup) byName.set(b.name, b);
159
- for (const b of fromRequires) byName.set(b.name, b);
160
-
161
- const bin = [...byName.values()];
162
- return bin.length > 0 ? { bin } : {};
163
- }
164
-
165
34
  /**
166
35
  * The plugin.json at a plugin directory, parsed — null when there is none or
167
36
  * it does not parse.
@@ -191,12 +60,15 @@ export async function readPluginManifest(
191
60
  return null;
192
61
  }
193
62
 
194
- /** Read `requires` from a plugin.json at a known plugin directory. */
63
+ /**
64
+ * `requires` from the plugin.json at a plugin directory, or null when there is
65
+ * no readable manifest there.
66
+ */
195
67
  export async function readPluginRequires(
196
68
  pluginPath: string,
197
- ): Promise<PluginRequires> {
198
- // Unreadable/malformed — treat as no requirements.
199
- return parsePluginRequires(await readPluginManifest(pluginPath));
69
+ ): Promise<ParseResult | null> {
70
+ const manifest = await readPluginManifest(pluginPath);
71
+ return manifest === null ? null : parseRequires(manifest);
200
72
  }
201
73
 
202
74
  /**
@@ -257,8 +129,8 @@ export interface PluginManifestSources {
257
129
  * row its loader resolves there (`pickLoadedEntry`). Claude Code checks
258
130
  * the dependencies of the version it loads, which may be older than the
259
131
  * marketplace's latest;
260
- * 2. its source in the local marketplace clone (the reader `requires.bin`
261
- * uses), for a plugin not installed yet;
132
+ * 2. its source in the local marketplace clone, for a plugin not installed
133
+ * yet;
262
134
  * 3. any other installed copy — where a plugin whose marketplace entry
263
135
  * points at another repository lives.
264
136
  */
@@ -337,14 +209,73 @@ export async function resolvePluginPath(
337
209
  }
338
210
 
339
211
  /**
340
- * Resolve the binary requirements a plugin declares. Returns an empty array
341
- * when the plugin can't be located or declares none.
212
+ * The `requires` of the copy of `pluginId` Claude Code loads in `projectPath`
213
+ * (else its marketplace clone), and the directory it was read from — the same
214
+ * plugin.json its SessionStart hook reads through ${CLAUDE_PLUGIN_ROOT}. Null
215
+ * when no plugin.json for it can be read on this machine.
216
+ */
217
+ export async function resolvePluginRequires(
218
+ pluginId: string,
219
+ projectPath: string = process.cwd(),
220
+ sources: PluginManifestSources = {},
221
+ ): Promise<{ dir: string; parse: ParseResult } | null> {
222
+ for (const dir of await pluginManifestDirs(pluginId, projectPath, sources)) {
223
+ const parse = await readPluginRequires(dir);
224
+ if (parse !== null) return { dir, parse };
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * Each requirement that applies on this platform, with its option chosen for
231
+ * this machine — the first in declaration order whose prerequisite is on PATH
232
+ * or declared beside it ({@link selectOption}) — and the directory it was read
233
+ * from, which a `${CLAUDE_PLUGIN_ROOT}` check and a plugin-relative script
234
+ * resolve against.
235
+ */
236
+ export function selectBins(
237
+ requirements: Requirement[],
238
+ root: string | undefined,
239
+ pathEnv: string = process.env.PATH ?? "",
240
+ platform: { os: Os; arch: Arch } | null = currentPlatform(),
241
+ ): SelectedBin[] {
242
+ if (!platform) return [];
243
+ const live = applicable(requirements, platform.os);
244
+ const names = new Set(live.map((r) => r.name));
245
+ const has = (tool: string) =>
246
+ names.has(tool) || onPath(tool, pathEnv) !== null;
247
+ return live.map((r) => ({
248
+ ...r,
249
+ chosen: selectOption(r, platform, has),
250
+ ...(root ? { root } : {}),
251
+ }));
252
+ }
253
+
254
+ /**
255
+ * The requirements a plugin declares, selected for this machine, from the copy
256
+ * Claude Code loads in `projectPath`. Empty when the plugin cannot be located,
257
+ * declares none, or its declaration does not parse (doctor and the plugin's
258
+ * own SessionStart hook report that one).
342
259
  */
343
260
  export async function resolvePluginBinRequirements(
344
261
  pluginId: string,
345
- ): Promise<BinRequirement[]> {
346
- const pluginPath = await resolvePluginPath(pluginId);
347
- if (!pluginPath) return [];
348
- const requires = await readPluginRequires(pluginPath);
349
- return requires.bin ?? [];
262
+ projectPath: string = process.cwd(),
263
+ ): Promise<SelectedBin[]> {
264
+ const found = await resolvePluginRequires(pluginId, projectPath);
265
+ if (!found?.parse.ok) return [];
266
+ return selectBins(found.parse.requirements, found.dir);
267
+ }
268
+
269
+ /**
270
+ * The requirement named `name` that `pluginId` declares — how a catalogue tool
271
+ * owned by a plugin is checked and installed — or null when that plugin's
272
+ * declaration cannot be read here or does not name it.
273
+ */
274
+ export async function ownedRequirement(
275
+ pluginId: string,
276
+ name: string,
277
+ projectPath: string = process.cwd(),
278
+ ): Promise<SelectedBin | null> {
279
+ const bins = await resolvePluginBinRequirements(pluginId, projectPath);
280
+ return bins.find((b) => b.name === name) ?? null;
350
281
  }
@@ -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
@@ -69,11 +69,7 @@ import {
69
69
  findEnclosingManifest,
70
70
  readManifest,
71
71
  } from "./manifest.js";
72
- import {
73
- type ActivationResult,
74
- type ProfileChoice,
75
- activationNotes,
76
- } from "./profile-activate.js";
72
+ import type { ActivationResult, ProfileChoice } from "./profile-activate.js";
77
73
  import { freeProfileId } from "./profile-adopt.js";
78
74
  import {
79
75
  activeProfile,
@@ -311,36 +307,29 @@ export async function oldFormatNeed(
311
307
  };
312
308
  }
313
309
 
314
- /** What the TUI shows at startup, beyond the question (if any). */
310
+ /** The TUI's startup: the question (if any), and what the gate refused. */
315
311
  export interface ProfileStartup {
316
312
  need: ProfileStartupNeed;
317
313
  /**
318
- * One line per thing the gate did: absorbed edits, regeneration, repairs.
319
- * Not what an activation reported — see `activations`.
320
- */
321
- notices: string[];
322
- /**
323
- * One line per thing the gate REFUSED — the lines the CLI stops on. Kept
324
- * apart from `notices` because a refusal can leave detection reading `ok`:
325
- * an old layout whose settings file does not parse is not converted, yet
326
- * still names its profile, so only this says the update did not happen.
314
+ * One line per thing the gate REFUSED — the lines the CLI stops on. A
315
+ * refusal can leave detection reading `ok`: an old layout whose settings
316
+ * file does not parse is not converted, yet still names its profile, so only
317
+ * this says the update did not happen.
327
318
  */
328
319
  problems: string[];
329
320
  /**
330
- * The activations the gate ran (a re-render, a format update). Handed over
331
- * as results rather than lines because the CLI's lines are the wrong words
332
- * for the TUI: they name plugin.json and backups, and they say "will not
333
- * load" in a line nothing marks as a warning. The TUI words them itself, in
334
- * user terms and with a tone (`activationWarnings` in the startup hook).
321
+ * Every activation the gate ran — a re-render after profiles.json changed,
322
+ * an older format converted — so the TUI can say what one of them could
323
+ * not do. Absent is none.
335
324
  */
336
- activations: ActivationResult[];
325
+ activations?: ActivationResult[];
337
326
  }
338
327
 
339
328
  /**
340
329
  * The TUI's startup: the same gate every CLI command runs, then detection.
341
330
  *
342
- * A failure in the gate never blocks the TUI — its message becomes a notice
343
- * and detection still runs, so the user is asked whatever is left to ask.
331
+ * A failure in the gate never blocks the TUI — its message is kept in
332
+ * `problems` and detection still runs, so the user is asked whatever is left.
344
333
  */
345
334
  export async function prepareProfileStartup(
346
335
  projectPath: string,
@@ -354,21 +343,13 @@ export async function prepareProfileStartup(
354
343
  if (!opts.convert) {
355
344
  const format = await oldFormatNeed(projectPath);
356
345
  if (format !== null) {
357
- return { need: format, notices: [], problems: [], activations: [] };
346
+ return { need: format, problems: [] };
358
347
  }
359
348
  }
360
- const notices: string[] = [];
361
349
  const problems: string[] = [];
362
350
  const activations: ActivationResult[] = [];
363
351
  try {
364
352
  const prep = await prepareProfileState(projectPath);
365
- // The gate's lines, less the ones its activations contributed: those
366
- // reach the TUI as `activations`, and passing both would say the same
367
- // thing twice — once in the CLI's words, as if it were a success.
368
- const fromActivations = new Set(
369
- prep.activations.flatMap((a) => activationNotes(a, { tracked: false })),
370
- );
371
- notices.push(...prep.lines.filter((line) => !fromActivations.has(line)));
372
353
  problems.push(...prep.problems);
373
354
  activations.push(...prep.activations);
374
355
  } catch (error) {
@@ -378,19 +359,18 @@ export async function prepareProfileStartup(
378
359
  }
379
360
  return {
380
361
  need: await detectProfileStartupNeed(projectPath),
381
- notices,
382
362
  problems,
383
- activations,
363
+ ...(activations.length > 0 ? { activations } : {}),
384
364
  };
385
365
  }
386
366
 
387
367
  /**
388
- * What the TUI shows once "Update it now" has run: another question, a notice
389
- * line, or an error modal.
368
+ * What the TUI does once "Update it now" has run: ask another question, carry
369
+ * on, or show an error modal.
390
370
  */
391
371
  export type FormatUpdateOutcome =
392
372
  | { kind: "ask"; need: ProfileStartupNeed }
393
- | { kind: "updated"; notice: string }
373
+ | { kind: "updated" }
394
374
  | { kind: "failed"; title: string; message: string };
395
375
 
396
376
  /**
@@ -410,12 +390,7 @@ export function formatUpdateOutcome(
410
390
  };
411
391
  }
412
392
  if (!needsNothing(result.need)) return { kind: "ask", need: result.need };
413
- // One line, not the gate's notices: those are the conversion's working log
414
- // (edits carried over, the old format's backup), and the result the user
415
- // cares about is only that the update is done. What the conversion's
416
- // activation warns about is NOT dropped: the caller words it from
417
- // `result.activations`, as it does for any activation.
418
- return { kind: "updated", notice: "Profile format updated" };
393
+ return { kind: "updated" };
419
394
  }
420
395
 
421
396
  // ─── What to offer, as data both surfaces render ─────────────────────────────
@@ -26,8 +26,9 @@
26
26
  */
27
27
 
28
28
  import path from "node:path";
29
- import type { ProfileManifest } from "../types/index.js";
30
- import { readManifest, writeManifest } from "./manifest.js";
29
+ import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
30
+ import { selectProfileId } from "./active-closure.js";
31
+ import { readManifest, updateProfileEntry, writeManifest } from "./manifest.js";
31
32
  import {
32
33
  type ReconcileResult,
33
34
  absorbOlderLayout,
@@ -47,6 +48,7 @@ import {
47
48
  type ActiveMarker,
48
49
  LIVE_FILE_KEYS,
49
50
  MACHINE_LOCAL_SETTINGS_KEYS,
51
+ activeProfile,
50
52
  folderHash,
51
53
  hasCurrentMarker,
52
54
  hasOlderLayout,
@@ -68,6 +70,7 @@ import {
68
70
  inspectManagedSkills,
69
71
  skillFolder,
70
72
  } from "./profile-skills.js";
73
+ import { resolveExtends } from "./resolver.js";
71
74
  import type { SettingsParseError } from "./settings-file.js";
72
75
 
73
76
  export type ActiveProfileCheck =
@@ -652,3 +655,70 @@ export async function withCliBookkeeping<T>(
652
655
  for (const line of reconciled.lines) opts.report?.(line);
653
656
  }
654
657
  }
658
+
659
+ /** Whether a profile entry, `extends` included, turns the plugin on. */
660
+ function profileLists(
661
+ entry: ProfileManifestEntry | undefined,
662
+ pluginId: string,
663
+ ): boolean {
664
+ if (!entry) return false;
665
+ const value = resolveExtends(entry).plugins?.[pluginId];
666
+ return value !== undefined && value !== false;
667
+ }
668
+
669
+ /**
670
+ * Stop a profile listing `pluginId`, so its next activation does not turn the
671
+ * plugin back on. Answers whether the profile no longer lists it — read back
672
+ * from profiles.json after the write, never assumed.
673
+ *
674
+ * For a deprecated plugin `magus update` removes. The uninstall that runs first
675
+ * deletes the plugin from this project's settings (measured, Claude Code
676
+ * 2.1.284), and `reconcileActiveProfile` would absorb that into the ACTIVE
677
+ * profile at the next entry point — but only there, and only for the active
678
+ * one. The Plugins screen's `a` runs no reconcile afterwards, so the screen kept
679
+ * the plugin declared and planned the same removal again; `magus update <name>`
680
+ * plans a profile that is not active, whose live files nothing absorbs from;
681
+ * and a profile declaring a plugin that is installed nowhere has no settings
682
+ * change to absorb at all. So the entry is edited here, directly, through
683
+ * {@link updateProfileEntry} — the editor `magus models use` writes through.
684
+ * The reconcile that follows (`withCliBookkeeping`, or the next entry point)
685
+ * finds profiles.json and the live files agreeing, and regenerates from it.
686
+ *
687
+ * `profile` undefined means the active one, chosen as the Plugins screen and
688
+ * `magus update` choose it; null means no profile, which lists nothing.
689
+ *
690
+ * Deletes the entry's own pin. When `extends` supplies the plugin, the pin
691
+ * becomes `false`, which is how an entry turns an inherited plugin off.
692
+ * Throws `SettingsParseError` when profiles.json exists and does not parse.
693
+ */
694
+ export async function removePluginFromProfile(
695
+ projectPath: string,
696
+ pluginId: string,
697
+ profile?: string | null,
698
+ ): Promise<boolean> {
699
+ const manifest = await readManifest(projectPath);
700
+ const id =
701
+ profile === undefined
702
+ ? selectProfileId(
703
+ Object.keys(manifest.profiles),
704
+ undefined,
705
+ await activeProfile(projectPath),
706
+ )
707
+ : profile;
708
+ if (!id || !profileLists(manifest.profiles[id], pluginId)) return true;
709
+
710
+ await updateProfileEntry(projectPath, id, (entry) => {
711
+ const plugins = { ...(entry.plugins ?? {}) };
712
+ delete plugins[pluginId];
713
+ if (profileLists({ ...entry, plugins }, pluginId))
714
+ plugins[pluginId] = false;
715
+ if (Object.keys(plugins).length > 0) entry.plugins = plugins;
716
+ // biome-ignore lint/performance/noDelete: absent, not undefined, in profiles.json
717
+ else delete entry.plugins;
718
+ });
719
+
720
+ return !profileLists(
721
+ (await readManifest(projectPath)).profiles[id],
722
+ pluginId,
723
+ );
724
+ }