magus-cli 7.1.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.
- package/package.json +4 -4
- package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
- package/src/__tests__/doctor.test.ts +31 -0
- package/src/__tests__/plugin-resolution.test.ts +431 -0
- package/src/__tests__/plugin-row-text.test.ts +66 -0
- package/src/__tests__/profile-migration-offer.test.ts +211 -0
- package/src/__tests__/profile-migration.test.ts +242 -0
- package/src/__tests__/profile-startup.test.ts +458 -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__/useStaleLoadModal.test.ts +177 -0
- package/src/cli/ansi.ts +39 -0
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/doctor.ts +128 -2
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +32 -1
- package/src/cli/update.ts +50 -7
- package/src/main.tsx +10 -1
- package/src/services/active-closure.ts +23 -0
- package/src/services/claude-settings.ts +645 -34
- package/src/services/manifest.ts +21 -0
- package/src/services/plugin-manager.ts +61 -0
- package/src/services/plugin-resolution.ts +280 -0
- package/src/services/profile-activate.ts +147 -0
- package/src/services/profile-adopt.ts +50 -4
- package/src/services/profile-startup.ts +311 -0
- package/src/services/profiles.ts +49 -11
- package/src/services/symlink-manager.ts +184 -30
- package/src/services/update-engine.ts +104 -2
- package/src/services/update-plan.ts +188 -1
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +123 -0
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -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/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/PluginsScreen.tsx +46 -13
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +54 -0
package/src/services/manifest.ts
CHANGED
|
@@ -52,6 +52,12 @@ export function migrateToV2(raw: unknown): ProfileManifest {
|
|
|
52
52
|
// Only carry strictVersions when enabled, so a committed manifest that
|
|
53
53
|
// doesn't opt in stays minimal (no spurious `"strictVersions": false`).
|
|
54
54
|
if (obj.strictVersions === true) manifest.strictVersions = true;
|
|
55
|
+
// Carried through even when it names no existing profile: dropping it here
|
|
56
|
+
// would silently "fix" a typo that `validateManifest` is meant to report,
|
|
57
|
+
// and the commands that read it all check the id resolves first.
|
|
58
|
+
if (typeof obj.default === "string" && obj.default !== "") {
|
|
59
|
+
manifest.default = obj.default;
|
|
60
|
+
}
|
|
55
61
|
return manifest;
|
|
56
62
|
}
|
|
57
63
|
|
|
@@ -110,6 +116,21 @@ export function validateManifest(
|
|
|
110
116
|
errors.push({ path: "profiles", message: "no profiles defined" });
|
|
111
117
|
}
|
|
112
118
|
|
|
119
|
+
// A `default` naming nothing is worse than none: a fresh clone would fall
|
|
120
|
+
// through to asking, with no sign that the repo tried to answer and got the
|
|
121
|
+
// id wrong. Reported here so it fails the same gate every other manifest
|
|
122
|
+
// mistake does, rather than being discovered as silence.
|
|
123
|
+
if (manifest.default !== undefined) {
|
|
124
|
+
if (typeof manifest.default !== "string") {
|
|
125
|
+
errors.push({ path: "default", message: "must be a profile id" });
|
|
126
|
+
} else if (!ids.includes(manifest.default)) {
|
|
127
|
+
errors.push({
|
|
128
|
+
path: "default",
|
|
129
|
+
message: `no profile "${manifest.default}" — declared: ${ids.join(", ")}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
113
134
|
for (const [id, entry] of Object.entries(manifest.profiles ?? {})) {
|
|
114
135
|
const base = `profiles.${id}`;
|
|
115
136
|
if (!entry || typeof entry !== "object") {
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
isAuthoritative,
|
|
46
46
|
resolveMarketplacePlugins,
|
|
47
47
|
} from "./marketplace-fetcher.js";
|
|
48
|
+
import { resolveLoadedVersions } from "./plugin-resolution.js";
|
|
48
49
|
export {
|
|
49
50
|
fetchMarketplacePlugins,
|
|
50
51
|
getMarketplaceFetchFailures,
|
|
@@ -72,6 +73,17 @@ export interface PluginInfo {
|
|
|
72
73
|
marketplaceDisplay: string;
|
|
73
74
|
enabled: boolean;
|
|
74
75
|
installedVersion?: string;
|
|
76
|
+
/**
|
|
77
|
+
* The version Claude Code's loader will ACTUALLY load in this project, or
|
|
78
|
+
* null when it will load nothing here.
|
|
79
|
+
*
|
|
80
|
+
* An INDEPENDENT column from `installedVersion`, not a correction of it. The
|
|
81
|
+
* two answer different questions — what magus records as installed here,
|
|
82
|
+
* versus what the loader resolves (services/plugin-resolution.ts) — and when
|
|
83
|
+
* they disagree the session runs code that no version magus records names.
|
|
84
|
+
* That disagreement is the finding; averaging the two would hide it.
|
|
85
|
+
*/
|
|
86
|
+
loadedVersion?: string | null;
|
|
75
87
|
/**
|
|
76
88
|
* Provably behind the catalog, on ONE version pair.
|
|
77
89
|
*
|
|
@@ -277,6 +289,18 @@ export async function getAvailablePlugins(
|
|
|
277
289
|
const enabledPlugins = await getEnabledPlugins(projectPath);
|
|
278
290
|
const installedVersions = await getInstalledPluginVersions(projectPath);
|
|
279
291
|
|
|
292
|
+
// What Claude Code will LOAD here, which is a different question from what is
|
|
293
|
+
// installed here and is answered by a different algorithm — the loader's own,
|
|
294
|
+
// ported in services/plugin-resolution.ts. Resolved once for the whole
|
|
295
|
+
// registry and carried alongside `installedVersion` on every row below; where
|
|
296
|
+
// the two disagree, the classifier calls it `stale-load`.
|
|
297
|
+
const loadedVersions = resolveLoadedVersions(
|
|
298
|
+
await readInstalledPluginsRegistry(),
|
|
299
|
+
projectPath ?? process.cwd(),
|
|
300
|
+
);
|
|
301
|
+
const loadedVersionOf = (pluginId: string): string | null =>
|
|
302
|
+
loadedVersions[pluginId] ?? null;
|
|
303
|
+
|
|
280
304
|
// Fetch all scopes for per-scope status
|
|
281
305
|
const userEnabledPlugins = await getGlobalEnabledPlugins();
|
|
282
306
|
const userInstalledVersions = await getGlobalInstalledPluginVersions();
|
|
@@ -384,6 +408,7 @@ export async function getAvailablePlugins(
|
|
|
384
408
|
marketplaceDisplay: marketplace.displayName,
|
|
385
409
|
enabled: isEnabled,
|
|
386
410
|
installedVersion: installedVersion,
|
|
411
|
+
loadedVersion: loadedVersionOf(pluginId),
|
|
387
412
|
hasUpdate: isBehind(plugin.version, installedVersion) === true,
|
|
388
413
|
catalogSource: resolution.source,
|
|
389
414
|
updateCheckFailed: unverified,
|
|
@@ -423,6 +448,7 @@ export async function getAvailablePlugins(
|
|
|
423
448
|
marketplaceDisplay: localMp.name || formatMarketplaceName(mpName),
|
|
424
449
|
enabled: isEnabled,
|
|
425
450
|
installedVersion: installedVersion,
|
|
451
|
+
loadedVersion: loadedVersionOf(pluginId),
|
|
426
452
|
hasUpdate: isBehind(localPlugin.version, installedVersion) === true,
|
|
427
453
|
...scopeStatus,
|
|
428
454
|
category: localPlugin.category,
|
|
@@ -472,6 +498,7 @@ export async function getAvailablePlugins(
|
|
|
472
498
|
marketplaceDisplay: localMp?.name || formatMarketplaceName(mpName),
|
|
473
499
|
enabled: isEnabled,
|
|
474
500
|
installedVersion: installedVersion,
|
|
501
|
+
loadedVersion: loadedVersionOf(pluginId),
|
|
475
502
|
hasUpdate,
|
|
476
503
|
isOrphaned: true,
|
|
477
504
|
...scopeStatus,
|
|
@@ -652,6 +679,40 @@ export function isBehind(
|
|
|
652
679
|
return bothOpaque ? true : null;
|
|
653
680
|
}
|
|
654
681
|
|
|
682
|
+
/**
|
|
683
|
+
* Is `loaded` behind `target` in a way that MOVING FORWARD would actually fix?
|
|
684
|
+
*
|
|
685
|
+
* THE predicate behind the `stale-load` state. Both the classifier (which counts
|
|
686
|
+
* broken plugins in the Plugins header) and `findStaleLoads` (which counts them
|
|
687
|
+
* for the startup popup) ask this and nothing else, so the two surfaces cannot
|
|
688
|
+
* report different numbers for the same directory. They did: the popup said 8
|
|
689
|
+
* where the header said 6, because it asked {@link isBehind} instead.
|
|
690
|
+
*
|
|
691
|
+
* Stricter than `isBehind` in exactly one place, and deliberately: `isBehind`
|
|
692
|
+
* answers TRUE for two differing opaque ids, because for its question — "is this
|
|
693
|
+
* what the marketplace publishes now" — a different commit sha means no. That
|
|
694
|
+
* reasoning does not carry here. "Move the rows forward from sha A to sha B" is
|
|
695
|
+
* not a repair, it is a coin toss: the two carry no ordering, so nothing
|
|
696
|
+
* establishes which is later. Measured on the reporting machine, that alone put
|
|
697
|
+
* `skill-creator@claude-plugins-official` (two differing shas) and
|
|
698
|
+
* `plugin-dev@claude-plugins-official` (version literally `"unknown"`) into the
|
|
699
|
+
* popup's count, and neither could be repaired — `repairStaleLoad` rejects them
|
|
700
|
+
* at its cache-directory check.
|
|
701
|
+
*
|
|
702
|
+
* So this requires an ORDERED pair: `compareVersions` must return a number, and
|
|
703
|
+
* that number must be positive. A version against `null`, a semver against an
|
|
704
|
+
* opaque id, `"unknown"` against anything, and two differing shas all answer
|
|
705
|
+
* false — "cannot rank these" is never grounds for rewriting a registry row.
|
|
706
|
+
*/
|
|
707
|
+
export function isRepairablyBehind(
|
|
708
|
+
target: string | null | undefined,
|
|
709
|
+
loaded: string | null | undefined,
|
|
710
|
+
): boolean {
|
|
711
|
+
if (!target || !loaded) return false;
|
|
712
|
+
const cmp = compareVersions(target, loaded);
|
|
713
|
+
return cmp !== null && cmp > 0;
|
|
714
|
+
}
|
|
715
|
+
|
|
655
716
|
// Get installed plugin versions from settings.json (shared, not secret)
|
|
656
717
|
async function getInstalledPluginVersions(
|
|
657
718
|
projectPath?: string,
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Claude Code's loader will ACTUALLY load in this directory.
|
|
3
|
+
*
|
|
4
|
+
* Ported from Claude Code 2.1.274, decompiled: the relevance filter `SF`
|
|
5
|
+
* (byte 178070986) and the selection function `Bls` (byte 178140984). The port
|
|
6
|
+
* was validated against the real loader over 123/123 plugin resolutions across
|
|
7
|
+
* 12 repositories and worktrees. RE-VALIDATE IT WHEN CLAUDE CODE UPDATES —
|
|
8
|
+
* this file describes someone else's algorithm, so a new release can move it
|
|
9
|
+
* without anything here failing.
|
|
10
|
+
*
|
|
11
|
+
* Why it exists: magus reported versions from `settings.installedPluginVersions`
|
|
12
|
+
* overlaid with this project's `installed_plugins.json` rows (see
|
|
13
|
+
* `pickRegistryEntry` in claude-settings.ts). The loader consults neither of
|
|
14
|
+
* those orderings, so magus could report "up to date" while the session in that
|
|
15
|
+
* same directory loaded a two-versions-old plugin. The two answers are
|
|
16
|
+
* genuinely different questions and both are kept:
|
|
17
|
+
*
|
|
18
|
+
* pickRegistryEntry — "what does magus consider installed for this scope"
|
|
19
|
+
* pickLoadedEntry — "what will Claude Code load here"
|
|
20
|
+
*
|
|
21
|
+
* This module holds NO magus policy. It answers the second question only, so
|
|
22
|
+
* that a disagreement between the two is visible rather than averaged away.
|
|
23
|
+
*
|
|
24
|
+
* KNOWN DIVERGENCES from the decompiled original, both of which only ever REMOVE
|
|
25
|
+
* candidates — so each can make this module name an entry the loader does not
|
|
26
|
+
* use, and neither can make it miss one the loader does use:
|
|
27
|
+
*
|
|
28
|
+
* 1. The binary additionally rejects an entry whose `installPath` fails a
|
|
29
|
+
* path-safety check (`suspect(installPath)`). Not ported — every
|
|
30
|
+
* `installPath` is treated as non-suspect here.
|
|
31
|
+
* 2. The binary's relevance filter reads `entry.projectPath === originalCwd ||
|
|
32
|
+
* repoRoot(entry.projectPath) === repoRoot(originalCwd)` with no guard on
|
|
33
|
+
* `projectPath` being absent at all. A project-scope row carrying no
|
|
34
|
+
* `projectPath` is rejected outright here (`isRelevantInstallation`), because
|
|
35
|
+
* what `repoRoot(undefined)` answers inside the binary was not measured and
|
|
36
|
+
* guessing it would put a row into the selection on a guess. Claude Code
|
|
37
|
+
* writes `projectPath` for every project-scope install, so the case is
|
|
38
|
+
* theoretical rather than observed.
|
|
39
|
+
*
|
|
40
|
+
* A THIRD divergence was removed rather than documented: two rootless paths used
|
|
41
|
+
* to compare unequal here, so in a directory belonging to no repository every
|
|
42
|
+
* rootless row was irrelevant to magus while the loader still counted it. See
|
|
43
|
+
* {@link isRelevantInstallation}.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import fs from "node:fs";
|
|
47
|
+
import path from "node:path";
|
|
48
|
+
import type {
|
|
49
|
+
InstalledPluginEntry,
|
|
50
|
+
InstalledPluginsRegistry,
|
|
51
|
+
} from "../types/index.js";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Memo for {@link repoRootForPath}. A registry holds one row per project path
|
|
55
|
+
* and a well-used machine has hundreds of them, most repeated across plugins;
|
|
56
|
+
* each miss walks the tree to the filesystem root with a `stat` per level.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately passed in rather than held at module scope: a git root can come
|
|
59
|
+
* into existence while the TUI is open, and a process-lifetime cache would
|
|
60
|
+
* answer from before it did.
|
|
61
|
+
*/
|
|
62
|
+
export type RepoRootCache = Map<string, string | null>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Claude Code writes a `managed` scope for policy-installed plugins. The
|
|
66
|
+
* registry type in `types/index.ts` names only the three scopes magus itself
|
|
67
|
+
* writes, so the extra one is widened here instead of loosening a type that
|
|
68
|
+
* dozens of call sites narrow on.
|
|
69
|
+
*/
|
|
70
|
+
type LoaderScope = InstalledPluginEntry["scope"] | "managed";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Scopes the loader treats as relevant to EVERY project, whatever its path:
|
|
74
|
+
* `user` (the machine-wide install) and `managed` (administrator policy).
|
|
75
|
+
*
|
|
76
|
+
* Exported because a writer has to ask the same question the filter does. A row
|
|
77
|
+
* in one of these scopes is not this repository's record — moving it forward
|
|
78
|
+
* changes what every project on the machine loads — so `repairStaleLoad` refuses
|
|
79
|
+
* to rewrite one instead of quietly reaching that far.
|
|
80
|
+
*/
|
|
81
|
+
export function isMachineWideScope(
|
|
82
|
+
scope: LoaderScope | string | undefined,
|
|
83
|
+
): boolean {
|
|
84
|
+
return scope === "user" || scope === "managed";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `.git` as an entry of `dir`: a directory (real checkout), a file (pointer), or absent. */
|
|
88
|
+
function gitEntryKind(dir: string): "dir" | "file" | null {
|
|
89
|
+
try {
|
|
90
|
+
const stat = fs.statSync(path.join(dir, ".git"));
|
|
91
|
+
if (stat.isDirectory()) return "dir";
|
|
92
|
+
if (stat.isFile()) return "file";
|
|
93
|
+
return null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The main repository root behind a linked worktree's `.git` FILE, which holds
|
|
101
|
+
* `gitdir: <main>/.git/worktrees/<name>`.
|
|
102
|
+
*
|
|
103
|
+
* The `.git` component is searched for first and the `worktrees` component only
|
|
104
|
+
* as a fallback, because the former sits ABOVE the latter in the usual layout
|
|
105
|
+
* and matching `worktrees` first would answer `<main>/.git`. The fallback is
|
|
106
|
+
* for a common directory kept outside a `.git` directory (a bare
|
|
107
|
+
* `/srv/repo.git/worktrees/<name>`), where the repository itself is the closest
|
|
108
|
+
* thing to a root. The same walk maps a submodule's
|
|
109
|
+
* `<super>/.git/modules/<name>` onto the superproject root.
|
|
110
|
+
*/
|
|
111
|
+
function mainRootFromGitFile(gitFile: string, dir: string): string | null {
|
|
112
|
+
let gitdir: string;
|
|
113
|
+
try {
|
|
114
|
+
const match = /^\s*gitdir:\s*(.+?)\s*$/m.exec(
|
|
115
|
+
fs.readFileSync(gitFile, "utf8"),
|
|
116
|
+
);
|
|
117
|
+
// A `.git` file we cannot parse still marks a working tree, and `dir` is
|
|
118
|
+
// the only root it could name.
|
|
119
|
+
if (!match?.[1]) return dir;
|
|
120
|
+
gitdir = path.resolve(dir, match[1]);
|
|
121
|
+
} catch {
|
|
122
|
+
return dir;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const component of [".git", "worktrees"]) {
|
|
126
|
+
let cur = gitdir;
|
|
127
|
+
for (;;) {
|
|
128
|
+
const parent = path.dirname(cur);
|
|
129
|
+
if (path.basename(cur) === component) return parent;
|
|
130
|
+
if (parent === cur) break;
|
|
131
|
+
cur = parent;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return gitdir;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The repository root containing `p`, or null when it is in no repository.
|
|
139
|
+
*
|
|
140
|
+
* PATH-BASED, never a `git` subprocess, and that is the whole point: registry
|
|
141
|
+
* rows for DELETED worktrees still take part in the selection below, and every
|
|
142
|
+
* git command fails on a directory that is not there — so a subprocess
|
|
143
|
+
* implementation returns "no root" for exactly the entries that decide the
|
|
144
|
+
* answer. Walking up works on a path that does not exist: the nearest surviving
|
|
145
|
+
* ancestor still carries the `.git` entry.
|
|
146
|
+
*/
|
|
147
|
+
export function repoRootForPath(
|
|
148
|
+
p: string,
|
|
149
|
+
cache?: RepoRootCache,
|
|
150
|
+
): string | null {
|
|
151
|
+
if (!p) return null;
|
|
152
|
+
const start = path.resolve(p);
|
|
153
|
+
const memo = cache?.get(start);
|
|
154
|
+
if (memo !== undefined) return memo;
|
|
155
|
+
|
|
156
|
+
let dir = start;
|
|
157
|
+
let root: string | null = null;
|
|
158
|
+
for (;;) {
|
|
159
|
+
const kind = gitEntryKind(dir);
|
|
160
|
+
if (kind === "dir") {
|
|
161
|
+
root = dir;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
if (kind === "file") {
|
|
165
|
+
root = mainRootFromGitFile(path.join(dir, ".git"), dir);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
const parent = path.dirname(dir);
|
|
169
|
+
if (parent === dir) break;
|
|
170
|
+
dir = parent;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
cache?.set(start, root);
|
|
174
|
+
return root;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `SF` — is this installation relevant to the current project?
|
|
179
|
+
*
|
|
180
|
+
* Three ways to be relevant, and the third is the one that surprises people: a
|
|
181
|
+
* row installed ANYWHERE in the same repository counts, including a worktree
|
|
182
|
+
* that has since been deleted. Nothing prunes those rows, so they keep voting.
|
|
183
|
+
*
|
|
184
|
+
* @param cwdRoot {@link repoRootForPath} of `cwd`, resolved once by the caller
|
|
185
|
+
* because it is the same answer for every entry.
|
|
186
|
+
*/
|
|
187
|
+
export function isRelevantInstallation(
|
|
188
|
+
entry: InstalledPluginEntry,
|
|
189
|
+
cwd: string,
|
|
190
|
+
cwdRoot: string | null,
|
|
191
|
+
cache?: RepoRootCache,
|
|
192
|
+
): boolean {
|
|
193
|
+
if (isMachineWideScope(entry.scope)) return true;
|
|
194
|
+
if (!entry.projectPath) return false;
|
|
195
|
+
|
|
196
|
+
// The binary compares the two paths as raw strings. They are normalised here
|
|
197
|
+
// first, which can only turn a spelling difference of the same directory
|
|
198
|
+
// ("/p/a/" vs "/p/a") into a match — and that same pair already matches on
|
|
199
|
+
// the repository-root comparison below, which normalises on the way through.
|
|
200
|
+
if (path.resolve(entry.projectPath) === path.resolve(cwd)) return true;
|
|
201
|
+
|
|
202
|
+
// NULL MATCHES NULL, because the original's `repoRoot(entry.projectPath) ===
|
|
203
|
+
// repoRoot(originalCwd)` does: two directories in no repository at all compare
|
|
204
|
+
// equal there, so every rootless row competes when Claude Code runs from a
|
|
205
|
+
// scratch directory, a home directory, or any plain non-git folder.
|
|
206
|
+
//
|
|
207
|
+
// This used to `return false` on a null `cwdRoot`, on the reasoning that two
|
|
208
|
+
// unrelated rootless directories are not the same project. That reasoning is
|
|
209
|
+
// magus policy, and this module holds none: with it, magus could name a
|
|
210
|
+
// `loadedVersion` the loader would not pick, and a repair would skip the very
|
|
211
|
+
// rows the loader treats as relevant — the same class of wrong answer the port
|
|
212
|
+
// exists to eliminate.
|
|
213
|
+
//
|
|
214
|
+
// The policy did not disappear, it moved to where it belongs: `repairStaleLoad`
|
|
215
|
+
// rewrites a row only when it belongs to THIS repository, which requires either
|
|
216
|
+
// the exact same directory or a non-null shared root. So a rootless row from an
|
|
217
|
+
// unrelated directory is reported as relevant (true) and never written (correct).
|
|
218
|
+
return repoRootForPath(entry.projectPath, cache) === cwdRoot;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* `Bls` — the entry the loader will use, or undefined when it loads nothing.
|
|
223
|
+
*
|
|
224
|
+
* Faithful to the decompiled original, including the two parts that look like
|
|
225
|
+
* bugs and are load-bearing:
|
|
226
|
+
*
|
|
227
|
+
* - The single-candidate branch returns WITHOUT checking that `installPath`
|
|
228
|
+
* exists. One relevant row means that row loads (or fails to), whatever is
|
|
229
|
+
* on disk.
|
|
230
|
+
* - `firstValid` is captured BEFORE the existence test, so the fallback is the
|
|
231
|
+
* first entry that merely HAS an `installPath` — not the first existing one.
|
|
232
|
+
*
|
|
233
|
+
* ARRAY ORDER decides ties, and array order is install order. It is not sorted
|
|
234
|
+
* by version, by scope, or by recency, so an older install made earlier beats a
|
|
235
|
+
* newer one made later. That is the madbench 0.3.0 defect in one sentence.
|
|
236
|
+
*/
|
|
237
|
+
export function pickLoadedEntry(
|
|
238
|
+
entries: readonly InstalledPluginEntry[] | undefined,
|
|
239
|
+
cwd: string,
|
|
240
|
+
cache?: RepoRootCache,
|
|
241
|
+
): InstalledPluginEntry | undefined {
|
|
242
|
+
if (!Array.isArray(entries) || entries.length === 0) return undefined;
|
|
243
|
+
|
|
244
|
+
const cwdRoot = repoRootForPath(cwd, cache);
|
|
245
|
+
const relevant = entries.filter((e) =>
|
|
246
|
+
isRelevantInstallation(e, cwd, cwdRoot, cache),
|
|
247
|
+
);
|
|
248
|
+
if (relevant.length === 0) return undefined;
|
|
249
|
+
if (relevant.length === 1) return relevant[0];
|
|
250
|
+
|
|
251
|
+
let firstValid: InstalledPluginEntry | undefined;
|
|
252
|
+
for (const entry of relevant) {
|
|
253
|
+
if (!entry.installPath) continue;
|
|
254
|
+
if (firstValid === undefined) firstValid = entry;
|
|
255
|
+
if (fs.existsSync(entry.installPath)) return entry;
|
|
256
|
+
}
|
|
257
|
+
return firstValid ?? relevant[0];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* pluginId → the version that will actually load in `cwd`.
|
|
262
|
+
*
|
|
263
|
+
* Plugins whose winning entry carries no version are absent from the map rather
|
|
264
|
+
* than present with a placeholder: "no version recorded" is not "loads nothing",
|
|
265
|
+
* and a caller comparing against it must be able to tell.
|
|
266
|
+
*/
|
|
267
|
+
export function resolveLoadedVersions(
|
|
268
|
+
registry: InstalledPluginsRegistry,
|
|
269
|
+
cwd: string,
|
|
270
|
+
): Record<string, string> {
|
|
271
|
+
// One cache for the whole pass: the same few hundred project paths repeat
|
|
272
|
+
// across every plugin in the registry.
|
|
273
|
+
const cache: RepoRootCache = new Map();
|
|
274
|
+
const out: Record<string, string> = {};
|
|
275
|
+
for (const [pluginId, entries] of Object.entries(registry.plugins ?? {})) {
|
|
276
|
+
const pick = pickLoadedEntry(entries, cwd, cache);
|
|
277
|
+
if (pick?.version) out[pluginId] = pick.version;
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finish a migration: put a profile on disk and make the project run it.
|
|
3
|
+
*
|
|
4
|
+
* Adoption used to end at `writeManifest`, which is the whole reason migrating
|
|
5
|
+
* felt like it did nothing. "Active" is not a field anywhere — it is derived
|
|
6
|
+
* from whether `.claude/settings.json` is a symlink into `_profiles/<id>/`
|
|
7
|
+
* (see `activeProfile`). A manifest that nothing links to leaves the project in
|
|
8
|
+
* exactly the state it was in, so the next command still asks which profile to
|
|
9
|
+
* use, having just been told.
|
|
10
|
+
*
|
|
11
|
+
* The three steps have to happen together and in this order, which is why they
|
|
12
|
+
* live in one function rather than at each call site:
|
|
13
|
+
*
|
|
14
|
+
* 1. materialize — write `_profiles/<id>/`, or activation throws
|
|
15
|
+
* 2. gitignore — before the links exist, or a symlink into generated state
|
|
16
|
+
* lands in `git status` (`.claude/` is committed)
|
|
17
|
+
* 3. activate — point the four links at the profile
|
|
18
|
+
*
|
|
19
|
+
* No network and no installs: `resolveProfile` reads the manifest, and binary
|
|
20
|
+
* resolution is skipped outright because nothing here materializes bins. That
|
|
21
|
+
* matters — this runs inside a prompt the user answered, not inside `install`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
26
|
+
import { ensureGitignoreEntries } from "./conventions-manager.js";
|
|
27
|
+
import { writeManifest } from "./manifest.js";
|
|
28
|
+
import { adoptCurrentProject } from "./profile-adopt.js";
|
|
29
|
+
import { materializeProfile } from "./profile-materializer.js";
|
|
30
|
+
import { resolveAllProfiles, resolveProfile } from "./resolver.js";
|
|
31
|
+
import {
|
|
32
|
+
type DisplacedArtifact,
|
|
33
|
+
PROFILE_GITIGNORE_ENTRIES,
|
|
34
|
+
activateProfile,
|
|
35
|
+
} from "./symlink-manager.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Bins are declared by plugins and read from the marketplace cache, one
|
|
39
|
+
* `plugin.json` per plugin. Materialization writes settings, mcp, models and
|
|
40
|
+
* skills — never bins — so resolving them here would be pure latency, and on a
|
|
41
|
+
* machine with a cold cache it is latency inside a prompt.
|
|
42
|
+
*/
|
|
43
|
+
const noBins = async () => [];
|
|
44
|
+
|
|
45
|
+
export interface ActivationResult {
|
|
46
|
+
/** The profile now live. */
|
|
47
|
+
profileId: string;
|
|
48
|
+
/** Hand-written artifacts moved aside to make room for the symlinks. */
|
|
49
|
+
displaced: DisplacedArtifact[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Announce one displaced artifact, in project-relative terms.
|
|
54
|
+
*
|
|
55
|
+
* Shared by every command that activates, because a hand-written file moving is
|
|
56
|
+
* the same event whichever command caused it, and the one that is easiest to
|
|
57
|
+
* leave out: `activateProfile` returns the list, and a caller that discards the
|
|
58
|
+
* return value still compiles. `magus install` and `magus profile switch` both
|
|
59
|
+
* did exactly that.
|
|
60
|
+
*
|
|
61
|
+
* Paths are relative because absolute ones wrap over two terminal lines each,
|
|
62
|
+
* and this is the notice a user must not skim.
|
|
63
|
+
*/
|
|
64
|
+
export function reportDisplaced(
|
|
65
|
+
projectPath: string,
|
|
66
|
+
): (artifact: DisplacedArtifact) => void {
|
|
67
|
+
const rel = (p: string) => path.relative(projectPath, p);
|
|
68
|
+
return ({ link, savedTo }) =>
|
|
69
|
+
console.log(` ${rel(link)} → kept at ${rel(savedTo)}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Materialize `profileId` and make it the active profile.
|
|
74
|
+
*
|
|
75
|
+
* The union of plugin ids across EVERY profile is passed to materialization, so
|
|
76
|
+
* the profile writes an explicit `false` for plugins other profiles own. Without
|
|
77
|
+
* it an omitted id falls through to user scope and stays enabled, which would
|
|
78
|
+
* make the freshly adopted profile additive rather than a description of this
|
|
79
|
+
* project — the exact drift adoption was run to end.
|
|
80
|
+
*/
|
|
81
|
+
export async function materializeAndActivate(
|
|
82
|
+
manifest: ProfileManifest,
|
|
83
|
+
profileId: string,
|
|
84
|
+
projectPath: string,
|
|
85
|
+
/** Called as each artifact moves; defaults to printing it. */
|
|
86
|
+
onDisplace: (artifact: DisplacedArtifact) => void = reportDisplaced(
|
|
87
|
+
projectPath,
|
|
88
|
+
),
|
|
89
|
+
): Promise<ActivationResult> {
|
|
90
|
+
const closure = await resolveProfile(manifest, profileId, {
|
|
91
|
+
binResolver: noBins,
|
|
92
|
+
});
|
|
93
|
+
const union = await resolveAllProfiles(manifest, { binResolver: noBins });
|
|
94
|
+
|
|
95
|
+
await materializeProfile(
|
|
96
|
+
profileId,
|
|
97
|
+
closure,
|
|
98
|
+
projectPath,
|
|
99
|
+
Object.keys(union.plugins),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await ensureGitignoreEntries(
|
|
103
|
+
path.join(projectPath, ".gitignore"),
|
|
104
|
+
PROFILE_GITIGNORE_ENTRIES,
|
|
105
|
+
"# magus: generated profile build output + active-profile symlinks",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const displaced = await activateProfile(profileId, projectPath, onDisplace);
|
|
109
|
+
return { profileId, displaced };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The two ways a project can be put onto a profile. */
|
|
113
|
+
export type ProfileChoice =
|
|
114
|
+
| { kind: "adopt"; id: string }
|
|
115
|
+
| { kind: "activate"; id: string };
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Carry out the user's choice, whichever surface asked.
|
|
119
|
+
*
|
|
120
|
+
* One implementation because "migrate" is two writes that must not come apart —
|
|
121
|
+
* write the manifest, then link the project to it — and a second copy of that
|
|
122
|
+
* pairing in the other surface is how one of them ends up writing the manifest
|
|
123
|
+
* and stopping, which is the defect this whole area was fixed for.
|
|
124
|
+
*/
|
|
125
|
+
export async function applyProfileChoice(
|
|
126
|
+
choice: ProfileChoice,
|
|
127
|
+
manifest: ProfileManifest,
|
|
128
|
+
projectPath: string,
|
|
129
|
+
onDisplace?: (artifact: DisplacedArtifact) => void,
|
|
130
|
+
): Promise<ActivationResult> {
|
|
131
|
+
if (choice.kind === "activate") {
|
|
132
|
+
return materializeAndActivate(manifest, choice.id, projectPath, onDisplace);
|
|
133
|
+
}
|
|
134
|
+
const result = await adoptCurrentProject(
|
|
135
|
+
projectPath,
|
|
136
|
+
undefined,
|
|
137
|
+
manifest,
|
|
138
|
+
choice.id,
|
|
139
|
+
);
|
|
140
|
+
await writeManifest(result.manifest, projectPath);
|
|
141
|
+
return materializeAndActivate(
|
|
142
|
+
result.manifest,
|
|
143
|
+
choice.id,
|
|
144
|
+
projectPath,
|
|
145
|
+
onDisplace,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -30,6 +30,21 @@ import { withDerivedMarketplaces } from "./resolver.js";
|
|
|
30
30
|
/** The profile id used when adopting a project that has no manifest. */
|
|
31
31
|
export const ADOPTED_PROFILE_ID = "default";
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* First free profile id, so adopting never overwrites an existing profile.
|
|
35
|
+
*
|
|
36
|
+
* Lives here rather than at a call site because both surfaces adopt now — the
|
|
37
|
+
* CLI prompt and the TUI's startup offer — and two copies of "which id is
|
|
38
|
+
* free?" is two chances to overwrite somebody's profile.
|
|
39
|
+
*/
|
|
40
|
+
export function freeProfileId(manifest: ProfileManifest): string {
|
|
41
|
+
if (!manifest.profiles[ADOPTED_PROFILE_ID]) return ADOPTED_PROFILE_ID;
|
|
42
|
+
for (let n = 2; ; n++) {
|
|
43
|
+
const id = `${ADOPTED_PROFILE_ID}-${n}`;
|
|
44
|
+
if (!manifest.profiles[id]) return id;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
export interface AdoptInput {
|
|
34
49
|
/** Union of enabled plugins across every scope: id -> enabled. */
|
|
35
50
|
enabledPlugins: Record<string, boolean>;
|
|
@@ -147,7 +162,7 @@ export type AdoptSource = "project" | "user";
|
|
|
147
162
|
* everything globally would otherwise have nothing to adopt. The caller is told
|
|
148
163
|
* which source was used and says so, so the choice is never invisible.
|
|
149
164
|
*/
|
|
150
|
-
async function collectEnabledPlugins(
|
|
165
|
+
export async function collectEnabledPlugins(
|
|
151
166
|
projectPath: string,
|
|
152
167
|
): Promise<{ plugins: Record<string, boolean>; source: AdoptSource }> {
|
|
153
168
|
const project = await getEnabledPlugins(projectPath).catch(() => ({}));
|
|
@@ -165,6 +180,19 @@ async function collectEnabledPlugins(
|
|
|
165
180
|
export async function adoptCurrentProject(
|
|
166
181
|
projectPath: string,
|
|
167
182
|
name?: string,
|
|
183
|
+
/**
|
|
184
|
+
* Manifest to add the adopted profile to. Omitted, adoption starts from an
|
|
185
|
+
* empty manifest — correct for a repo that has none.
|
|
186
|
+
*
|
|
187
|
+
* This parameter is what makes adoption reachable twice. Without it the
|
|
188
|
+
* function always returned `emptyManifest()` plus one profile, so calling
|
|
189
|
+
* it on a repo that HAD profiles would have deleted them, and the zero-
|
|
190
|
+
* profile gate in `ensureManifest` was the only thing preventing that.
|
|
191
|
+
* Existing profiles are preserved untouched; only `id` is written.
|
|
192
|
+
*/
|
|
193
|
+
into?: ProfileManifest,
|
|
194
|
+
/** Profile id to write. Defaults to the adopted-profile id. */
|
|
195
|
+
id: string = ADOPTED_PROFILE_ID,
|
|
168
196
|
): Promise<AdoptResult> {
|
|
169
197
|
const { plugins: enabledPlugins, source } =
|
|
170
198
|
await collectEnabledPlugins(projectPath);
|
|
@@ -181,8 +209,26 @@ export async function adoptCurrentProject(
|
|
|
181
209
|
name,
|
|
182
210
|
});
|
|
183
211
|
|
|
184
|
-
|
|
185
|
-
|
|
212
|
+
// Copy rather than mutate the caller's manifest: this function does not
|
|
213
|
+
// write, and a caller that declines the plan must be left with the file it
|
|
214
|
+
// had, not a version already carrying the profile it refused.
|
|
215
|
+
const manifest: ProfileManifest = into
|
|
216
|
+
? { ...into, profiles: { ...into.profiles } }
|
|
217
|
+
: emptyManifest();
|
|
218
|
+
manifest.profiles[id] = entry;
|
|
219
|
+
|
|
220
|
+
// Record which profile the project wants, but ONLY when nothing has said yet.
|
|
221
|
+
//
|
|
222
|
+
// Writing it on a first adoption is the point: the manifest is committed, and
|
|
223
|
+
// a teammate who clones it should get this profile without being asked. It is
|
|
224
|
+
// the answer the repo now has.
|
|
225
|
+
//
|
|
226
|
+
// Overwriting an existing `default` would be different — one developer saving
|
|
227
|
+
// their local state would republish the team's committed choice as whatever
|
|
228
|
+
// they happened to have on. The new profile still becomes active for them,
|
|
229
|
+
// because activation is per-developer; the declared intent is not theirs to
|
|
230
|
+
// change by accident.
|
|
231
|
+
if (manifest.default === undefined) manifest.default = id;
|
|
186
232
|
|
|
187
233
|
const skipped = Object.entries(enabledPlugins)
|
|
188
234
|
.filter(([id, on]) => on && !id.includes("@"))
|
|
@@ -190,7 +236,7 @@ export async function adoptCurrentProject(
|
|
|
190
236
|
|
|
191
237
|
return {
|
|
192
238
|
manifest,
|
|
193
|
-
profileId:
|
|
239
|
+
profileId: id,
|
|
194
240
|
entry,
|
|
195
241
|
source,
|
|
196
242
|
counts: {
|