magus-cli 7.2.0 → 7.3.1
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/scripts/build-binaries.ts +4 -2
- package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
- package/src/__tests__/doctor.test.ts +31 -0
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/plugin-resolution.test.ts +431 -0
- package/src/__tests__/plugin-row-text.test.ts +66 -0
- package/src/__tests__/stale-load-repair.test.ts +1076 -0
- package/src/__tests__/update-apply.test.ts +146 -1
- package/src/__tests__/update-plan.test.ts +338 -0
- package/src/__tests__/upgrade-spawn.test.ts +355 -0
- package/src/__tests__/useStaleLoadModal.test.ts +177 -0
- package/src/cli/doctor.ts +128 -2
- package/src/cli/update-view.ts +31 -1
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +4 -1
- package/src/data/npm-package.ts +15 -0
- package/src/services/active-closure.ts +23 -0
- package/src/services/claude-settings.ts +645 -34
- package/src/services/plugin-manager.ts +61 -0
- package/src/services/plugin-resolution.ts +280 -0
- package/src/services/update-engine.ts +104 -2
- package/src/services/update-plan.ts +188 -1
- package/src/services/version-check.ts +20 -10
- package/src/ui/App.tsx +63 -0
- package/src/ui/hooks/useStaleLoadModal.ts +157 -0
- package/src/ui/renderers/pluginRenderers.tsx +29 -0
- package/src/ui/renderers/pluginStatusText.ts +14 -0
- package/src/ui/screens/PluginsScreen.tsx +46 -13
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type PluginInfo,
|
|
35
35
|
compareVersions,
|
|
36
36
|
isBehind,
|
|
37
|
+
isRepairablyBehind,
|
|
37
38
|
} from "./plugin-manager.js";
|
|
38
39
|
import {
|
|
39
40
|
type ToolFacts,
|
|
@@ -54,6 +55,23 @@ export type PluginUpdateAction =
|
|
|
54
55
|
| "repair"
|
|
55
56
|
/** Already on the right version. */
|
|
56
57
|
| "current"
|
|
58
|
+
/**
|
|
59
|
+
* BROKEN: the right version is installed, but Claude Code's loader will
|
|
60
|
+
* resolve an OLDER one — so the session runs stale code while every version
|
|
61
|
+
* magus records looks correct.
|
|
62
|
+
*
|
|
63
|
+
* A REINSTALL DOES NOT FIX IT (measured). `claude plugin install` writes a
|
|
64
|
+
* row for this project, and the row that wins belongs to another one — often
|
|
65
|
+
* a worktree that no longer exists, whose registry entry nothing prunes.
|
|
66
|
+
*
|
|
67
|
+
* It IS applied, by `repairStaleLoad` (services/claude-settings.ts), which
|
|
68
|
+
* moves those rows forward onto the installed version rather than reinstalling
|
|
69
|
+
* anything. `target` is therefore the version installed here — the one whose
|
|
70
|
+
* cache directory exists — and not what the catalog offers. `scopes` stays
|
|
71
|
+
* empty because this is not a per-scope install: one registry rewrite covers
|
|
72
|
+
* every row, whichever scope or project each belongs to.
|
|
73
|
+
*/
|
|
74
|
+
| "stale-load"
|
|
57
75
|
/** The catalog check failed; we cannot claim either way. */
|
|
58
76
|
| "unknown";
|
|
59
77
|
|
|
@@ -78,6 +96,8 @@ export type PluginReason =
|
|
|
78
96
|
| "content-stale"
|
|
79
97
|
/** update: a scope is provably behind. */
|
|
80
98
|
| "behind"
|
|
99
|
+
/** stale-load: the loader resolves an older version than the one installed. */
|
|
100
|
+
| "older-version-loads"
|
|
81
101
|
/** current|update: an exact pin differs from what the marketplace offers. */
|
|
82
102
|
| "pin-undeliverable"
|
|
83
103
|
/** current: every installed scope is at (or ahead of) the target. */
|
|
@@ -89,6 +109,16 @@ export interface PluginUpdateItem {
|
|
|
89
109
|
pinned: string;
|
|
90
110
|
/** Highest version installed in any scope, or null when absent. */
|
|
91
111
|
installed: string | null;
|
|
112
|
+
/**
|
|
113
|
+
* The version Claude Code's loader will ACTUALLY load in this project, or
|
|
114
|
+
* null when it will load nothing (and when the registry was unreadable).
|
|
115
|
+
*
|
|
116
|
+
* A separate column from `installed` because they answer different questions
|
|
117
|
+
* and routinely disagree — see services/plugin-resolution.ts. `installed` is
|
|
118
|
+
* what the settings file and this project's registry rows record; `loaded` is
|
|
119
|
+
* what runs.
|
|
120
|
+
*/
|
|
121
|
+
loaded: string | null;
|
|
92
122
|
/** Version the marketplace catalog offers, or null when unknown. */
|
|
93
123
|
available: string | null;
|
|
94
124
|
/** Version this item would move to. Null for `current`/`unknown`. */
|
|
@@ -142,6 +172,40 @@ function highestInstalled(info: PluginInfo | undefined): string | null {
|
|
|
142
172
|
);
|
|
143
173
|
}
|
|
144
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The highest installed version THE PIN PERMITS — the ceiling a stale-load
|
|
177
|
+
* repair may aim at. Null when no installed version qualifies.
|
|
178
|
+
*
|
|
179
|
+
* Under "latest" that is simply {@link highestInstalled}: nothing caps it, and
|
|
180
|
+
* the newest version on this machine is the one to converge on.
|
|
181
|
+
*
|
|
182
|
+
* Under an EXACT pin it is the highest installed version at or below the pin.
|
|
183
|
+
* `installed` reaches across every scope and `scopesNeeding` never downgrades, so
|
|
184
|
+
* a scope sitting above an exact pin is a normal, permanent state — not something
|
|
185
|
+
* to plan toward. Comparing the stale-load branch against the uncapped
|
|
186
|
+
* `installed` made a plugin loading exactly its pinned version read as BROKEN,
|
|
187
|
+
* and the repair then moved the pinned rows off the pin.
|
|
188
|
+
*
|
|
189
|
+
* A version that cannot be ORDERED against the pin (a commit sha, the literal
|
|
190
|
+
* "unknown") is excluded rather than assumed to qualify: "cannot tell" must never
|
|
191
|
+
* license a registry rewrite, which is the same rule {@link isRepairablyBehind}
|
|
192
|
+
* applies one step later.
|
|
193
|
+
*/
|
|
194
|
+
function highestPermittedInstalled(
|
|
195
|
+
info: PluginInfo | undefined,
|
|
196
|
+
pin: string,
|
|
197
|
+
): string | null {
|
|
198
|
+
if (pin === "latest") return highestInstalled(info);
|
|
199
|
+
const permitted = scopeVersions(info)
|
|
200
|
+
.map(([, v]) => v)
|
|
201
|
+
// pin >= v, i.e. v is at or below the pin. Null (unorderable) fails this.
|
|
202
|
+
.filter((v) => (compareVersions(pin, v) ?? -1) >= 0);
|
|
203
|
+
if (permitted.length === 0) return null;
|
|
204
|
+
return permitted.reduce((a, b) =>
|
|
205
|
+
(compareVersions(a, b) ?? -1) >= 0 ? a : b,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
145
209
|
/**
|
|
146
210
|
* Scopes that are strictly BEHIND `target`.
|
|
147
211
|
*
|
|
@@ -277,6 +341,7 @@ export function classifyPlugin(
|
|
|
277
341
|
pluginId,
|
|
278
342
|
pinned: pin,
|
|
279
343
|
installed,
|
|
344
|
+
loaded: info?.loadedVersion ?? null,
|
|
280
345
|
available,
|
|
281
346
|
beforeVersions: Object.fromEntries(scopeVersions(info)) as Partial<
|
|
282
347
|
Record<PluginScope, string>
|
|
@@ -375,6 +440,112 @@ export function classifyPlugin(
|
|
|
375
440
|
};
|
|
376
441
|
}
|
|
377
442
|
|
|
443
|
+
// Installed and listed — but the loader will resolve an OLDER version than
|
|
444
|
+
// what is installed here, so this session runs stale code.
|
|
445
|
+
//
|
|
446
|
+
// Decided BEFORE every branch below, and that ordering is the whole fix.
|
|
447
|
+
// Those branches compare the REGISTERED version against the catalog, and the
|
|
448
|
+
// registered version is precisely the one that is not loading: a plugin at
|
|
449
|
+
// the newest version in every scope classified `current` while Claude Code
|
|
450
|
+
// loaded a two-versions-old copy. The report was green about code that was
|
|
451
|
+
// not running — which is worse than no report, because magus is the tool
|
|
452
|
+
// relied on to stay current.
|
|
453
|
+
//
|
|
454
|
+
// `loaded` is the loader's own answer (services/plugin-resolution.ts), not
|
|
455
|
+
// magus's registry resolution. Null means we could not establish it, and
|
|
456
|
+
// "cannot tell" never becomes a finding here — the plugin falls through to
|
|
457
|
+
// the ordinary comparisons below.
|
|
458
|
+
//
|
|
459
|
+
// `target` is what is INSTALLED here, never `available`. The repair points
|
|
460
|
+
// registry rows at a cache directory, and only the installed version's cache
|
|
461
|
+
// is on disk — aiming at the catalog version would name a directory that does
|
|
462
|
+
// not exist, which `repairStaleLoad` refuses. When the install is also behind
|
|
463
|
+
// the catalog, the next run plans the ordinary `update` on top, so the two
|
|
464
|
+
// steps converge instead of competing.
|
|
465
|
+
//
|
|
466
|
+
// `scopes` stays empty: one registry rewrite covers every row, so there is no
|
|
467
|
+
// per-scope work to enumerate. `pluginNeedsWork` says so directly.
|
|
468
|
+
//
|
|
469
|
+
// `isRepairablyBehind`, NOT `isBehind` — the one predicate `findStaleLoads`
|
|
470
|
+
// asks too, so this header's count and the startup popup's count are the same
|
|
471
|
+
// question. `isBehind` calls two differing commit shas "behind", which is the
|
|
472
|
+
// right answer to ITS question and the wrong one to this: moving rows forward
|
|
473
|
+
// between two unordered shas is a guess, not a repair. That difference alone
|
|
474
|
+
// made the popup claim 8 broken plugins where this header said 6.
|
|
475
|
+
//
|
|
476
|
+
// INSTALLED against LOADED — never `available` against loaded. This state is
|
|
477
|
+
// "what is installed HERE is not what runs here", and the catalog is not a
|
|
478
|
+
// party to it. Asking the catalog question here was wrong in both directions,
|
|
479
|
+
// measured against this module:
|
|
480
|
+
//
|
|
481
|
+
// - installed 7.1.0, loaded 7.1.0, catalog 7.2.0 — an ordinary pending
|
|
482
|
+
// release, nothing stale at all — classified `stale-load` with an empty
|
|
483
|
+
// `scopes`, so no installer ran, `repairStaleLoad` found no row behind
|
|
484
|
+
// 7.1.0, wrote nothing, and its post-write assertion passed because the
|
|
485
|
+
// loader already resolved 7.1.0. The engine counted that as a success and
|
|
486
|
+
// 7.2.0 was never installed. Every run classified identically while
|
|
487
|
+
// `planIsBehind` stayed true, so `magus update --check` failed forever over
|
|
488
|
+
// something `magus update` could not clear — the un-greenable gate the note
|
|
489
|
+
// on `planIsBehind` argues must not exist. It fired on any machine with a
|
|
490
|
+
// pending release, which is the normal state for this marketplace.
|
|
491
|
+
// - a marketplace publishing NO version (278 of the 292 official plugins)
|
|
492
|
+
// leaves `available` null, so `isRepairablyBehind` answered false and a
|
|
493
|
+
// genuine stale load classified `current` — green about a session running
|
|
494
|
+
// old code, the exact defect this state exists to end.
|
|
495
|
+
//
|
|
496
|
+
// The two formulas agree whenever `available` equals `installed`, which is
|
|
497
|
+
// every machine with nothing pending — which is why it held up by hand and why
|
|
498
|
+
// the parity fixture in stale-load-repair.test.ts had to be changed to be able
|
|
499
|
+
// to fail at all.
|
|
500
|
+
//
|
|
501
|
+
// BOTH AT ONCE — stale-loaded AND behind the catalog — is deliberately NOT
|
|
502
|
+
// folded together, and neither fact is dropped. This branch wins (what runs is
|
|
503
|
+
// the more consequential fact, and a reinstall is measured not to clear it),
|
|
504
|
+
// `target` stays an installed version because only an installed version's cache
|
|
505
|
+
// directory exists, and the pending catalog version is named in the note rather
|
|
506
|
+
// than left for the user to infer from a column. `magus update` then converges
|
|
507
|
+
// over consecutive runs: repair the load, then install the update once the load
|
|
508
|
+
// is no longer stale. It is not collapsed into one run because the two are
|
|
509
|
+
// different mechanisms — a registry rewrite and an installer — and the apply
|
|
510
|
+
// engine runs one action per item; interleaving them silently would make a
|
|
511
|
+
// failed half look like a whole success.
|
|
512
|
+
//
|
|
513
|
+
// CAPPED AT THE PIN, which is why the comparison is against `staleTarget` and
|
|
514
|
+
// not `installed`. `installed` is the highest version in ANY scope, and
|
|
515
|
+
// `scopesNeeding` deliberately never downgrades, so a scope legitimately sits
|
|
516
|
+
// ABOVE an exact pin. Measured: pin 0.3.0, project scope at 0.3.0 and loading,
|
|
517
|
+
// user scope at 0.5.0 — `installed` is 0.5.0, so an uncapped comparison called
|
|
518
|
+
// a plugin loading exactly what its pin asked for a broken load, and the repair
|
|
519
|
+
// then succeeded (this repo's row is writable, and 0.5.0's cache exists because
|
|
520
|
+
// user scope installed it), moving a pinned plugin off its pin and reporting
|
|
521
|
+
// success. An exact pin is a request, not a starting point: this branch may
|
|
522
|
+
// never plan past it, for the same reason the pin branch below refuses to plan
|
|
523
|
+
// toward something the marketplace cannot deliver.
|
|
524
|
+
//
|
|
525
|
+
// A pin naming a version installed NOWHERE at or below it leaves `staleTarget`
|
|
526
|
+
// null and the branch silent. That is the honest answer rather than a quiet
|
|
527
|
+
// promotion: the only versions on disk are above the pin, so there is no repair
|
|
528
|
+
// this may perform, and the pin branch below reports the pin state instead.
|
|
529
|
+
const staleTarget = highestPermittedInstalled(info, pin);
|
|
530
|
+
if (isRepairablyBehind(staleTarget, base.loaded)) {
|
|
531
|
+
// Only ever under "latest". Under an exact pin the catalog is not what this
|
|
532
|
+
// plugin is judged against, so naming a newer published version as something
|
|
533
|
+
// "available once the load is repaired" would advertise a move the pin
|
|
534
|
+
// forbids.
|
|
535
|
+
const pending =
|
|
536
|
+
pin === "latest" && isRepairablyBehind(available, installed);
|
|
537
|
+
return {
|
|
538
|
+
...base,
|
|
539
|
+
target: staleTarget,
|
|
540
|
+
action: "stale-load",
|
|
541
|
+
reason: "older-version-loads",
|
|
542
|
+
scopes: [],
|
|
543
|
+
note: pending
|
|
544
|
+
? `loads v${base.loaded}, installed v${staleTarget}; v${available} is available once the load is repaired`
|
|
545
|
+
: `loads v${base.loaded}, installed v${staleTarget}`,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
378
549
|
// An exact pin is planned against what can ACTUALLY be delivered.
|
|
379
550
|
//
|
|
380
551
|
// This is dependency management, not a package manager: there is no
|
|
@@ -563,6 +734,12 @@ export interface PluginStateCounts {
|
|
|
563
734
|
update: number;
|
|
564
735
|
repair: number;
|
|
565
736
|
current: number;
|
|
737
|
+
/**
|
|
738
|
+
* Its own count, never folded into `current`. The installed version being
|
|
739
|
+
* right is exactly what makes this state dangerous: counted as current, the
|
|
740
|
+
* header would report a clean bill of health for a session running old code.
|
|
741
|
+
*/
|
|
742
|
+
"stale-load": number;
|
|
566
743
|
unknown: number;
|
|
567
744
|
}
|
|
568
745
|
|
|
@@ -575,6 +752,7 @@ export function countPluginStates(
|
|
|
575
752
|
update: 0,
|
|
576
753
|
repair: 0,
|
|
577
754
|
current: 0,
|
|
755
|
+
"stale-load": 0,
|
|
578
756
|
unknown: 0,
|
|
579
757
|
};
|
|
580
758
|
for (const item of items) counts[item.action]++;
|
|
@@ -762,6 +940,14 @@ export function planHasWork(plan: UpdatePlan): boolean {
|
|
|
762
940
|
* binary had no version probe and "upgrade" meant "re-run the installer"; it
|
|
763
941
|
* now means the owning installer says the tool is behind, which is drift.
|
|
764
942
|
*
|
|
943
|
+
* A `stale-load` plugin DOES count. It was excluded while it was report-only, on
|
|
944
|
+
* the un-greenable-gate argument above: nothing magus ran could clear it, so a
|
|
945
|
+
* gate that failed on it would have failed forever. `repairStaleLoad` clears it
|
|
946
|
+
* now, and `magus update` applies that, so the gate closes — and leaving it out
|
|
947
|
+
* would mean `--check` printing "nothing behind" over a plugin whose session
|
|
948
|
+
* loads a version nobody installed, which is the deception the state exists to
|
|
949
|
+
* end.
|
|
950
|
+
*
|
|
765
951
|
* NEITHER an `unknown` plugin NOR an `unknown` tool counts. "We could not check
|
|
766
952
|
* this" is not evidence of drift, and most unknowns are structural and
|
|
767
953
|
* permanent rather than transient: a plugin whose marketplace publishes no
|
|
@@ -784,7 +970,8 @@ export function planIsBehind(plan: UpdatePlan): boolean {
|
|
|
784
970
|
(p) =>
|
|
785
971
|
p.action === "install" ||
|
|
786
972
|
p.action === "update" ||
|
|
787
|
-
p.action === "repair"
|
|
973
|
+
p.action === "repair" ||
|
|
974
|
+
p.action === "stale-load",
|
|
788
975
|
) ||
|
|
789
976
|
plan.bins.some((b) => b.action === "install" || b.action === "upgrade") ||
|
|
790
977
|
plan.skills.some((s) => s.action === "install")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import semver from "semver";
|
|
2
2
|
import pkg from "../../package.json";
|
|
3
|
+
import { MAGUS_NPM_PACKAGE } from "../data/npm-package.js";
|
|
3
4
|
|
|
4
5
|
// Cache the result for the session
|
|
5
6
|
let cachedResult: VersionCheckResult | null = null;
|
|
@@ -29,15 +30,20 @@ async function fetchLatestVersion(): Promise<string | null> {
|
|
|
29
30
|
const controller = new AbortController();
|
|
30
31
|
const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
// The npm package, not the command: bare `magus` on npm is an unrelated
|
|
34
|
+
// package whose latest is 0.0.2, which hid every release from this check.
|
|
35
|
+
const response = await fetch(
|
|
36
|
+
`https://registry.npmjs.org/${MAGUS_NPM_PACKAGE}/latest`,
|
|
37
|
+
{
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
headers: {
|
|
40
|
+
Accept: "application/json",
|
|
41
|
+
// Always revalidate — a CDN/HTTP-cached response serves a stale
|
|
42
|
+
// "latest" right after a release (the 4.25-vs-4.26 flicker).
|
|
43
|
+
"Cache-Control": "no-cache",
|
|
44
|
+
},
|
|
39
45
|
},
|
|
40
|
-
|
|
46
|
+
);
|
|
41
47
|
|
|
42
48
|
clearTimeout(timeout);
|
|
43
49
|
|
|
@@ -117,11 +123,15 @@ export async function checkForUpdates(
|
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
/**
|
|
120
|
-
* Format update message for display
|
|
126
|
+
* Format update message for display.
|
|
127
|
+
*
|
|
128
|
+
* Points at `magus upgrade`, which picks bun or npm from the active binary. A
|
|
129
|
+
* hard-coded `npm i -g …` would give every bun user a second, npm-owned copy —
|
|
130
|
+
* the duplicate state `magus upgrade` itself warns about.
|
|
121
131
|
*/
|
|
122
132
|
export function formatUpdateMessage(result: VersionCheckResult): string {
|
|
123
133
|
if (!result.updateAvailable) return "";
|
|
124
134
|
|
|
125
135
|
const typeLabel = result.updateType ? ` (${result.updateType})` : "";
|
|
126
|
-
return `Update available: v${result.currentVersion} → v${result.latestVersion}${typeLabel}. Run:
|
|
136
|
+
return `Update available: v${result.currentVersion} → v${result.latestVersion}${typeLabel}. Run: magus upgrade`;
|
|
127
137
|
}
|
package/src/ui/App.tsx
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { useRenderer } from "@opentui/react";
|
|
3
3
|
import React, { useEffect, useState } from "react";
|
|
4
|
+
import { activePluginPins } from "../services/active-closure.js";
|
|
4
5
|
import { isClaudeAvailable } from "../services/claude-cli.js";
|
|
5
6
|
import {
|
|
7
|
+
findStaleLoads,
|
|
6
8
|
migrateMarketplaceRename,
|
|
7
9
|
recoverMarketplaceSettings,
|
|
10
|
+
repairAllStaleLoads,
|
|
8
11
|
} from "../services/claude-settings.js";
|
|
9
12
|
import { checkGitignore } from "../services/gitignore-check.js";
|
|
10
13
|
import {
|
|
@@ -28,6 +31,10 @@ import { ModalContainer } from "./components/modals/index.js";
|
|
|
28
31
|
import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
|
|
29
32
|
import { useKeyboardHandler } from "./hooks/useKeyboardHandler.js";
|
|
30
33
|
import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
|
|
34
|
+
import {
|
|
35
|
+
staleLoadRepairSummary,
|
|
36
|
+
useStaleLoadModal,
|
|
37
|
+
} from "./hooks/useStaleLoadModal.js";
|
|
31
38
|
import {
|
|
32
39
|
AliasScreen,
|
|
33
40
|
CliToolsScreen,
|
|
@@ -391,6 +398,10 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
391
398
|
null,
|
|
392
399
|
);
|
|
393
400
|
const gitignoreModal = useGitignoreModal();
|
|
401
|
+
const [brokenPluginCount, setBrokenPluginCount] = useState<number | null>(
|
|
402
|
+
null,
|
|
403
|
+
);
|
|
404
|
+
const staleLoadModal = useStaleLoadModal();
|
|
394
405
|
// The profile modal cannot be dismissed, so it needs the app's own exit: its
|
|
395
406
|
// only non-action is to leave.
|
|
396
407
|
const profileModal = useProfileStartupModal(onExit);
|
|
@@ -436,6 +447,58 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
436
447
|
setGitignoreData(null);
|
|
437
448
|
}, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
|
|
438
449
|
|
|
450
|
+
// Show the broken-plugin modal — same "wait for the modal slot" rule as the
|
|
451
|
+
// gitignore one above, so two startup findings queue instead of one stomping
|
|
452
|
+
// the other.
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
if (brokenPluginCount === null) return;
|
|
455
|
+
if (state.modal) return;
|
|
456
|
+
staleLoadModal.show({
|
|
457
|
+
brokenCount: brokenPluginCount,
|
|
458
|
+
// Re-surveys before repairing, like the gitignore fixer: the registry may
|
|
459
|
+
// have moved between the startup scan and the user pressing Enter, and
|
|
460
|
+
// repairing from the stale count would act on rows that are no longer
|
|
461
|
+
// there. `repairAllStaleLoads` is the same `repairStaleLoad` the Plugins
|
|
462
|
+
// screen's `a` key drives through the apply engine.
|
|
463
|
+
//
|
|
464
|
+
// The pins are re-read with it, from the same helper the survey used: a
|
|
465
|
+
// repair that capped at a different ceiling than the offer was counted
|
|
466
|
+
// against would move rows the dialog never mentioned.
|
|
467
|
+
onFix: async () =>
|
|
468
|
+
staleLoadRepairSummary(
|
|
469
|
+
await repairAllStaleLoads(
|
|
470
|
+
state.projectPath,
|
|
471
|
+
undefined,
|
|
472
|
+
await activePluginPins(state.projectPath),
|
|
473
|
+
),
|
|
474
|
+
),
|
|
475
|
+
});
|
|
476
|
+
setBrokenPluginCount(null);
|
|
477
|
+
}, [brokenPluginCount, staleLoadModal, state.modal, state.projectPath]);
|
|
478
|
+
|
|
479
|
+
// Does this project load an older plugin than it installed? Detection only;
|
|
480
|
+
// nothing is written until the user chooses Fix.
|
|
481
|
+
//
|
|
482
|
+
// Its own effect for the same reason the profile check below has one: the
|
|
483
|
+
// marketplace block returns early on `--no-refresh`, which is about network
|
|
484
|
+
// calls, and this question is answered from the local registry alone. Sharing
|
|
485
|
+
// that effect let the flag silently disable the check — observed, while this
|
|
486
|
+
// was still inside it.
|
|
487
|
+
useEffect(() => {
|
|
488
|
+
(async () => {
|
|
489
|
+
try {
|
|
490
|
+
const broken = await findStaleLoads(
|
|
491
|
+
process.cwd(),
|
|
492
|
+
undefined,
|
|
493
|
+
await activePluginPins(process.cwd()),
|
|
494
|
+
);
|
|
495
|
+
if (broken.length > 0) setBrokenPluginCount(broken.length);
|
|
496
|
+
} catch {
|
|
497
|
+
// non-fatal: the Plugins tab still shows the same rows
|
|
498
|
+
}
|
|
499
|
+
})();
|
|
500
|
+
}, []);
|
|
501
|
+
|
|
439
502
|
// Show the profile modal on the same terms as the gitignore one: only once
|
|
440
503
|
// the modal slot is free, so two startup checks that both found something do
|
|
441
504
|
// not stomp on each other. Gitignore is asked first simply because its
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useApp, useNavigation } from "../state/AppContext.js";
|
|
3
|
+
import type { ModalState } from "../state/types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Startup broken-plugin modal: magus launches, finds that Claude Code will load
|
|
7
|
+
* an older version of a plugin than the one installed here, and offers to fix it.
|
|
8
|
+
*
|
|
9
|
+
* Same shape as the gitignore modal beside it — a startup repair magus can make
|
|
10
|
+
* without being asked to, surfaced as a choice rather than done silently. The
|
|
11
|
+
* difference is the DEFAULT: fixing is the first option and `defaultIndex` says
|
|
12
|
+
* so explicitly, because a broken plugin means every session in this directory
|
|
13
|
+
* runs code nobody installed, and there is no reason to prefer leaving it.
|
|
14
|
+
*
|
|
15
|
+
* The copy names the CAUSE, not magus's internal state. An earlier version read
|
|
16
|
+
* "Claude Code loads an older version here than the one installed", which is a
|
|
17
|
+
* report on two stores disagreeing and leaves the reader with nothing to do. It
|
|
18
|
+
* is a defect in Claude Code's plugin loader, the user did nothing to cause it,
|
|
19
|
+
* and the sentence they need is what is wrong and how far it reaches.
|
|
20
|
+
*
|
|
21
|
+
* Keep it plain and keep it accurate. A draft said "your sessions run code you
|
|
22
|
+
* did not install", which is both overstated and wrong: nothing happens to the
|
|
23
|
+
* user's code, an old cached plugin version is used instead of the current one.
|
|
24
|
+
* Say that, in those words.
|
|
25
|
+
*
|
|
26
|
+
* The reach is not this directory. Relevance is decided by repository root, so
|
|
27
|
+
* every worktree of this repo — and the main checkout — resolve identically and
|
|
28
|
+
* are affected together. Saying "in this directory" understated it.
|
|
29
|
+
*
|
|
30
|
+
* NO UPSTREAM LINK YET, deliberately. anthropics/claude-code#45997 is the
|
|
31
|
+
* nearest issue and is CLOSED, and its title states the `[0]`-index diagnosis,
|
|
32
|
+
* which measurement disproved: the loader filters by repository root first and
|
|
33
|
+
* takes the first RELEVANT row. Linking a closed issue with a wrong cause sends
|
|
34
|
+
* the reader somewhere that contradicts this dialog. Add a link here when an
|
|
35
|
+
* accurate issue exists.
|
|
36
|
+
*
|
|
37
|
+
* Pure builder is exported separately for tests.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export interface BuildStaleLoadModalArgs {
|
|
41
|
+
brokenCount: number;
|
|
42
|
+
onFix: () => void;
|
|
43
|
+
onOpenTab: () => void;
|
|
44
|
+
onDismiss: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildStaleLoadModal(args: BuildStaleLoadModalArgs): ModalState {
|
|
48
|
+
const { brokenCount, onFix, onOpenTab, onDismiss } = args;
|
|
49
|
+
const plural = brokenCount === 1 ? "plugin" : "plugins";
|
|
50
|
+
return {
|
|
51
|
+
type: "select",
|
|
52
|
+
title: "Claude Code is loading old plugin versions",
|
|
53
|
+
message: `It picks up a stale cached version — the one recorded by another directory in this repo, including worktrees you deleted. ${brokenCount} ${plural} ${brokenCount === 1 ? "is" : "are"} on an older version than you installed, in every worktree and the main checkout.\n\nFix updates this repo's install records to the versions you installed. installed_plugins.json is backed up first. Nothing is deleted or downgraded.`,
|
|
54
|
+
options: [
|
|
55
|
+
{ label: `Fix it (${brokenCount} ${plural})`, value: "fix" },
|
|
56
|
+
{ label: "Open the Plugins tab to review", value: "open-tab" },
|
|
57
|
+
{ label: "Not now", value: "dismiss" },
|
|
58
|
+
],
|
|
59
|
+
// FIX, stated rather than inherited from being first — a reordering must not
|
|
60
|
+
// silently make "leave it broken" the thing Enter does.
|
|
61
|
+
defaultIndex: 0,
|
|
62
|
+
onSelect: (value: string) => {
|
|
63
|
+
if (value === "fix") onFix();
|
|
64
|
+
else if (value === "open-tab") onOpenTab();
|
|
65
|
+
else onDismiss();
|
|
66
|
+
},
|
|
67
|
+
onCancel: onDismiss,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function useStaleLoadModal() {
|
|
72
|
+
const { dispatch } = useApp();
|
|
73
|
+
const { navigateToScreen } = useNavigation();
|
|
74
|
+
|
|
75
|
+
const show = useCallback(
|
|
76
|
+
(args: {
|
|
77
|
+
brokenCount: number;
|
|
78
|
+
/** Runs the repair. Resolves to what to tell the user afterwards. */
|
|
79
|
+
onFix: () => Promise<string> | string;
|
|
80
|
+
}) => {
|
|
81
|
+
if (args.brokenCount === 0) return;
|
|
82
|
+
const dismiss = () => dispatch({ type: "HIDE_MODAL" });
|
|
83
|
+
const modal = buildStaleLoadModal({
|
|
84
|
+
brokenCount: args.brokenCount,
|
|
85
|
+
onFix: () => {
|
|
86
|
+
dispatch({
|
|
87
|
+
type: "SHOW_MODAL",
|
|
88
|
+
modal: { type: "loading", message: "Repairing plugins…" },
|
|
89
|
+
});
|
|
90
|
+
// The outcome is REPORTED, never assumed. `repairStaleLoad` re-asks
|
|
91
|
+
// the loader after writing and can legitimately answer "still not
|
|
92
|
+
// loading the target"; swallowing that would leave the user believing
|
|
93
|
+
// a broken plugin was fixed.
|
|
94
|
+
void Promise.resolve(args.onFix())
|
|
95
|
+
.then((summary) => {
|
|
96
|
+
dispatch({
|
|
97
|
+
type: "SHOW_MODAL",
|
|
98
|
+
modal: {
|
|
99
|
+
type: "message",
|
|
100
|
+
title: "Plugin repair",
|
|
101
|
+
message: summary,
|
|
102
|
+
variant: "info",
|
|
103
|
+
onDismiss: dismiss,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
})
|
|
107
|
+
.catch((err) => {
|
|
108
|
+
dispatch({
|
|
109
|
+
type: "SHOW_MODAL",
|
|
110
|
+
modal: {
|
|
111
|
+
type: "message",
|
|
112
|
+
title: "Plugin repair failed",
|
|
113
|
+
message: err instanceof Error ? err.message : String(err),
|
|
114
|
+
variant: "error",
|
|
115
|
+
onDismiss: dismiss,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
onOpenTab: () => {
|
|
121
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
122
|
+
navigateToScreen("plugins");
|
|
123
|
+
},
|
|
124
|
+
onDismiss: dismiss,
|
|
125
|
+
});
|
|
126
|
+
dispatch({ type: "SHOW_MODAL", modal });
|
|
127
|
+
},
|
|
128
|
+
[dispatch, navigateToScreen],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
return { show };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One line per repair attempt, for the modal that follows the fix.
|
|
136
|
+
*
|
|
137
|
+
* Exported and pure so the wording is tested rather than inspected by eye. Each
|
|
138
|
+
* line says what the loader resolves NOW, because that — not the row count — is
|
|
139
|
+
* the thing the user came here to change.
|
|
140
|
+
*/
|
|
141
|
+
export function staleLoadRepairSummary(
|
|
142
|
+
results: ReadonlyArray<{
|
|
143
|
+
pluginId: string;
|
|
144
|
+
target: string;
|
|
145
|
+
ok: boolean;
|
|
146
|
+
error?: string;
|
|
147
|
+
}>,
|
|
148
|
+
): string {
|
|
149
|
+
if (results.length === 0) return "Nothing to repair.";
|
|
150
|
+
return results
|
|
151
|
+
.map((r) =>
|
|
152
|
+
r.ok
|
|
153
|
+
? `✓ ${r.pluginId} now loads v${r.target}`
|
|
154
|
+
: `✗ ${r.pluginId} — ${r.error ?? "repair did not take effect"}`,
|
|
155
|
+
)
|
|
156
|
+
.join("\n");
|
|
157
|
+
}
|
|
@@ -278,6 +278,9 @@ const ACTION_LABEL: Record<PluginUpdateAction, string> = {
|
|
|
278
278
|
update: "UPDATE AVAILABLE",
|
|
279
279
|
repair: "NEEDS REINSTALL",
|
|
280
280
|
current: "UP TO DATE",
|
|
281
|
+
// The DETAIL panel's label, not the row's — this is where the mechanism is
|
|
282
|
+
// allowed to be named, and the note beside it spells out both versions.
|
|
283
|
+
"stale-load": "BROKEN — OLDER VERSION LOADS",
|
|
281
284
|
unknown: "UNKNOWN",
|
|
282
285
|
};
|
|
283
286
|
|
|
@@ -463,6 +466,32 @@ function pluginDetail(
|
|
|
463
466
|
</text>
|
|
464
467
|
</box>
|
|
465
468
|
) : null}
|
|
469
|
+
{/*
|
|
470
|
+
* THE panel where the mechanism is spelled out in full. The row above
|
|
471
|
+
* says only "BROKEN — press a to fix", on purpose; a reader who wants to
|
|
472
|
+
* know why comes here, so this names both versions, says which registry
|
|
473
|
+
* row wins, and says what the fix does. A reinstall is measured NOT to
|
|
474
|
+
* fix it — it writes another row for this project while the winner
|
|
475
|
+
* belongs to a different one — so the fix rewrites those rows instead.
|
|
476
|
+
*/}
|
|
477
|
+
{status?.action === "stale-load" ? (
|
|
478
|
+
<box flexDirection="column">
|
|
479
|
+
<text fg={theme.colors.danger}>
|
|
480
|
+
✗ Broken: Claude Code loads v{status.loaded ?? "?"} in this
|
|
481
|
+
directory, not the v{status.installed ?? "?"} that is installed.
|
|
482
|
+
</text>
|
|
483
|
+
<text fg={theme.colors.muted}>
|
|
484
|
+
An install row belonging to another directory in this repository —
|
|
485
|
+
often a worktree that no longer exists — is resolved first. `magus
|
|
486
|
+
doctor` names which one.
|
|
487
|
+
</text>
|
|
488
|
+
<text fg={theme.colors.muted}>
|
|
489
|
+
Press a to fix: magus moves those rows forward to v
|
|
490
|
+
{status.target ?? status.installed ?? "?"}, backing up
|
|
491
|
+
installed_plugins.json first. Nothing is deleted or downgraded.
|
|
492
|
+
</text>
|
|
493
|
+
</box>
|
|
494
|
+
) : null}
|
|
466
495
|
|
|
467
496
|
{/* Description */}
|
|
468
497
|
<box marginTop={1} marginBottom={1}>
|
|
@@ -41,6 +41,20 @@ export function pluginStatusText(
|
|
|
41
41
|
};
|
|
42
42
|
case "repair":
|
|
43
43
|
return { suffix: ` stale — reinstall${withNote}`, tone: "warning" };
|
|
44
|
+
case "stale-load":
|
|
45
|
+
// NO VERSIONS, and the note is deliberately not shown. This row used to
|
|
46
|
+
// print the classifier's note verbatim — "≠ loads v0.3.0, installed
|
|
47
|
+
// v0.5.0" — which is internal vocabulary: it tells a reader neither what
|
|
48
|
+
// is wrong nor what to do, because "loads" versus "installed" is a
|
|
49
|
+
// distinction only this codebase draws. The row's job is the verdict and
|
|
50
|
+
// the key; the mechanism, including which project's registry row wins,
|
|
51
|
+
// belongs in the detail panel, which is where someone curious looks.
|
|
52
|
+
//
|
|
53
|
+
// Red, not the amber it carried while this was report-only. Amber is
|
|
54
|
+
// this file's tone for "cannot tell" and for routine work; a plugin
|
|
55
|
+
// whose session runs a version nobody installed is broken, and there is
|
|
56
|
+
// now a key that fixes it.
|
|
57
|
+
return { suffix: " BROKEN — press a to fix", tone: "danger" };
|
|
44
58
|
case "install":
|
|
45
59
|
// `absent` only ever comes from a profile pin (see `pinForRow`), so it
|
|
46
60
|
// may say so. An undeclared row that is merely enabled must not.
|