magus-cli 7.5.0 → 7.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 +105 -0
- package/src/__tests__/support/preload.ts +14 -0
- package/src/__tests__/update-apply.test.ts +145 -0
- 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 +27 -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 +87 -2
- 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
|
+
}
|
|
@@ -42,9 +42,11 @@ import type { PluginScope } from "./claude-cli.js";
|
|
|
42
42
|
import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
|
|
43
43
|
import {
|
|
44
44
|
type StaleLoadRepairResult,
|
|
45
|
+
loadedVersionHere,
|
|
45
46
|
repairStaleLoad,
|
|
46
47
|
} from "./claude-settings.js";
|
|
47
48
|
import {
|
|
49
|
+
isRepairablyBehind,
|
|
48
50
|
readInstalledVersionForScope,
|
|
49
51
|
saveInstalledPluginVersionForScope,
|
|
50
52
|
} from "./plugin-manager.js";
|
|
@@ -181,6 +183,15 @@ export interface PluginApplyDeps {
|
|
|
181
183
|
target: string,
|
|
182
184
|
projectPath: string,
|
|
183
185
|
) => Promise<StaleLoadRepairResult>;
|
|
186
|
+
/**
|
|
187
|
+
* The version Claude Code's loader resolves for a plugin in a project — the
|
|
188
|
+
* question an install must ask afterwards (see {@link bringLoadForward}).
|
|
189
|
+
* Optional so a test double that does not model the loader skips the check.
|
|
190
|
+
*/
|
|
191
|
+
loadedVersion?: (
|
|
192
|
+
pluginId: string,
|
|
193
|
+
projectPath: string,
|
|
194
|
+
) => Promise<string | null>;
|
|
184
195
|
}
|
|
185
196
|
|
|
186
197
|
export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
@@ -190,8 +201,47 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
|
190
201
|
readInstalled: readInstalledVersionForScope,
|
|
191
202
|
saveInstalled: saveInstalledPluginVersionForScope,
|
|
192
203
|
repairStaleLoad,
|
|
204
|
+
loadedVersion: loadedVersionHere,
|
|
193
205
|
};
|
|
194
206
|
|
|
207
|
+
/**
|
|
208
|
+
* After a plugin landed at `target` for `projectPath`: make sure that is the
|
|
209
|
+
* version Claude Code actually loads there, and bring the loader forward when
|
|
210
|
+
* it is not.
|
|
211
|
+
*
|
|
212
|
+
* The loader takes the FIRST registry row relevant to the repository, and rows
|
|
213
|
+
* from this repository's other checkouts — deleted worktrees included — often
|
|
214
|
+
* precede this project's own row. `claude plugin install|update` writes only
|
|
215
|
+
* this project's row, so a successful update could leave the plugin showing
|
|
216
|
+
* "updated" and loading the old version at once (measured: an update wrote
|
|
217
|
+
* 2.0.5 to this project's row while eight earlier rows held 2.0.4, and
|
|
218
|
+
* `--debug-file` showed 2.0.4 loading). So the update runs the stale-load
|
|
219
|
+
* repair as its own last step — the same `repairStaleLoad` the `stale-load`
|
|
220
|
+
* state uses, moving this repository's older rows forward to `target`.
|
|
221
|
+
*
|
|
222
|
+
* Null when nothing needed doing (the loader already resolves `target`, or the
|
|
223
|
+
* dep that asks it is absent); otherwise the repair's result, which the caller
|
|
224
|
+
* reports — `ok: false` means the plugin still does not load `target`.
|
|
225
|
+
*/
|
|
226
|
+
export async function bringLoadForward(
|
|
227
|
+
pluginId: string,
|
|
228
|
+
target: string,
|
|
229
|
+
projectPath: string,
|
|
230
|
+
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
231
|
+
): Promise<StaleLoadRepairResult | null> {
|
|
232
|
+
if (!deps.loadedVersion) return null;
|
|
233
|
+
const loaded = await deps.loadedVersion(pluginId, projectPath);
|
|
234
|
+
if (!isRepairablyBehind(target, loaded)) return null;
|
|
235
|
+
return deps.repairStaleLoad(pluginId, target, projectPath);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The words for a load brought forward, appended to an item's detail. */
|
|
239
|
+
function describeLoadForward(outcome: StaleLoadRepairResult): string {
|
|
240
|
+
return `Claude Code now loads v${outcome.target} here (${outcome.rewritten} older record${
|
|
241
|
+
outcome.rewritten === 1 ? "" : "s"
|
|
242
|
+
} moved forward)`;
|
|
243
|
+
}
|
|
244
|
+
|
|
195
245
|
// -- selection ---------------------------------------------------------------
|
|
196
246
|
|
|
197
247
|
/**
|
|
@@ -448,7 +498,28 @@ export async function applyPlugins(
|
|
|
448
498
|
}
|
|
449
499
|
}
|
|
450
500
|
const noMove = pluginNoMove(item, landed);
|
|
451
|
-
|
|
501
|
+
// What landed here — the project row when there is one, since that is
|
|
502
|
+
// the row the loader must end up agreeing with.
|
|
503
|
+
const here =
|
|
504
|
+
landed.find(([scope]) => scope === "project")?.[1] ??
|
|
505
|
+
landed.find(([, actual]) => actual)?.[1] ??
|
|
506
|
+
null;
|
|
507
|
+
const forward = here
|
|
508
|
+
? await bringLoadForward(item.pluginId, here, projectPath, deps)
|
|
509
|
+
: null;
|
|
510
|
+
if (forward && !forward.ok) {
|
|
511
|
+
// Installed, but not what loads: that is not "updated". Reported as
|
|
512
|
+
// a failure with the repair's own reason, so nothing says current.
|
|
513
|
+
report.finish(
|
|
514
|
+
false,
|
|
515
|
+
item.pluginId,
|
|
516
|
+
`installed v${here}, but Claude Code still loads v${
|
|
517
|
+
forward.loaded ?? "an older version"
|
|
518
|
+
} here — ${forward.error ?? "the older records could not be moved forward"}`,
|
|
519
|
+
Date.now() - startedAt,
|
|
520
|
+
);
|
|
521
|
+
result.failed.push(item.pluginId);
|
|
522
|
+
} else if (noMove && !forward) {
|
|
452
523
|
report.finish(
|
|
453
524
|
true,
|
|
454
525
|
item.pluginId,
|
|
@@ -457,10 +528,11 @@ export async function applyPlugins(
|
|
|
457
528
|
);
|
|
458
529
|
result.unchanged.push(item.pluginId);
|
|
459
530
|
} else {
|
|
531
|
+
const moved = `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`;
|
|
460
532
|
report.finish(
|
|
461
533
|
true,
|
|
462
534
|
item.pluginId,
|
|
463
|
-
`${
|
|
535
|
+
forward ? `${moved} · ${describeLoadForward(forward)}` : moved,
|
|
464
536
|
Date.now() - startedAt,
|
|
465
537
|
);
|
|
466
538
|
result.ok++;
|
|
@@ -499,6 +571,19 @@ async function applyOneInScope(
|
|
|
499
571
|
await run(pluginId, scope);
|
|
500
572
|
const actual = await deps.readInstalled(pluginId, scope, projectPath);
|
|
501
573
|
if (actual) await deps.saveInstalled(pluginId, actual, scope, projectPath);
|
|
574
|
+
// The same last step as the apply loop, at every scope: installed must mean
|
|
575
|
+
// loaded here. `repairStaleLoad` itself refuses — and says why — when the
|
|
576
|
+
// winning record is not one this repository's rows can move.
|
|
577
|
+
if (actual) {
|
|
578
|
+
const forward = await bringLoadForward(pluginId, actual, projectPath, deps);
|
|
579
|
+
if (forward && !forward.ok) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`installed v${actual}, but Claude Code still loads v${
|
|
582
|
+
forward.loaded ?? "an older version"
|
|
583
|
+
} here — ${forward.error ?? "the older records could not be moved forward"}`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
502
587
|
return actual;
|
|
503
588
|
}
|
|
504
589
|
|
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
|
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
renameProfile,
|
|
27
27
|
} from "../../services/profiles.js";
|
|
28
28
|
import { writableMap } from "../../services/settings-file.js";
|
|
29
|
+
import { LiveFileRefusal } from "../../services/tracked-live-files.js";
|
|
29
30
|
import {
|
|
30
31
|
ClipboardUnavailableError,
|
|
31
32
|
readClipboard,
|
|
@@ -177,7 +178,9 @@ export function ProfilesScreen() {
|
|
|
177
178
|
modal.hideModal();
|
|
178
179
|
await modal.message(
|
|
179
180
|
"Error",
|
|
180
|
-
|
|
181
|
+
error instanceof LiveFileRefusal
|
|
182
|
+
? error.message
|
|
183
|
+
: `Failed to apply profile: ${error}`,
|
|
181
184
|
"error",
|
|
182
185
|
);
|
|
183
186
|
}
|
|
@@ -242,7 +245,9 @@ export function ProfilesScreen() {
|
|
|
242
245
|
modal.hideModal();
|
|
243
246
|
await modal.message(
|
|
244
247
|
"Error",
|
|
245
|
-
|
|
248
|
+
error instanceof LiveFileRefusal
|
|
249
|
+
? error.message
|
|
250
|
+
: `Failed to switch profile: ${error}`,
|
|
246
251
|
"error",
|
|
247
252
|
);
|
|
248
253
|
}
|
|
@@ -275,7 +280,9 @@ export function ProfilesScreen() {
|
|
|
275
280
|
modal.hideModal();
|
|
276
281
|
await modal.message(
|
|
277
282
|
"Error",
|
|
278
|
-
|
|
283
|
+
error instanceof LiveFileRefusal
|
|
284
|
+
? error.message
|
|
285
|
+
: `Failed to apply profile: ${error}`,
|
|
279
286
|
"error",
|
|
280
287
|
);
|
|
281
288
|
}
|