magus-cli 7.2.0 → 7.3.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.
@@ -14,6 +14,20 @@ import type {
14
14
  import { parsePluginId } from "../utils/string-utils.js";
15
15
  import { repoHeadSha } from "./content-drift.js";
16
16
  import { inheritablePaths } from "./git-worktree.js";
17
+ // `isRepairablyBehind` is the ONE predicate behind the `stale-load` state, shared
18
+ // with the classifier so the popup and the Plugins header cannot count different
19
+ // plugins — see the notes on it and on `findStaleLoads`. plugin-manager imports
20
+ // this module in turn; the cycle is binding-only (neither module calls the other
21
+ // at evaluation time), so both resolve whichever is loaded second against a live,
22
+ // hoisted function.
23
+ import { compareVersions, isRepairablyBehind } from "./plugin-manager.js";
24
+ import {
25
+ type RepoRootCache,
26
+ isMachineWideScope,
27
+ isRelevantInstallation,
28
+ pickLoadedEntry,
29
+ repoRootForPath,
30
+ } from "./plugin-resolution.js";
17
31
 
18
32
  const CLAUDE_DIR = ".claude";
19
33
  const SETTINGS_FILE = "settings.json";
@@ -790,8 +804,18 @@ export async function discoverAllGlobalMarketplaces(): Promise<
790
804
  // os.homedir() in a module-level constant makes the path capture whatever
791
805
  // HOME was set to when this module was first imported, which is fragile
792
806
  // (import-order-dependent) and breaks any caller that changes HOME afterward.
793
- const installedPluginsFile = (): string =>
794
- path.join(os.homedir(), ".claude", "plugins", "installed_plugins.json");
807
+ /**
808
+ * `~/.claude/plugins` — the directory holding BOTH the registry and the plugin
809
+ * cache. The two are derived from one root rather than spelled out twice, so a
810
+ * caller that needs to point the pair somewhere else (a test, above all) moves
811
+ * one argument and cannot land the registry in a temp directory while the cache
812
+ * path it writes still names the operator's real home.
813
+ */
814
+ export const pluginsDir = (): string =>
815
+ path.join(os.homedir(), ".claude", "plugins");
816
+
817
+ const installedPluginsFile = (root: string = pluginsDir()): string =>
818
+ path.join(root, "installed_plugins.json");
795
819
 
796
820
  const knownMarketplacesFile = (): string =>
797
821
  path.join(os.homedir(), ".claude", "plugins", "known_marketplaces.json");
@@ -1393,62 +1417,201 @@ export async function resolveCacheProvenance(
1393
1417
 
1394
1418
  /**
1395
1419
  * Read installed_plugins.json registry
1420
+ *
1421
+ * @param root the plugins directory to read from — see {@link pluginsDir}
1396
1422
  */
1397
- export async function readInstalledPluginsRegistry(): Promise<InstalledPluginsRegistry> {
1398
- try {
1399
- if (await fs.pathExists(installedPluginsFile())) {
1400
- const content = await fs.readJson(installedPluginsFile());
1401
- // Validate structure
1402
- if (!content.version || !content.plugins) {
1403
- throw new Error("Invalid registry structure");
1404
- }
1405
- return content;
1406
- }
1407
- } catch (error) {
1408
- // Backup corrupted file
1409
- if (await fs.pathExists(installedPluginsFile())) {
1410
- try {
1411
- const backup = `${installedPluginsFile()}.backup.${Date.now()}`;
1412
- await fs.copy(installedPluginsFile(), backup);
1413
- console.warn(`Corrupted registry backed up to: ${backup}`);
1414
- } catch {
1415
- // Ignore backup errors
1416
- }
1423
+ export async function readInstalledPluginsRegistry(
1424
+ root: string = pluginsDir(),
1425
+ ): Promise<InstalledPluginsRegistry> {
1426
+ const file = installedPluginsFile(root);
1427
+ const raw = await readRegistryBytes(file);
1428
+ const parsed = parseRegistryBytes(raw);
1429
+ if (parsed) return parsed;
1430
+ if (raw !== null) {
1431
+ // Corrupt rather than absent — keep a copy before any caller writes over it.
1432
+ try {
1433
+ const backup = await registryBackupPath(file, "corrupt");
1434
+ await fs.copy(file, backup);
1435
+ console.warn(`Corrupted registry backed up to: ${backup}`);
1436
+ } catch {
1437
+ // Ignore backup errors
1417
1438
  }
1418
1439
  }
1419
1440
  return { version: 2, plugins: {} };
1420
1441
  }
1421
1442
 
1422
1443
  /**
1423
- * Write installed_plugins.json registry
1444
+ * The registry file's exact bytes, or null when it is not there.
1445
+ *
1446
+ * Separate from the parse so a caller can hold what it read and hand it back as
1447
+ * a write PRECONDITION — see {@link writeInstalledPluginsRegistry}. Parsing from
1448
+ * those same bytes, rather than re-reading the file, is what makes the
1449
+ * precondition mean anything: a snapshot parsed from a second read would already
1450
+ * be a different document than the one the precondition names.
1451
+ */
1452
+ async function readRegistryBytes(file: string): Promise<string | null> {
1453
+ try {
1454
+ return await fs.readFile(file, "utf8");
1455
+ } catch {
1456
+ return null;
1457
+ }
1458
+ }
1459
+
1460
+ /** Parse + structure check. Null means "not a registry", including absent. */
1461
+ function parseRegistryBytes(
1462
+ raw: string | null,
1463
+ ): InstalledPluginsRegistry | null {
1464
+ if (raw === null) return null;
1465
+ try {
1466
+ const content = JSON.parse(raw);
1467
+ if (!content?.version || !content?.plugins) return null;
1468
+ return content as InstalledPluginsRegistry;
1469
+ } catch {
1470
+ return null;
1471
+ }
1472
+ }
1473
+
1474
+ /** How many `installed_plugins.json.backup.*` files are kept. */
1475
+ const MAX_REGISTRY_BACKUPS = 10;
1476
+
1477
+ /**
1478
+ * The one backup naming rule, so a reader can find every backup with one glob —
1479
+ * and so that two backups taken in the same millisecond are two files.
1480
+ *
1481
+ * `Date.now()` alone was the name. Two repairs inside one millisecond produced
1482
+ * the same path and `fs.copy` overwrote, which matters most in
1483
+ * `repairAllStaleLoads`: the second backup is a copy of the file AFTER the first
1484
+ * repair, so the surviving file no longer holds the pre-repair state of the
1485
+ * first plugin — the undo for it was gone. A process-lifetime sequence number
1486
+ * plus a label fixes the within-process case, and the existence loop covers two
1487
+ * magus processes landing on the same millisecond.
1488
+ *
1489
+ * The timestamp stays FIRST so a plain name sort is chronological, which is what
1490
+ * {@link pruneRegistryBackups} ranks by.
1491
+ */
1492
+ let registryBackupSeq = 0;
1493
+ async function registryBackupPath(
1494
+ file: string,
1495
+ label: string,
1496
+ ): Promise<string> {
1497
+ const safe = label.replace(/[^A-Za-z0-9._-]+/g, "-");
1498
+ const stamp = Date.now();
1499
+ for (;;) {
1500
+ // Fixed-width sequence: two backups in the same millisecond must still sort
1501
+ // in the order they were taken, and "10" sorts before "2" otherwise.
1502
+ const seq = String(registryBackupSeq++).padStart(3, "0");
1503
+ const candidate = `${file}.backup.${stamp}.${seq}.${safe}`;
1504
+ if (!(await fs.pathExists(candidate))) return candidate;
1505
+ }
1506
+ }
1507
+
1508
+ /**
1509
+ * Keep the newest {@link MAX_REGISTRY_BACKUPS} backups and delete the rest.
1510
+ *
1511
+ * Nothing pruned these, so every repair left another copy of a multi-hundred-KB
1512
+ * file in a directory magus does not own. Best-effort by design: a failure to
1513
+ * delete an old copy must never turn into a failed repair.
1514
+ */
1515
+ async function pruneRegistryBackups(file: string): Promise<void> {
1516
+ try {
1517
+ const dir = path.dirname(file);
1518
+ const prefix = `${path.basename(file)}.backup.`;
1519
+ const names = (await fs.readdir(dir))
1520
+ .filter((n) => n.startsWith(prefix))
1521
+ .sort();
1522
+ for (const name of names.slice(0, -MAX_REGISTRY_BACKUPS)) {
1523
+ await fs.remove(path.join(dir, name)).catch(() => {});
1524
+ }
1525
+ } catch {
1526
+ // Best-effort.
1527
+ }
1528
+ }
1529
+
1530
+ /**
1531
+ * A registry write was refused because the file changed after the caller read it.
1532
+ *
1533
+ * Its own type so a caller can tell a lost-update refusal from an IO failure and
1534
+ * say which happened, rather than reporting "could not write" for both.
1535
+ */
1536
+ export class RegistryChangedError extends Error {
1537
+ constructor(readonly file: string) {
1538
+ super(
1539
+ `${file} changed on disk after it was read — another Claude Code session wrote it. Nothing was written; re-run to work from the current file.`,
1540
+ );
1541
+ this.name = "RegistryChangedError";
1542
+ }
1543
+ }
1544
+
1545
+ /**
1546
+ * Write installed_plugins.json registry.
1547
+ *
1548
+ * ATOMIC — temp file in the same directory, then rename. This file is read by
1549
+ * every Claude Code session on the machine, so a write interrupted partway
1550
+ * leaves a truncated JSON document that `readInstalledPluginsRegistry` treats as
1551
+ * corrupt and replaces with an EMPTY registry: every plugin on the machine
1552
+ * reads as not installed. `rename(2)` within one directory cannot be observed
1553
+ * half-done, so a reader sees either the old file or the new one.
1554
+ *
1555
+ * ATOMICITY IS NOT ISOLATION, and conflating the two is how this silently lost
1556
+ * other sessions' work. A read-modify-write rewrites the WHOLE file from the
1557
+ * snapshot the caller parsed, so a `claude plugin install` landing in between —
1558
+ * for any plugin, in any project — is discarded by a write that is perfectly
1559
+ * atomic. This repo's own note (`ai-docs/claude-code-plugin-resolution.md`)
1560
+ * measured that file drifting across sessions inside minutes and losing 12 of
1561
+ * 123 resolutions to it; dozens of sessions on one machine is the normal case.
1562
+ *
1563
+ * @param root the plugins directory to write to — see {@link pluginsDir}
1564
+ * @param expectBytes the exact bytes the caller read, from
1565
+ * {@link readRegistryBytes} — `null` means "the file did not exist". Given
1566
+ * one, this compares it against the file as the LAST act before the rename and
1567
+ * throws {@link RegistryChangedError} rather than renaming over a newer
1568
+ * document. Omitted (`undefined`), there is no precondition and the write
1569
+ * clobbers, which is the old behaviour of every caller that has no snapshot to
1570
+ * offer.
1571
+ *
1572
+ * It is a precondition, not a lock: the compare and the rename are two
1573
+ * syscalls, so a write landing between them is still lost. That window is
1574
+ * microseconds instead of the whole read-modify-write, and a loop that retried
1575
+ * until it won would be the other failure mode — an unbounded retry against a
1576
+ * file other processes write constantly. Refusing hands the decision back to
1577
+ * the caller, which is why the repair reports it instead of spinning.
1424
1578
  */
1425
1579
  export async function writeInstalledPluginsRegistry(
1426
1580
  registry: InstalledPluginsRegistry,
1581
+ root: string = pluginsDir(),
1582
+ expectBytes?: string | null,
1427
1583
  ): Promise<void> {
1428
- await fs.ensureDir(path.dirname(installedPluginsFile()));
1429
- await fs.writeJson(installedPluginsFile(), registry, { spaces: 2 });
1584
+ const file = installedPluginsFile(root);
1585
+ await fs.ensureDir(path.dirname(file));
1586
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1587
+ try {
1588
+ await fs.writeJson(tmp, registry, { spaces: 2 });
1589
+ if (expectBytes !== undefined) {
1590
+ const current = await readRegistryBytes(file);
1591
+ if (current !== expectBytes) throw new RegistryChangedError(file);
1592
+ }
1593
+ await fs.rename(tmp, file);
1594
+ } catch (error) {
1595
+ await fs.remove(tmp).catch(() => {});
1596
+ throw error;
1597
+ }
1430
1598
  }
1431
1599
 
1432
1600
  /**
1433
1601
  * Get install path for a plugin version in cache
1602
+ *
1603
+ * @param root the plugins directory the cache sits under — see {@link pluginsDir}
1434
1604
  */
1435
1605
  function getPluginCachePath(
1436
1606
  pluginId: string,
1437
1607
  version: string,
1438
1608
  marketplace: string,
1609
+ root: string = pluginsDir(),
1439
1610
  ): string {
1440
1611
  const { pluginName } = parsePluginId(pluginId) || {
1441
1612
  pluginName: pluginId.split("@")[0],
1442
1613
  };
1443
- return path.join(
1444
- os.homedir(),
1445
- ".claude",
1446
- "plugins",
1447
- "cache",
1448
- marketplace,
1449
- pluginName,
1450
- version,
1451
- );
1614
+ return path.join(root, "cache", marketplace, pluginName, version);
1452
1615
  }
1453
1616
 
1454
1617
  /**
@@ -1562,3 +1725,451 @@ export async function removeFromInstalledPluginsRegistry(
1562
1725
  );
1563
1726
  }
1564
1727
  }
1728
+
1729
+ // ── Stale-load repair ────────────────────────────────────────────────────────
1730
+
1731
+ /**
1732
+ * Rows {@link repairStaleLoad} is allowed to rewrite: THIS repository's own
1733
+ * install records, and nothing else.
1734
+ *
1735
+ * Narrower than the loader's `isRelevantInstallation` in two deliberate ways, and
1736
+ * the difference is the point — relevance is the LOADER's question, this is the
1737
+ * WRITER's:
1738
+ *
1739
+ * - A `user` or `managed` row is relevant to every project on the machine, so
1740
+ * rewriting one moves every project at once. Measured before this filter
1741
+ * existed: repairing one project moved a user-scope row installed from an
1742
+ * unrelated directory forward on its own, while the dialog the user consented
1743
+ * on said "Fix updates this repo's install records". A repair must not exceed
1744
+ * the reach it states, so those rows are never written here. When such a row is
1745
+ * the one that WINS, the repair refuses outright and names the scope — silently
1746
+ * rewriting nothing would be the worse answer of the two.
1747
+ * - A null repository root matches null in the loader (two rootless directories
1748
+ * are relevant to each other there, and this port now says so). It must not
1749
+ * match here: "the same repository" cannot be true of two directories that are
1750
+ * in no repository at all, and treating it as true would have a repair run
1751
+ * from a scratch directory rewrite another scratch directory's rows. Only the
1752
+ * exact same directory qualifies without a root.
1753
+ */
1754
+ function belongsToThisRepository(
1755
+ entry: InstalledPluginEntry,
1756
+ cwd: string,
1757
+ cwdRoot: string | null,
1758
+ cache?: RepoRootCache,
1759
+ ): boolean {
1760
+ if (isMachineWideScope(entry.scope)) return false;
1761
+ if (!entry.projectPath) return false;
1762
+ if (path.resolve(entry.projectPath) === path.resolve(cwd)) return true;
1763
+ if (!cwdRoot) return false;
1764
+ return repoRootForPath(entry.projectPath, cache) === cwdRoot;
1765
+ }
1766
+
1767
+ /** A plugin whose loader-resolved version is behind a row this repair can rewrite. */
1768
+ export interface StaleLoad {
1769
+ pluginId: string;
1770
+ /** What Claude Code resolves in this directory. */
1771
+ loaded: string;
1772
+ /** The newest version a row relevant to this directory holds — the target. */
1773
+ target: string;
1774
+ }
1775
+
1776
+ /**
1777
+ * Which plugins {@link repairStaleLoad} can fix here, and to what version.
1778
+ *
1779
+ * REGISTRY ONLY — no catalog, no settings, no `git` subprocess. That is not a
1780
+ * shortcut, it is the question: this function's answer is the repair's own
1781
+ * PRECONDITION, and the repair rewrites registry rows, so the registry is the
1782
+ * only authority that can answer it. It therefore works offline and costs
1783
+ * nothing, which is what lets the TUI ask it during startup before any plugin
1784
+ * data or marketplace catalog has loaded.
1785
+ *
1786
+ * REPORTS ONLY WHAT IS ACTUALLY REPAIRABLE — with the one stated exception at the
1787
+ * end of this comment — and every exclusion below is a measured one. The first version of this function counted 8 broken plugins on
1788
+ * the reporting machine where the Plugins header counted 6, and offered to fix
1789
+ * all 8. The two extras could not be fixed by anything:
1790
+ *
1791
+ * - `plugin-dev@claude-plugins-official` recorded the version string
1792
+ * `"unknown"`. There is no cache directory named `unknown`, so
1793
+ * `repairStaleLoad` would reject it — the popup was offering a fix that
1794
+ * cannot run.
1795
+ * - `skill-creator@claude-plugins-official` held two differing commit shas.
1796
+ * `isBehind` calls those "behind" by design, but moving rows forward between
1797
+ * two unordered shas is a guess rather than a repair.
1798
+ *
1799
+ * So the version test is {@link isRepairablyBehind} — the SAME predicate
1800
+ * `classifyPlugin` uses for its `stale-load` state, which is what makes the
1801
+ * popup's count and the Plugins header's count the same question rather than two
1802
+ * that happen to agree. And the target's cache directory must exist, which is
1803
+ * `repairStaleLoad`'s own refusal asked in advance: a finding nothing can act on
1804
+ * does not belong in a popup that offers to act.
1805
+ *
1806
+ * ONE refusal is deliberately NOT asked in advance: a stale load whose winning
1807
+ * row is machine-wide (`user` or `managed`) is still reported here. It is a real
1808
+ * stale load — this directory runs old code — and the Plugins header counts it,
1809
+ * since the classifier sees versions and not scopes. Dropping it from the popup
1810
+ * to keep "everything I name is fixable" would mean the startup surface staying
1811
+ * silent about a session running a version nobody installed, which is the
1812
+ * deception this whole state exists to end. `repairStaleLoad` refuses it by name
1813
+ * and the fix summary prints that reason per plugin, so pressing Fix reports what
1814
+ * to do instead rather than a bare failure.
1815
+ *
1816
+ * Deliberately NOT the same question as `magus doctor`'s plugin-loads section,
1817
+ * which reports what MAGUS RECORDS as installed against what loads. That one is
1818
+ * a report and may legitimately name a mismatch nothing can act on.
1819
+ *
1820
+ * @param pins the active profile's pins — `pluginId -> exact version | "latest"`,
1821
+ * the SAME map `classifyPlugin` is given, so the popup and the Plugins header
1822
+ * cap their targets identically. An id the map does not mention is "latest",
1823
+ * which is also what `pinForRow` assumes for an installed-but-undeclared plugin.
1824
+ * The default `{}` therefore means "latest for everything".
1825
+ *
1826
+ * It is the one non-registry input, and it earns its place: without it a
1827
+ * user-scope row at 0.5.0 made this offer to move a plugin pinned to 0.3.0 —
1828
+ * installed at 0.3.0 here and loading it — up to 0.5.0, and the repair
1829
+ * succeeded. A machine-wide row is relevant to every directory, so the pin is
1830
+ * the only thing that can say the move is unwanted. Reading it costs one local
1831
+ * manifest read (`loadActiveClosure`), no network, so the offline promise holds.
1832
+ */
1833
+ export async function findStaleLoads(
1834
+ projectPath: string,
1835
+ root: string = pluginsDir(),
1836
+ pins: Readonly<Record<string, string>> = {},
1837
+ ): Promise<StaleLoad[]> {
1838
+ const registry = await readInstalledPluginsRegistry(root);
1839
+ // One cache for the whole pass — the same project paths repeat across every
1840
+ // plugin's rows, and each miss walks the tree to the filesystem root.
1841
+ const cache: RepoRootCache = new Map();
1842
+ const cwdRoot = repoRootForPath(projectPath, cache);
1843
+ const out: StaleLoad[] = [];
1844
+
1845
+ for (const [pluginId, rows] of Object.entries(registry.plugins ?? {})) {
1846
+ if (!Array.isArray(rows) || rows.length === 0) continue;
1847
+ const relevant = rows.filter((row) =>
1848
+ isRelevantInstallation(row, projectPath, cwdRoot, cache),
1849
+ );
1850
+ if (relevant.length === 0) continue;
1851
+
1852
+ // The newest version any relevant row holds THAT THE PIN PERMITS — by the
1853
+ // same ordered comparison, so a set of unrankable ids keeps the incumbent
1854
+ // instead of crowning one arbitrarily. The plugin is dropped below in that
1855
+ // case anyway.
1856
+ //
1857
+ // An exact pin is a ceiling, not a starting point: a row above it (usually
1858
+ // the machine-wide one) must not become the version this repository is moved
1859
+ // to. A version that cannot be ORDERED against the pin is excluded for the
1860
+ // same reason `isRepairablyBehind` excludes it — "cannot tell" never
1861
+ // licenses a rewrite.
1862
+ const pin = pins[pluginId] ?? "latest";
1863
+ const permitted = (version: string): boolean =>
1864
+ pin === "latest" || (compareVersions(pin, version) ?? -1) >= 0;
1865
+ let target: string | null = null;
1866
+ for (const row of relevant) {
1867
+ if (!row.version) continue;
1868
+ if (!permitted(row.version)) continue;
1869
+ if (target === null || isRepairablyBehind(row.version, target)) {
1870
+ target = row.version;
1871
+ }
1872
+ }
1873
+ if (!target) continue;
1874
+
1875
+ const loaded = pickLoadedEntry(rows, projectPath, cache)?.version ?? null;
1876
+ if (!isRepairablyBehind(target, loaded)) continue;
1877
+
1878
+ // `repairStaleLoad`'s cache-directory refusal, asked before offering the
1879
+ // fix instead of after accepting it.
1880
+ const parsed = parsePluginId(pluginId);
1881
+ if (!parsed) continue;
1882
+ if (
1883
+ !(await fs.pathExists(
1884
+ getPluginCachePath(pluginId, target, parsed.marketplace, root),
1885
+ ))
1886
+ ) {
1887
+ continue;
1888
+ }
1889
+
1890
+ out.push({ pluginId, loaded: loaded as string, target });
1891
+ }
1892
+
1893
+ out.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
1894
+ return out;
1895
+ }
1896
+
1897
+ /**
1898
+ * Repair every plugin {@link findStaleLoads} reports, one at a time.
1899
+ *
1900
+ * The same `repairStaleLoad` the apply engine calls per plan item, so the
1901
+ * startup popup and the Plugins screen's `a` key run identical work — there is
1902
+ * one repair, reached two ways, not two repairs.
1903
+ *
1904
+ * @param pins passed straight through to {@link findStaleLoads}, so the fix moves
1905
+ * rows to exactly the versions the survey named. Handing the survey a pin map
1906
+ * and the repair a different one is how a dialog would offer one thing and do
1907
+ * another.
1908
+ */
1909
+ export async function repairAllStaleLoads(
1910
+ projectPath: string,
1911
+ root: string = pluginsDir(),
1912
+ pins: Readonly<Record<string, string>> = {},
1913
+ ): Promise<StaleLoadRepairResult[]> {
1914
+ const out: StaleLoadRepairResult[] = [];
1915
+ // Sequential, not Promise.all: every repair reads, mutates and rewrites the
1916
+ // SAME file, so running two concurrently means the second overwrites the
1917
+ // first's rows from a snapshot taken before them.
1918
+ for (const broken of await findStaleLoads(projectPath, root, pins)) {
1919
+ out.push(
1920
+ await repairStaleLoad(broken.pluginId, broken.target, projectPath, root),
1921
+ );
1922
+ }
1923
+ return out;
1924
+ }
1925
+
1926
+ /**
1927
+ * What one {@link repairStaleLoad} attempt did, and whether it worked.
1928
+ *
1929
+ * `ok` is the POST-WRITE assertion, not "the write succeeded". Rewriting rows
1930
+ * and reporting success without re-asking the loader is how a repair that
1931
+ * changed nothing would read as a fix — the exact failure mode that made the
1932
+ * `stale-load` finding necessary in the first place.
1933
+ */
1934
+ export interface StaleLoadRepairResult {
1935
+ pluginId: string;
1936
+ /** The version every stale row was moved forward to. */
1937
+ target: string;
1938
+ /** Rows rewritten. Zero when no relevant row was behind the target. */
1939
+ rewritten: number;
1940
+ /** The backup taken before the first write; null when nothing was written. */
1941
+ backup: string | null;
1942
+ /** What the loader resolves here AFTER the write. */
1943
+ loaded: string | null;
1944
+ /** True only when `loaded` is now `target`. */
1945
+ ok: boolean;
1946
+ /** Why it failed. Absent when `ok`. */
1947
+ error?: string;
1948
+ }
1949
+
1950
+ /**
1951
+ * Make Claude Code's loader resolve `target` for `pluginId` in `projectPath`, by
1952
+ * moving this repository's stale registry rows FORWARD onto it.
1953
+ *
1954
+ * ── Why this edits installed_plugins.json directly ──
1955
+ *
1956
+ * The project's CLAUDE.md forbids writing `installed_plugins.json`: it is
1957
+ * Claude Code-owned state, and everything else goes through a `claude plugin`
1958
+ * command. This function is a DELIBERATE exception, and the user approved it,
1959
+ * because no `claude plugin` command can reach the rows that cause this bug.
1960
+ *
1961
+ * The loader takes the first row relevant to this repository whose `installPath`
1962
+ * exists (`pickLoadedEntry`, services/plugin-resolution.ts). "Relevant" includes
1963
+ * any row installed ANYWHERE in the same repository — including worktrees that
1964
+ * have since been deleted, whose rows nothing prunes. Measured on the reporting
1965
+ * machine, the winning rows belonged to other worktrees, several of them gone.
1966
+ * `claude plugin install` keys on the working directory, so it writes ANOTHER
1967
+ * row for this project and leaves the winner untouched; `claude plugin uninstall
1968
+ * --scope project` resolves the project from the working directory too, so it
1969
+ * cannot name a directory that no longer exists. A reinstall is measured not to
1970
+ * clear this. There is no CLI path to those rows at all.
1971
+ *
1972
+ * ── The rules this obeys ──
1973
+ *
1974
+ * MOVE FORWARD ONLY, DELETE NOTHING — the same promise `magus update` makes.
1975
+ * Rows are rewritten in place, so the array order the loader's tie-break depends
1976
+ * on is preserved, and a row belonging to a deleted worktree is moved forward
1977
+ * like any other. That is not collateral damage: it is the point, since that row
1978
+ * is usually the winner.
1979
+ *
1980
+ * A row is rewritten only when {@link belongsToThisRepository} accepts it AND
1981
+ * `isRepairablyBehind` says its version is ORDERED behind `target`. A row ahead
1982
+ * of the target, and one that cannot be ranked against it at all — two commit
1983
+ * shas, a `"unknown"` version — is left exactly as it is. "Cannot compare" is
1984
+ * never a licence to overwrite; the same predicate is what {@link findStaleLoads}
1985
+ * and the Plugins header ask, so nothing offers a repair this would then decline
1986
+ * to make.
1987
+ *
1988
+ * THIS REPOSITORY ONLY, which is what the dialog offering the fix promises. The
1989
+ * relevance filter the LOADER uses counts `user` and `managed` rows for every
1990
+ * project on the machine, and rewriting through it moved a machine-wide record
1991
+ * from an unrelated directory forward — measured. `belongsToThisRepository` is
1992
+ * the writer's narrower question; `pickLoadedEntry` keeps modelling the loader
1993
+ * faithfully, machine-wide relevance included, because a repair that misjudges
1994
+ * what loads is worse than one that declines to write.
1995
+ *
1996
+ * WHEN THE MACHINE-WIDE ROW IS THE WINNER, this refuses and says so, naming the
1997
+ * scope and the action that would fix it. The alternatives were both worse:
1998
+ * rewriting it exceeds the stated reach, and writing this repository's rows
1999
+ * anyway would change nothing the loader reads and then report `ok: false` from
2000
+ * the post-write assertion with no explanation of why. `magus update --check`
2001
+ * stays red for such a plugin until the user acts on that scope — the state is
2002
+ * real, and the one thing that must not happen is reporting it green.
2003
+ *
2004
+ * NOTHING IS WRITTEN OVER A FILE THAT MOVED. The snapshot is taken as bytes and
2005
+ * handed back to `writeInstalledPluginsRegistry` as a precondition, so a
2006
+ * concurrent `claude plugin install` makes this refuse rather than discard that
2007
+ * session's row. Dozens of sessions write this file on a working machine.
2008
+ *
2009
+ * @param root the plugins directory holding the registry and cache — see
2010
+ * {@link pluginsDir}. Tests pass a temp directory; nothing else should.
2011
+ */
2012
+ export async function repairStaleLoad(
2013
+ pluginId: string,
2014
+ target: string,
2015
+ projectPath: string,
2016
+ root: string = pluginsDir(),
2017
+ ): Promise<StaleLoadRepairResult> {
2018
+ const fail = (error: string): StaleLoadRepairResult => ({
2019
+ pluginId,
2020
+ target,
2021
+ rewritten: 0,
2022
+ backup: null,
2023
+ loaded: null,
2024
+ ok: false,
2025
+ error,
2026
+ });
2027
+
2028
+ const parsed = parsePluginId(pluginId);
2029
+ if (!parsed) return fail(`not a plugin id: ${pluginId}`);
2030
+
2031
+ // Refuse a target with no cache directory. Writing that row would leave the
2032
+ // registry naming a directory that is not there — the loader would resolve
2033
+ // it, fail to load anything, and the state would be worse than the stale load
2034
+ // it replaced. The installed version's cache always exists, which is why the
2035
+ // target is what is installed here rather than what the catalog offers.
2036
+ const cacheDir = getPluginCachePath(
2037
+ pluginId,
2038
+ target,
2039
+ parsed.marketplace,
2040
+ root,
2041
+ );
2042
+ if (!(await fs.pathExists(cacheDir))) {
2043
+ return fail(
2044
+ `no cache directory for v${target} — ${cacheDir} does not exist`,
2045
+ );
2046
+ }
2047
+
2048
+ const file = installedPluginsFile(root);
2049
+ // The BYTES first, and the snapshot parsed from those same bytes. They are the
2050
+ // precondition the write is checked against, so they have to describe one
2051
+ // moment — see `writeInstalledPluginsRegistry`.
2052
+ const before = await readRegistryBytes(file);
2053
+ const registry = parseRegistryBytes(before);
2054
+ if (!registry) {
2055
+ return fail(
2056
+ before === null
2057
+ ? `${file} does not exist`
2058
+ : `${file} is not a readable registry — refusing to write over it`,
2059
+ );
2060
+ }
2061
+ const rows = registry.plugins[pluginId];
2062
+ if (!Array.isArray(rows) || rows.length === 0) {
2063
+ return fail(`${pluginId} has no rows in ${file}`);
2064
+ }
2065
+
2066
+ // One cache for the whole pass: the same project paths repeat across rows,
2067
+ // and each miss walks the tree to the filesystem root.
2068
+ const cache: RepoRootCache = new Map();
2069
+ const cwdRoot = repoRootForPath(projectPath, cache);
2070
+
2071
+ // The row the loader picks here, asked BEFORE anything is written. When that
2072
+ // row is machine-wide, no rewrite this function is willing to make can change
2073
+ // the resolution, so it says so instead of writing rows that will not help —
2074
+ // and instead of reaching machine-wide, which is what it used to do.
2075
+ const winner = pickLoadedEntry(rows, projectPath, cache);
2076
+ if (
2077
+ winner &&
2078
+ isMachineWideScope(winner.scope) &&
2079
+ isRepairablyBehind(target, winner.version)
2080
+ ) {
2081
+ // Widened: Claude Code writes a `managed` scope for policy-installed
2082
+ // plugins, which the registry type in types/index.ts does not name — see
2083
+ // `LoaderScope` in plugin-resolution.ts.
2084
+ const scope: string = winner.scope;
2085
+ return fail(
2086
+ `the row Claude Code loads here is the machine-wide ${scope}-scope record at v${
2087
+ winner.version
2088
+ }, which every project on this machine resolves through. This repair only rewrites THIS repository's records, so it will not move it. ${
2089
+ scope === "managed"
2090
+ ? "A managed record is administrator policy — raise it with whoever set that policy."
2091
+ : `Update that scope directly instead: claude plugin install ${pluginId} --scope user`
2092
+ }`,
2093
+ );
2094
+ }
2095
+
2096
+ const stale = rows.filter(
2097
+ (row) =>
2098
+ belongsToThisRepository(row, projectPath, cwdRoot, cache) &&
2099
+ isRepairablyBehind(target, row.version),
2100
+ );
2101
+
2102
+ let backup: string | null = null;
2103
+ if (stale.length > 0) {
2104
+ // BEFORE the first mutation, and a failure here aborts the repair. This
2105
+ // file is the only record of what every project on the machine has
2106
+ // installed; an edit with no undo is not one to offer.
2107
+ try {
2108
+ backup = await registryBackupPath(file, pluginId);
2109
+ await fs.copy(file, backup);
2110
+ } catch (error) {
2111
+ return fail(
2112
+ `could not back up ${file}: ${
2113
+ error instanceof Error ? error.message : String(error)
2114
+ }`,
2115
+ );
2116
+ }
2117
+
2118
+ const now = new Date().toISOString();
2119
+ for (const row of stale) {
2120
+ row.version = target;
2121
+ row.installPath = cacheDir;
2122
+ row.lastUpdated = now;
2123
+ // The recorded commit describes the version we just moved AWAY from, so
2124
+ // keeping it would have content-drift diff two different releases and
2125
+ // report every repaired plugin as "stale — reinstall". Unknown
2126
+ // provenance is `undefined`, which `hasContentDrift` answers false for.
2127
+ row.gitCommitSha = undefined;
2128
+ }
2129
+ try {
2130
+ // `before` makes this a compare-and-set: another session's write landing
2131
+ // since the read means this snapshot would silently drop it, so the write
2132
+ // is refused instead. Nothing was changed at that point — the rows were
2133
+ // mutated in memory only — so the backup just taken is an undo for a file
2134
+ // nobody touched, and it goes rather than accumulating.
2135
+ await writeInstalledPluginsRegistry(registry, root, before);
2136
+ } catch (error) {
2137
+ if (backup) await fs.remove(backup).catch(() => {});
2138
+ return fail(
2139
+ error instanceof RegistryChangedError
2140
+ ? error.message
2141
+ : `could not write ${file}: ${
2142
+ error instanceof Error ? error.message : String(error)
2143
+ }`,
2144
+ );
2145
+ }
2146
+ await pruneRegistryBackups(file);
2147
+ }
2148
+
2149
+ // Re-read from DISK, not from the object just mutated: the assertion has to
2150
+ // judge the file the loader will read, which also proves the write landed.
2151
+ const after = await readInstalledPluginsRegistry(root);
2152
+ const loaded =
2153
+ pickLoadedEntry(after.plugins[pluginId], projectPath)?.version ?? null;
2154
+ if (loaded === target) {
2155
+ return {
2156
+ pluginId,
2157
+ target,
2158
+ rewritten: stale.length,
2159
+ backup,
2160
+ loaded,
2161
+ ok: true,
2162
+ };
2163
+ }
2164
+ return {
2165
+ pluginId,
2166
+ target,
2167
+ rewritten: stale.length,
2168
+ backup,
2169
+ loaded,
2170
+ ok: false,
2171
+ error: `the loader still resolves ${
2172
+ loaded ? `v${loaded}` : "nothing"
2173
+ } here, not v${target}`,
2174
+ };
2175
+ }