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.
Files changed (33) hide show
  1. package/package.json +5 -4
  2. package/src/__tests__/profile-absorb.test.ts +72 -0
  3. package/src/__tests__/profile-dependencies.test.ts +442 -0
  4. package/src/__tests__/profile-manifest-write.test.ts +231 -0
  5. package/src/__tests__/profile-skills.test.ts +99 -0
  6. package/src/__tests__/profile-state.test.ts +328 -13
  7. package/src/__tests__/stale-load-repair.test.ts +265 -0
  8. package/src/__tests__/support/preload.ts +14 -0
  9. package/src/__tests__/update-apply.test.ts +342 -1
  10. package/src/cli/bootstrap.ts +4 -0
  11. package/src/cli/doctor.ts +39 -6
  12. package/src/cli/install.ts +68 -4
  13. package/src/cli/router.ts +7 -0
  14. package/src/cli/update.ts +3 -0
  15. package/src/services/claude-cli.ts +24 -0
  16. package/src/services/claude-settings.ts +45 -0
  17. package/src/services/git-worktree.ts +92 -9
  18. package/src/services/manifest.ts +220 -3
  19. package/src/services/plugin-dependencies.ts +48 -0
  20. package/src/services/plugin-requires.ts +137 -14
  21. package/src/services/profile-absorb.ts +69 -15
  22. package/src/services/profile-activate.ts +179 -33
  23. package/src/services/profile-live.ts +66 -7
  24. package/src/services/profile-render.ts +73 -6
  25. package/src/services/profile-skills.ts +32 -7
  26. package/src/services/profile-state.ts +89 -5
  27. package/src/services/profiles.ts +6 -5
  28. package/src/services/resolver.ts +96 -3
  29. package/src/services/tracked-live-files.ts +125 -0
  30. package/src/services/update-engine.ts +266 -8
  31. package/src/types/index.ts +19 -0
  32. package/src/ui/screens/PluginsScreen.tsx +11 -2
  33. package/src/ui/screens/ProfilesScreen.tsx +10 -3
@@ -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
- ): Promise<void> {
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) await deps.update(pluginId, scope);
275
- else await deps.install(pluginId, scope);
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(deps, item.pluginId, scope, projectPath);
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
- if (noMove) {
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
- `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`,
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) => advanceInScope(deps, id, sc, projectPath),
796
+ async (id, sc) => {
797
+ await advanceInScope(deps, id, sc, projectPath);
798
+ },
541
799
  pluginId,
542
800
  scope,
543
801
  projectPath,
@@ -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("Error", `Failed: ${error}`, "error");
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("Error", `Failed: ${error}`, "error");
1210
+ await modal.message(
1211
+ "Error",
1212
+ error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
1213
+ "error",
1214
+ );
1206
1215
  }
1207
1216
  };
1208
1217