magus-cli 7.5.0 → 7.5.2
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 +5 -4
- package/src/__tests__/profile-absorb.test.ts +72 -0
- package/src/__tests__/profile-dependencies.test.ts +442 -0
- package/src/__tests__/profile-manifest-write.test.ts +231 -0
- package/src/__tests__/profile-skills.test.ts +99 -0
- package/src/__tests__/profile-state.test.ts +328 -13
- package/src/__tests__/stale-load-repair.test.ts +265 -0
- package/src/__tests__/support/preload.ts +14 -0
- package/src/__tests__/update-apply.test.ts +342 -1
- package/src/cli/bootstrap.ts +4 -0
- package/src/cli/doctor.ts +39 -6
- package/src/cli/install.ts +68 -4
- package/src/cli/router.ts +7 -0
- package/src/cli/update.ts +3 -0
- package/src/services/claude-cli.ts +24 -0
- package/src/services/claude-settings.ts +45 -0
- package/src/services/git-worktree.ts +92 -9
- package/src/services/manifest.ts +220 -3
- package/src/services/plugin-dependencies.ts +48 -0
- package/src/services/plugin-requires.ts +137 -14
- package/src/services/profile-absorb.ts +69 -15
- package/src/services/profile-activate.ts +179 -33
- package/src/services/profile-live.ts +66 -7
- package/src/services/profile-render.ts +73 -6
- package/src/services/profile-skills.ts +32 -7
- package/src/services/profile-state.ts +89 -5
- package/src/services/profiles.ts +6 -5
- package/src/services/resolver.ts +96 -3
- package/src/services/tracked-live-files.ts +125 -0
- package/src/services/update-engine.ts +266 -8
- package/src/types/index.ts +19 -0
- package/src/ui/screens/PluginsScreen.tsx +11 -2
- package/src/ui/screens/ProfilesScreen.tsx +10 -3
package/src/services/resolver.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* marketplaces, plugins, MCP servers, binary deps, skills, settings, and env a
|
|
6
6
|
* machine must have to match the profile. Binary deps are the union of the
|
|
7
7
|
* profile's own `cliTools` and every enabled plugin's declared `requires.bin`,
|
|
8
|
-
* deduped by executable name with the project's pin winning.
|
|
8
|
+
* deduped by executable name with the project's pin winning. Plugins include
|
|
9
|
+
* every plugin an enabled one declares as a dependency, transitively.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import semver from "semver";
|
|
@@ -14,12 +15,17 @@ import { getMarketplaceByName } from "../data/marketplaces.js";
|
|
|
14
15
|
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
15
16
|
import type {
|
|
16
17
|
BinRequirement,
|
|
18
|
+
DependencyReport,
|
|
17
19
|
ProfileManifest,
|
|
18
20
|
ProfileManifestEntry,
|
|
19
21
|
ResolvedBin,
|
|
20
22
|
ResolvedClosure,
|
|
21
23
|
} from "../types/index.js";
|
|
22
24
|
import { readManifest } from "./manifest.js";
|
|
25
|
+
import {
|
|
26
|
+
type PluginDependencyReader,
|
|
27
|
+
readPluginDependencies,
|
|
28
|
+
} from "./plugin-dependencies.js";
|
|
23
29
|
import { resolvePluginBinRequirements } from "./plugin-requires.js";
|
|
24
30
|
import { activeProfile } from "./profile-live.js";
|
|
25
31
|
|
|
@@ -29,6 +35,61 @@ export interface ResolveOptions {
|
|
|
29
35
|
* tests; defaults to reading plugin.json from the marketplace cache.
|
|
30
36
|
*/
|
|
31
37
|
binResolver?: (pluginId: string) => Promise<BinRequirement[]>;
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a plugin id to the plugin ids its plugin.json declares as
|
|
40
|
+
* dependencies, or null when it cannot be read. Defaults to reading the
|
|
41
|
+
* plugin.json on this machine (`readPluginDependencies`).
|
|
42
|
+
*/
|
|
43
|
+
dependencyResolver?: PluginDependencyReader;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every plugin the `enabled` plugins depend on, transitively, as their own
|
|
48
|
+
* plugin.json files declare it.
|
|
49
|
+
*
|
|
50
|
+
* Claude Code refuses to load a plugin whose dependency is not enabled, so a
|
|
51
|
+
* dependency is part of what a profile enables — derived, never written into
|
|
52
|
+
* profiles.json. A dependency the profile itself turns off (`off`) is left
|
|
53
|
+
* off and reported as `blocked`; nothing it depends on is followed. A plugin
|
|
54
|
+
* whose plugin.json cannot be read is reported as `unreadable` rather than
|
|
55
|
+
* guessed at.
|
|
56
|
+
*/
|
|
57
|
+
export async function resolveDependencies(
|
|
58
|
+
enabled: readonly string[],
|
|
59
|
+
off: ReadonlySet<string>,
|
|
60
|
+
read: PluginDependencyReader,
|
|
61
|
+
): Promise<DependencyReport> {
|
|
62
|
+
const derived: string[] = [];
|
|
63
|
+
const unreadable: string[] = [];
|
|
64
|
+
const blocked: DependencyReport["blocked"] = [];
|
|
65
|
+
const queue = [...enabled];
|
|
66
|
+
const seen = new Set(enabled);
|
|
67
|
+
for (let id = queue.shift(); id !== undefined; id = queue.shift()) {
|
|
68
|
+
const dependencies = await read(id);
|
|
69
|
+
if (dependencies === null) {
|
|
70
|
+
unreadable.push(id);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const dependency of dependencies) {
|
|
74
|
+
if (off.has(dependency)) {
|
|
75
|
+
blocked.push({ plugin: id, dependency });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (seen.has(dependency)) continue;
|
|
79
|
+
seen.add(dependency);
|
|
80
|
+
derived.push(dependency);
|
|
81
|
+
queue.push(dependency);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { derived: derived.sort(), unreadable: unreadable.sort(), blocked };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasDependencies(report: DependencyReport): boolean {
|
|
88
|
+
return (
|
|
89
|
+
report.derived.length > 0 ||
|
|
90
|
+
report.unreadable.length > 0 ||
|
|
91
|
+
report.blocked.length > 0
|
|
92
|
+
);
|
|
32
93
|
}
|
|
33
94
|
|
|
34
95
|
/** Normalize a manifest plugins map to pluginId -> version ("latest" for true). */
|
|
@@ -234,16 +295,25 @@ export async function resolveProfile(
|
|
|
234
295
|
const binResolver = opts.binResolver ?? resolvePluginBinRequirements;
|
|
235
296
|
|
|
236
297
|
const plugins = normalizePlugins(entry.plugins);
|
|
237
|
-
const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
|
|
238
|
-
|
|
239
298
|
const disabledPlugins = Object.entries(entry.plugins ?? {})
|
|
240
299
|
.filter(([, ver]) => ver === false)
|
|
241
300
|
.map(([id]) => id);
|
|
242
301
|
|
|
302
|
+
// The plugins the enabled ones need in order to load at all.
|
|
303
|
+
const dependencies = await resolveDependencies(
|
|
304
|
+
Object.keys(plugins),
|
|
305
|
+
new Set(disabledPlugins),
|
|
306
|
+
opts.dependencyResolver ?? readPluginDependencies,
|
|
307
|
+
);
|
|
308
|
+
for (const id of dependencies.derived) plugins[id] = "latest";
|
|
309
|
+
|
|
310
|
+
const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
|
|
311
|
+
|
|
243
312
|
return {
|
|
244
313
|
marketplaces: withDerivedMarketplaces(entry.marketplaces, plugins),
|
|
245
314
|
plugins,
|
|
246
315
|
...(disabledPlugins.length > 0 ? { disabledPlugins } : {}),
|
|
316
|
+
...(hasDependencies(dependencies) ? { dependencies } : {}),
|
|
247
317
|
mcpServers: { ...(entry.mcpServers ?? {}) },
|
|
248
318
|
bins,
|
|
249
319
|
skills: [...(entry.skills ?? [])],
|
|
@@ -333,6 +403,9 @@ function mergeClosures(
|
|
|
333
403
|
}
|
|
334
404
|
}
|
|
335
405
|
|
|
406
|
+
// Dependency reports: union, for `install` and `doctor` to report.
|
|
407
|
+
const dependencies = mergeDependencyReports(a.dependencies, b.dependencies);
|
|
408
|
+
|
|
336
409
|
// Skills: union by repo+path.
|
|
337
410
|
const skillKey = (s: { repo: string; path: string }) => `${s.repo}#${s.path}`;
|
|
338
411
|
const skillMap = new Map(b.skills.map((s) => [skillKey(s), s]));
|
|
@@ -353,6 +426,26 @@ function mergeClosures(
|
|
|
353
426
|
optional: [...new Set([...a.env.optional, ...b.env.optional])],
|
|
354
427
|
},
|
|
355
428
|
...(conflicts.length > 0 ? { conflicts: [...new Set(conflicts)] } : {}),
|
|
429
|
+
...(dependencies ? { dependencies } : {}),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function mergeDependencyReports(
|
|
434
|
+
a: DependencyReport | undefined,
|
|
435
|
+
b: DependencyReport | undefined,
|
|
436
|
+
): DependencyReport | undefined {
|
|
437
|
+
if (!a) return b;
|
|
438
|
+
if (!b) return a;
|
|
439
|
+
const blocked = new Map(
|
|
440
|
+
[...a.blocked, ...b.blocked].map((x) => [
|
|
441
|
+
`${x.plugin}\u0000${x.dependency}`,
|
|
442
|
+
x,
|
|
443
|
+
]),
|
|
444
|
+
);
|
|
445
|
+
return {
|
|
446
|
+
derived: [...new Set([...a.derived, ...b.derived])].sort(),
|
|
447
|
+
unreadable: [...new Set([...a.unreadable, ...b.unreadable])].sort(),
|
|
448
|
+
blocked: [...blocked.values()],
|
|
356
449
|
};
|
|
357
450
|
}
|
|
358
451
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* magus never writes a generated file that git tracks.
|
|
3
|
+
*
|
|
4
|
+
* In a project with profiles, `.claude/settings.json`, `.mcp.json` and
|
|
5
|
+
* `.claude/models.json` are generated per developer from `.claude/profiles.json`.
|
|
6
|
+
* A committed copy is the team's hand-kept file: writing it leaves git
|
|
7
|
+
* tracking a file that now differs per developer, so every write shows as a
|
|
8
|
+
* modified tracked file and one developer's plugin set gets committed for
|
|
9
|
+
* everyone. `.gitignore` cannot fix that — it does not untrack a file already
|
|
10
|
+
* committed. So every writer refuses, having written nothing, and says how to
|
|
11
|
+
* stop tracking it; the user decides.
|
|
12
|
+
*
|
|
13
|
+
* Activation checks every path it would write up front (`profile-activate.ts`).
|
|
14
|
+
* This module holds the refusal itself, and the check for the writers outside
|
|
15
|
+
* activation: the TUI's direct settings edits, `.mcp.json` edits, and the
|
|
16
|
+
* Claude CLI's project-scope installs, which write `.claude/settings.json`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { GitQueryError, trackedByGit } from "./git-worktree.js";
|
|
21
|
+
import { getManifestPath, readProfilesFile } from "./manifest.js";
|
|
22
|
+
|
|
23
|
+
/** magus refused to write a live file, and changed nothing. */
|
|
24
|
+
export class LiveFileRefusal extends Error {}
|
|
25
|
+
|
|
26
|
+
/** A live file magus would write is tracked by git. */
|
|
27
|
+
export class TrackedLiveFileError extends LiveFileRefusal {
|
|
28
|
+
constructor(readonly paths: readonly string[]) {
|
|
29
|
+
const one = paths.length === 1;
|
|
30
|
+
super(
|
|
31
|
+
[
|
|
32
|
+
`magus will not write ${one ? "a file" : "files"} git tracks in this project — ${
|
|
33
|
+
one ? "it is" : "they are"
|
|
34
|
+
} generated from .claude/profiles.json and ${one ? "differs" : "differ"} per developer:`,
|
|
35
|
+
...paths.map((p) => ` ${p}`),
|
|
36
|
+
`Stop tracking ${one ? "it" : "them"}, commit, then run the command again:`,
|
|
37
|
+
...paths.map((p) => ` ${untrackCommand(p)}`),
|
|
38
|
+
' git commit -m "Stop tracking generated Claude Code config"',
|
|
39
|
+
"Nothing was changed.",
|
|
40
|
+
].join("\n"),
|
|
41
|
+
);
|
|
42
|
+
this.name = "TrackedLiveFileError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The project is a git work tree, but git could not say what it tracks — it
|
|
48
|
+
* timed out, is missing, or refused the repository (`detected dubious
|
|
49
|
+
* ownership`). Answering "nothing is tracked" would let magus write a tracked
|
|
50
|
+
* file, so it refuses instead and passes on git's own words.
|
|
51
|
+
*/
|
|
52
|
+
export class GitCheckError extends LiveFileRefusal {
|
|
53
|
+
constructor(readonly detail: string) {
|
|
54
|
+
super(
|
|
55
|
+
[
|
|
56
|
+
"magus could not check which files git tracks in this project, so it wrote nothing. git said:",
|
|
57
|
+
...detail
|
|
58
|
+
.trim()
|
|
59
|
+
.split("\n")
|
|
60
|
+
.map((line) => ` ${line}`),
|
|
61
|
+
"Fix that, then run the command again. Nothing was changed.",
|
|
62
|
+
].join("\n"),
|
|
63
|
+
);
|
|
64
|
+
this.name = "GitCheckError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** How to untrack one generated path — `-r` for a skill folder. */
|
|
69
|
+
export function untrackCommand(rel: string): string {
|
|
70
|
+
const isDir =
|
|
71
|
+
rel.split(path.sep).length > 2 &&
|
|
72
|
+
rel.startsWith(path.join(".claude", "skills") + path.sep);
|
|
73
|
+
return isDir ? `git rm --cached -r ${rel}` : `git rm --cached ${rel}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Which of `files` (exact entries) and `dirs` (any file under them) git
|
|
78
|
+
* tracks, relative to `projectPath`. Empty outside a git work tree; throws
|
|
79
|
+
* {@link GitCheckError} when inside one and git cannot answer.
|
|
80
|
+
*/
|
|
81
|
+
export async function trackedPaths(
|
|
82
|
+
projectPath: string,
|
|
83
|
+
files: readonly string[],
|
|
84
|
+
dirs: readonly string[] = [],
|
|
85
|
+
): Promise<Set<string>> {
|
|
86
|
+
try {
|
|
87
|
+
return await trackedByGit(projectPath, files, dirs);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error instanceof GitQueryError) throw new GitCheckError(error.detail);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Does magus generate this project's live files — does its profiles.json
|
|
96
|
+
* define a profile? A profiles.json that cannot be read counts as yes: the
|
|
97
|
+
* check that follows only ever stops a write.
|
|
98
|
+
*/
|
|
99
|
+
async function hasProfiles(projectPath: string): Promise<boolean> {
|
|
100
|
+
try {
|
|
101
|
+
const file = await readProfilesFile(getManifestPath(projectPath));
|
|
102
|
+
return Object.keys(file.profiles).length > 0;
|
|
103
|
+
} catch {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Throw {@link TrackedLiveFileError} — before anything is written — when
|
|
110
|
+
* `projectPath` has profiles and git tracks any of `files` (absolute, or
|
|
111
|
+
* relative to the project). A project without profiles owns its files by
|
|
112
|
+
* hand, and writing them is not magus's to refuse.
|
|
113
|
+
*/
|
|
114
|
+
export async function refuseTrackedLiveFiles(
|
|
115
|
+
projectPath: string,
|
|
116
|
+
files: readonly string[],
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
if (!(await hasProfiles(projectPath))) return;
|
|
119
|
+
const rel = files.map((f) =>
|
|
120
|
+
path.isAbsolute(f) ? path.relative(projectPath, f) : f,
|
|
121
|
+
);
|
|
122
|
+
const tracked = await trackedPaths(projectPath, rel);
|
|
123
|
+
const hit = rel.filter((r) => tracked.has(r));
|
|
124
|
+
if (hit.length > 0) throw new TrackedLiveFileError(hit);
|
|
125
|
+
}
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import type {
|
|
34
|
+
InstalledPluginEntry,
|
|
34
35
|
ProfileSkillRef,
|
|
35
36
|
ResolvedBin,
|
|
36
37
|
SkillInfo,
|
|
@@ -42,9 +43,14 @@ import type { PluginScope } from "./claude-cli.js";
|
|
|
42
43
|
import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
|
|
43
44
|
import {
|
|
44
45
|
type StaleLoadRepairResult,
|
|
46
|
+
loadedVersionHere,
|
|
47
|
+
readInstalledPluginsRegistry,
|
|
45
48
|
repairStaleLoad,
|
|
49
|
+
rowBelongsToRepository,
|
|
46
50
|
} from "./claude-settings.js";
|
|
47
51
|
import {
|
|
52
|
+
compareVersions,
|
|
53
|
+
isRepairablyBehind,
|
|
48
54
|
readInstalledVersionForScope,
|
|
49
55
|
saveInstalledPluginVersionForScope,
|
|
50
56
|
} from "./plugin-manager.js";
|
|
@@ -181,6 +187,30 @@ export interface PluginApplyDeps {
|
|
|
181
187
|
target: string,
|
|
182
188
|
projectPath: string,
|
|
183
189
|
) => Promise<StaleLoadRepairResult>;
|
|
190
|
+
/**
|
|
191
|
+
* The version Claude Code's loader resolves for a plugin in a project — the
|
|
192
|
+
* question an install must ask afterwards (see {@link bringLoadForward}).
|
|
193
|
+
* Optional so a test double that does not model the loader skips the check.
|
|
194
|
+
*/
|
|
195
|
+
loadedVersion?: (
|
|
196
|
+
pluginId: string,
|
|
197
|
+
projectPath: string,
|
|
198
|
+
) => Promise<string | null>;
|
|
199
|
+
/**
|
|
200
|
+
* Every registry row for a plugin, read fresh — compared before and after
|
|
201
|
+
* `update` to see WHICH row Claude Code moved (see
|
|
202
|
+
* {@link followMisdirectedUpdate}). Optional so a test double that does not
|
|
203
|
+
* model the registry skips the comparison.
|
|
204
|
+
*/
|
|
205
|
+
registryRows?: (pluginId: string) => Promise<readonly InstalledPluginEntry[]>;
|
|
206
|
+
/**
|
|
207
|
+
* Is a row this project's repository's — the rows the stale-load repair may
|
|
208
|
+
* rewrite. Defaults to that repair's own predicate (`rowBelongsToRepository`).
|
|
209
|
+
*/
|
|
210
|
+
inThisRepository?: (
|
|
211
|
+
row: InstalledPluginEntry,
|
|
212
|
+
projectPath: string,
|
|
213
|
+
) => boolean;
|
|
184
214
|
}
|
|
185
215
|
|
|
186
216
|
export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
@@ -190,8 +220,168 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
|
190
220
|
readInstalled: readInstalledVersionForScope,
|
|
191
221
|
saveInstalled: saveInstalledPluginVersionForScope,
|
|
192
222
|
repairStaleLoad,
|
|
223
|
+
loadedVersion: loadedVersionHere,
|
|
224
|
+
registryRows: async (pluginId) => {
|
|
225
|
+
const rows = (await readInstalledPluginsRegistry()).plugins?.[pluginId];
|
|
226
|
+
return Array.isArray(rows) ? rows : [];
|
|
227
|
+
},
|
|
193
228
|
};
|
|
194
229
|
|
|
230
|
+
/**
|
|
231
|
+
* An update Claude Code applied to another project's row instead of this one.
|
|
232
|
+
*
|
|
233
|
+
* Claude Code 2.1.281 changed `claude plugin update P --scope project`: it
|
|
234
|
+
* updates the FIRST registry row relevant to the repository — the row its
|
|
235
|
+
* loader would pick — not the row of the project it runs in. Measured
|
|
236
|
+
* 2026-09-24 on two APFS clones of one sandbox, same registry, cwd = the
|
|
237
|
+
* project:
|
|
238
|
+
*
|
|
239
|
+
* 2.1.280 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src)
|
|
240
|
+
* 2.1.281 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src/tools/claudeup)
|
|
241
|
+
*
|
|
242
|
+
* Both exit 0; under 2.1.281 this project's row stays at 8.0.0.
|
|
243
|
+
*/
|
|
244
|
+
export interface MisdirectedUpdate {
|
|
245
|
+
/** Where Claude Code wrote instead: each moved row's project path, or scope. */
|
|
246
|
+
elsewhere: string[];
|
|
247
|
+
/** The version it moved them to. */
|
|
248
|
+
version: string;
|
|
249
|
+
/** Why this project's row did not follow to `version`; null when it did. */
|
|
250
|
+
notFollowed: string | null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One registry row's identity: scope, and the project it belongs to. */
|
|
254
|
+
function rowKey(
|
|
255
|
+
row: Pick<InstalledPluginEntry, "scope" | "projectPath">,
|
|
256
|
+
): string {
|
|
257
|
+
const where = row.projectPath ? path.resolve(row.projectPath) : "";
|
|
258
|
+
return `${row.scope}\u0000${where}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function readRows(
|
|
262
|
+
deps: PluginApplyDeps,
|
|
263
|
+
pluginId: string,
|
|
264
|
+
): Promise<readonly InstalledPluginEntry[] | null> {
|
|
265
|
+
if (!deps.registryRows) return null;
|
|
266
|
+
try {
|
|
267
|
+
return await deps.registryRows(pluginId);
|
|
268
|
+
} catch {
|
|
269
|
+
return null; // No readable registry: nothing to compare.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* After `claude plugin update P --scope <scope>`: if this project's row did not
|
|
275
|
+
* move but another row OF THIS REPOSITORY did — Claude Code 2.1.281 updating
|
|
276
|
+
* the repository's first relevant row instead (see {@link MisdirectedUpdate})
|
|
277
|
+
* — move this project's row forward to that version, with the registry
|
|
278
|
+
* rewrite the stale-load repair performs. That repair refuses a version whose
|
|
279
|
+
* cache directory does not exist.
|
|
280
|
+
*
|
|
281
|
+
* Only rows the repair itself would rewrite count (`inThisRepository`): a row
|
|
282
|
+
* of another repository that happens to move while the CLI runs says nothing
|
|
283
|
+
* about this update. And when the update's `target` is known, the row must
|
|
284
|
+
* have reached exactly that version, or it is not followed.
|
|
285
|
+
*
|
|
286
|
+
* The row Claude Code moved is left as it is: it belongs to this repository,
|
|
287
|
+
* and bringing this repository's rows forward is what the repair does anyway.
|
|
288
|
+
* Null when there was nothing to follow — this project's row moved (2.1.280),
|
|
289
|
+
* or no row of this repository moved at all.
|
|
290
|
+
*/
|
|
291
|
+
async function followMisdirectedUpdate(
|
|
292
|
+
deps: PluginApplyDeps,
|
|
293
|
+
pluginId: string,
|
|
294
|
+
scope: PluginScope,
|
|
295
|
+
projectPath: string,
|
|
296
|
+
before: readonly InstalledPluginEntry[],
|
|
297
|
+
target: string | null,
|
|
298
|
+
): Promise<MisdirectedUpdate | null> {
|
|
299
|
+
const now = await deps.readInstalled(pluginId, scope, projectPath);
|
|
300
|
+
const after = await readRows(deps, pluginId);
|
|
301
|
+
if (!now || !after) return null;
|
|
302
|
+
const inThisRepository = deps.inThisRepository ?? rowBelongsToRepository;
|
|
303
|
+
const was = new Map(before.map((row) => [rowKey(row), row.version]));
|
|
304
|
+
const moved = after.filter(
|
|
305
|
+
(row) =>
|
|
306
|
+
row.version &&
|
|
307
|
+
was.get(rowKey(row)) !== row.version &&
|
|
308
|
+
inThisRepository(row, projectPath),
|
|
309
|
+
);
|
|
310
|
+
const mine = rowKey({
|
|
311
|
+
scope,
|
|
312
|
+
projectPath: scope === "user" ? undefined : projectPath,
|
|
313
|
+
});
|
|
314
|
+
if (moved.length === 0 || moved.some((row) => rowKey(row) === mine)) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const version = moved
|
|
318
|
+
.map((row) => row.version)
|
|
319
|
+
.reduce((a, b) => ((compareVersions(b, a) ?? 0) > 0 ? b : a));
|
|
320
|
+
if (!isRepairablyBehind(version, now)) return null;
|
|
321
|
+
const elsewhere = moved.map((row) => row.projectPath ?? `${row.scope} scope`);
|
|
322
|
+
if (target && version !== target) {
|
|
323
|
+
return {
|
|
324
|
+
elsewhere,
|
|
325
|
+
version,
|
|
326
|
+
notFollowed: `it went to v${version}, not the v${target} this update asked for`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const repair = await deps.repairStaleLoad(pluginId, version, projectPath);
|
|
330
|
+
return {
|
|
331
|
+
elsewhere,
|
|
332
|
+
version,
|
|
333
|
+
notFollowed: repair.ok
|
|
334
|
+
? null
|
|
335
|
+
: (repair.error ?? "the registry was not rewritten"),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The words for a misdirected update, for an item's detail. */
|
|
340
|
+
function describeMisdirected(m: MisdirectedUpdate): string {
|
|
341
|
+
const where = m.elsewhere.join(", ");
|
|
342
|
+
return m.notFollowed === null
|
|
343
|
+
? `Claude Code updated ${where} instead of this project — moved this project's record to v${m.version} too`
|
|
344
|
+
: `Claude Code updated ${where} instead of this project, and this project's record could not follow: ${m.notFollowed}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* After a plugin landed at `target` for `projectPath`: make sure that is the
|
|
349
|
+
* version Claude Code actually loads there, and bring the loader forward when
|
|
350
|
+
* it is not.
|
|
351
|
+
*
|
|
352
|
+
* The loader takes the FIRST registry row relevant to the repository, and rows
|
|
353
|
+
* from this repository's other checkouts — deleted worktrees included — often
|
|
354
|
+
* precede this project's own row. `claude plugin install|update` writes only
|
|
355
|
+
* this project's row, so a successful update could leave the plugin showing
|
|
356
|
+
* "updated" and loading the old version at once (measured: an update wrote
|
|
357
|
+
* 2.0.5 to this project's row while eight earlier rows held 2.0.4, and
|
|
358
|
+
* `--debug-file` showed 2.0.4 loading). So the update runs the stale-load
|
|
359
|
+
* repair as its own last step — the same `repairStaleLoad` the `stale-load`
|
|
360
|
+
* state uses, moving this repository's older rows forward to `target`.
|
|
361
|
+
*
|
|
362
|
+
* Null when nothing needed doing (the loader already resolves `target`, or the
|
|
363
|
+
* dep that asks it is absent); otherwise the repair's result, which the caller
|
|
364
|
+
* reports — `ok: false` means the plugin still does not load `target`.
|
|
365
|
+
*/
|
|
366
|
+
export async function bringLoadForward(
|
|
367
|
+
pluginId: string,
|
|
368
|
+
target: string,
|
|
369
|
+
projectPath: string,
|
|
370
|
+
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
371
|
+
): Promise<StaleLoadRepairResult | null> {
|
|
372
|
+
if (!deps.loadedVersion) return null;
|
|
373
|
+
const loaded = await deps.loadedVersion(pluginId, projectPath);
|
|
374
|
+
if (!isRepairablyBehind(target, loaded)) return null;
|
|
375
|
+
return deps.repairStaleLoad(pluginId, target, projectPath);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The words for a load brought forward, appended to an item's detail. */
|
|
379
|
+
function describeLoadForward(outcome: StaleLoadRepairResult): string {
|
|
380
|
+
return `Claude Code now loads v${outcome.target} here (${outcome.rewritten} older record${
|
|
381
|
+
outcome.rewritten === 1 ? "" : "s"
|
|
382
|
+
} moved forward)`;
|
|
383
|
+
}
|
|
384
|
+
|
|
195
385
|
// -- selection ---------------------------------------------------------------
|
|
196
386
|
|
|
197
387
|
/**
|
|
@@ -269,10 +459,26 @@ async function advanceInScope(
|
|
|
269
459
|
pluginId: string,
|
|
270
460
|
scope: PluginScope,
|
|
271
461
|
projectPath: string,
|
|
272
|
-
|
|
462
|
+
/** The version this update targets, when known — see followMisdirectedUpdate. */
|
|
463
|
+
target: string | null = null,
|
|
464
|
+
): Promise<MisdirectedUpdate | null> {
|
|
273
465
|
const here = await deps.readInstalled(pluginId, scope, projectPath);
|
|
274
|
-
if (here)
|
|
275
|
-
|
|
466
|
+
if (!here) {
|
|
467
|
+
await deps.install(pluginId, scope);
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
const before = await readRows(deps, pluginId);
|
|
471
|
+
await deps.update(pluginId, scope);
|
|
472
|
+
return before
|
|
473
|
+
? followMisdirectedUpdate(
|
|
474
|
+
deps,
|
|
475
|
+
pluginId,
|
|
476
|
+
scope,
|
|
477
|
+
projectPath,
|
|
478
|
+
before,
|
|
479
|
+
target,
|
|
480
|
+
)
|
|
481
|
+
: null;
|
|
276
482
|
}
|
|
277
483
|
|
|
278
484
|
/**
|
|
@@ -399,6 +605,7 @@ export async function applyPlugins(
|
|
|
399
605
|
report.begin(item.pluginId);
|
|
400
606
|
const startedAt = Date.now();
|
|
401
607
|
const landed: Array<[PluginScope, string | null]> = [];
|
|
608
|
+
const misdirected: MisdirectedUpdate[] = [];
|
|
402
609
|
|
|
403
610
|
try {
|
|
404
611
|
for (const scope of scopes) {
|
|
@@ -417,7 +624,14 @@ export async function applyPlugins(
|
|
|
417
624
|
} else if (item.action === "install") {
|
|
418
625
|
await deps.install(item.pluginId, scope);
|
|
419
626
|
} else {
|
|
420
|
-
await advanceInScope(
|
|
627
|
+
const m = await advanceInScope(
|
|
628
|
+
deps,
|
|
629
|
+
item.pluginId,
|
|
630
|
+
scope,
|
|
631
|
+
projectPath,
|
|
632
|
+
item.target,
|
|
633
|
+
);
|
|
634
|
+
if (m) misdirected.push(m);
|
|
421
635
|
}
|
|
422
636
|
|
|
423
637
|
// Record what LANDED, not what we asked for.
|
|
@@ -448,19 +662,46 @@ export async function applyPlugins(
|
|
|
448
662
|
}
|
|
449
663
|
}
|
|
450
664
|
const noMove = pluginNoMove(item, landed);
|
|
451
|
-
|
|
665
|
+
// What landed here — the project row when there is one, since that is
|
|
666
|
+
// the row the loader must end up agreeing with.
|
|
667
|
+
const here =
|
|
668
|
+
landed.find(([scope]) => scope === "project")?.[1] ??
|
|
669
|
+
landed.find(([, actual]) => actual)?.[1] ??
|
|
670
|
+
null;
|
|
671
|
+
const forward = here
|
|
672
|
+
? await bringLoadForward(item.pluginId, here, projectPath, deps)
|
|
673
|
+
: null;
|
|
674
|
+
if (forward && !forward.ok) {
|
|
675
|
+
// Installed, but not what loads: that is not "updated". Reported as
|
|
676
|
+
// a failure with the repair's own reason, so nothing says current.
|
|
677
|
+
report.finish(
|
|
678
|
+
false,
|
|
679
|
+
item.pluginId,
|
|
680
|
+
`installed v${here}, but Claude Code still loads v${
|
|
681
|
+
forward.loaded ?? "an older version"
|
|
682
|
+
} here — ${forward.error ?? "the older records could not be moved forward"}`,
|
|
683
|
+
Date.now() - startedAt,
|
|
684
|
+
);
|
|
685
|
+
result.failed.push(item.pluginId);
|
|
686
|
+
} else if (noMove && !forward) {
|
|
687
|
+
const why = misdirected.map(describeMisdirected);
|
|
452
688
|
report.finish(
|
|
453
689
|
true,
|
|
454
690
|
item.pluginId,
|
|
455
|
-
`no change — ${noMove}`,
|
|
691
|
+
`no change — ${noMove}${why.length > 0 ? ` (${why.join("; ")})` : ""}`,
|
|
456
692
|
Date.now() - startedAt,
|
|
457
693
|
);
|
|
458
694
|
result.unchanged.push(item.pluginId);
|
|
459
695
|
} else {
|
|
696
|
+
const moved = `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`;
|
|
697
|
+
const notes = [
|
|
698
|
+
...misdirected.map(describeMisdirected),
|
|
699
|
+
...(forward ? [describeLoadForward(forward)] : []),
|
|
700
|
+
];
|
|
460
701
|
report.finish(
|
|
461
702
|
true,
|
|
462
703
|
item.pluginId,
|
|
463
|
-
|
|
704
|
+
[moved, ...notes].join(" · "),
|
|
464
705
|
Date.now() - startedAt,
|
|
465
706
|
);
|
|
466
707
|
result.ok++;
|
|
@@ -499,6 +740,19 @@ async function applyOneInScope(
|
|
|
499
740
|
await run(pluginId, scope);
|
|
500
741
|
const actual = await deps.readInstalled(pluginId, scope, projectPath);
|
|
501
742
|
if (actual) await deps.saveInstalled(pluginId, actual, scope, projectPath);
|
|
743
|
+
// The same last step as the apply loop, at every scope: installed must mean
|
|
744
|
+
// loaded here. `repairStaleLoad` itself refuses — and says why — when the
|
|
745
|
+
// winning record is not one this repository's rows can move.
|
|
746
|
+
if (actual) {
|
|
747
|
+
const forward = await bringLoadForward(pluginId, actual, projectPath, deps);
|
|
748
|
+
if (forward && !forward.ok) {
|
|
749
|
+
throw new Error(
|
|
750
|
+
`installed v${actual}, but Claude Code still loads v${
|
|
751
|
+
forward.loaded ?? "an older version"
|
|
752
|
+
} here — ${forward.error ?? "the older records could not be moved forward"}`,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
502
756
|
return actual;
|
|
503
757
|
}
|
|
504
758
|
|
|
@@ -536,8 +790,12 @@ export async function updatePluginInScope(
|
|
|
536
790
|
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
537
791
|
): Promise<string | null> {
|
|
538
792
|
// Same guard as the apply loop, for the same reason — see advanceInScope.
|
|
793
|
+
// A misdirected update is followed there; the version read back afterwards
|
|
794
|
+
// is what this returns.
|
|
539
795
|
return applyOneInScope(
|
|
540
|
-
(id, sc) =>
|
|
796
|
+
async (id, sc) => {
|
|
797
|
+
await advanceInScope(deps, id, sc, projectPath);
|
|
798
|
+
},
|
|
541
799
|
pluginId,
|
|
542
800
|
scope,
|
|
543
801
|
projectPath,
|
package/src/types/index.ts
CHANGED
|
@@ -471,4 +471,23 @@ export interface ResolvedClosure {
|
|
|
471
471
|
env: { required: string[]; optional: string[] };
|
|
472
472
|
/** Cross-profile version conflicts detected while unioning profiles. */
|
|
473
473
|
conflicts?: string[];
|
|
474
|
+
/**
|
|
475
|
+
* What the enabled plugins' own plugin.json files require — present when
|
|
476
|
+
* anything was derived or could not be read. The derived ids are already
|
|
477
|
+
* in `plugins`.
|
|
478
|
+
*/
|
|
479
|
+
dependencies?: DependencyReport;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The dependencies a profile's enabled plugins declare, resolved transitively.
|
|
484
|
+
* Derived, never recorded: profiles.json names none of them.
|
|
485
|
+
*/
|
|
486
|
+
export interface DependencyReport {
|
|
487
|
+
/** Enabled only because an enabled plugin depends on them. Sorted. */
|
|
488
|
+
derived: string[];
|
|
489
|
+
/** Enabled plugins whose plugin.json could not be read here. Sorted. */
|
|
490
|
+
unreadable: string[];
|
|
491
|
+
/** A dependency the profile itself turns off: `plugin` will not load. */
|
|
492
|
+
blocked: Array<{ plugin: string; dependency: string }>;
|
|
474
493
|
}
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
fetchPluginContents,
|
|
45
45
|
fetchPluginSkillDetail,
|
|
46
46
|
} from "../../services/skills-manager.js";
|
|
47
|
+
import { LiveFileRefusal } from "../../services/tracked-live-files.js";
|
|
47
48
|
// The one update implementation, shared with `magus update`. See the header
|
|
48
49
|
// of update-engine.ts for what drifted while the TUI had its own loop.
|
|
49
50
|
import {
|
|
@@ -982,7 +983,11 @@ export function PluginsScreen() {
|
|
|
982
983
|
fetchData();
|
|
983
984
|
} catch (error) {
|
|
984
985
|
modal.hideModal();
|
|
985
|
-
await modal.message(
|
|
986
|
+
await modal.message(
|
|
987
|
+
"Error",
|
|
988
|
+
error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
|
|
989
|
+
"error",
|
|
990
|
+
);
|
|
986
991
|
}
|
|
987
992
|
}
|
|
988
993
|
};
|
|
@@ -1202,7 +1207,11 @@ export function PluginsScreen() {
|
|
|
1202
1207
|
fetchData();
|
|
1203
1208
|
} catch (error) {
|
|
1204
1209
|
modal.hideModal();
|
|
1205
|
-
await modal.message(
|
|
1210
|
+
await modal.message(
|
|
1211
|
+
"Error",
|
|
1212
|
+
error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
|
|
1213
|
+
"error",
|
|
1214
|
+
);
|
|
1206
1215
|
}
|
|
1207
1216
|
};
|
|
1208
1217
|
|