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.
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +29 -15
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1203 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +920 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +724 -0
- package/src/__tests__/format-update-outcome.test.ts +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +23 -17
- package/src/__tests__/useStaleLoadModal.test.ts +1 -57
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/doctor.ts +151 -30
- package/src/cli/install.ts +5 -6
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +27 -5
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +84 -36
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +31 -19
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -2
- package/src/services/resolver.ts +95 -1
- package/src/services/update-engine.ts +326 -31
- package/src/services/update-plan.ts +293 -33
- package/src/types/index.ts +7 -0
- package/src/ui/App.tsx +90 -146
- package/src/ui/adapters/cliToolsAdapter.ts +203 -1
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- package/src/ui/adapters/startupReports.ts +282 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -12
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -45
- package/src/ui/screens/CliToolsScreen.tsx +65 -54
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +313 -221
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- 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(
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
*
|
|
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
|
|
1996
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
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
|
|
2455
|
+
} catch {
|
|
2402
2456
|
return fail(
|
|
2403
|
-
|
|
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
|
-
?
|
|
2432
|
-
:
|
|
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
|
|
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
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
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
|
-
|
|
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
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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.
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
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
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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.
|
|
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
|