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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.5.
|
|
3
|
+
"version": "7.5.1",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@opentui/react": "^0.1.75",
|
|
53
53
|
"chalk": "^5.3.0",
|
|
54
54
|
"fs-extra": "^11.2.0",
|
|
55
|
+
"jsonc-parser": "3.3.1",
|
|
55
56
|
"react": "^19.0.0",
|
|
56
57
|
"semver": "^7.6.3"
|
|
57
58
|
},
|
|
@@ -64,8 +65,8 @@
|
|
|
64
65
|
"typescript": "^5.6.3"
|
|
65
66
|
},
|
|
66
67
|
"optionalDependencies": {
|
|
67
|
-
"magus-cli-darwin-arm64": "7.5.
|
|
68
|
-
"magus-cli-darwin-x64": "7.5.
|
|
69
|
-
"magus-cli-linux-x64": "7.5.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.5.1",
|
|
69
|
+
"magus-cli-darwin-x64": "7.5.1",
|
|
70
|
+
"magus-cli-linux-x64": "7.5.1"
|
|
70
71
|
}
|
|
71
72
|
}
|
|
@@ -1091,6 +1091,78 @@ describe("the Claude CLI's install bookkeeping is never absorbed", () => {
|
|
|
1091
1091
|
});
|
|
1092
1092
|
});
|
|
1093
1093
|
|
|
1094
|
+
describe("`magus update` owns only the plugins it installs", () => {
|
|
1095
|
+
/** What `claude plugin install --scope project` does to the live file. */
|
|
1096
|
+
async function enable(id: string): Promise<void> {
|
|
1097
|
+
await editLive(settingsPath(), (s) => {
|
|
1098
|
+
(s.enabledPlugins as Record<string, boolean>)[id] = true;
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
/** `magus update` installs the active profile's closure — team's plugins. */
|
|
1102
|
+
const TEAM_CLOSURE = ["dev@magus", "terminal@magus"];
|
|
1103
|
+
|
|
1104
|
+
test("B5: a plugin the user enables for another profile's id during the run is kept", async () => {
|
|
1105
|
+
await start(baseManifest());
|
|
1106
|
+
const reported: string[] = [];
|
|
1107
|
+
|
|
1108
|
+
await withCliBookkeeping(
|
|
1109
|
+
project,
|
|
1110
|
+
async () => {
|
|
1111
|
+
await enable("terminal@magus"); // the CLI's own record — team already has it
|
|
1112
|
+
await enable("designer@magus"); // the user, in Claude Code, meanwhile
|
|
1113
|
+
},
|
|
1114
|
+
{ installable: TEAM_CLOSURE, report: (line) => reported.push(line) },
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
expect(reported).toEqual(['Added designer@magus to profile "team"']);
|
|
1118
|
+
expect((await entry()).plugins).toEqual({
|
|
1119
|
+
"dev@magus": "2.1.0",
|
|
1120
|
+
"terminal@magus": "latest",
|
|
1121
|
+
"designer@magus": "latest",
|
|
1122
|
+
});
|
|
1123
|
+
expect(
|
|
1124
|
+
(await fs.readJson(settingsPath())).enabledPlugins["designer@magus"],
|
|
1125
|
+
).toBe(true);
|
|
1126
|
+
const marker = await readActiveMarker(project);
|
|
1127
|
+
expect(marker?.cliBookkeeping).toBeUndefined();
|
|
1128
|
+
expect(marker?.cliInstallable).toBeUndefined();
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
test("B5: a run killed part-way uses the same set — recorded in the marker", async () => {
|
|
1132
|
+
await start(baseManifest());
|
|
1133
|
+
const marker = await readActiveMarker(project);
|
|
1134
|
+
if (!marker) throw new Error("no marker");
|
|
1135
|
+
// What withCliBookkeeping writes before the loop; `finally` never ran.
|
|
1136
|
+
await writeActiveMarker(
|
|
1137
|
+
{ ...marker, cliBookkeeping: true, cliInstallable: TEAM_CLOSURE },
|
|
1138
|
+
project,
|
|
1139
|
+
);
|
|
1140
|
+
await enable("designer@magus");
|
|
1141
|
+
|
|
1142
|
+
const result = await reconcileActiveProfile(project);
|
|
1143
|
+
|
|
1144
|
+
expect(result.lines).toEqual(['Added designer@magus to profile "team"']);
|
|
1145
|
+
expect((await entry()).plugins).toHaveProperty("designer@magus", "latest");
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
test("B5 control: a plugin the run DID install for another profile is still discarded", async () => {
|
|
1149
|
+
// `magus install` with no profile installs every profile's plugins, so
|
|
1150
|
+
// designer@magus is in its installable set and its `true` is the CLI's.
|
|
1151
|
+
await start(baseManifest());
|
|
1152
|
+
|
|
1153
|
+
await withCliBookkeeping(project, async () => enable("designer@magus"), {
|
|
1154
|
+
installable: [...TEAM_CLOSURE, "designer@magus"],
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
expect((await entry()).plugins).toEqual(
|
|
1158
|
+
baseManifest().profiles.team?.plugins as never,
|
|
1159
|
+
);
|
|
1160
|
+
expect(
|
|
1161
|
+
(await fs.readJson(settingsPath())).enabledPlugins["designer@magus"],
|
|
1162
|
+
).toBe(false);
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1094
1166
|
describe("a false left over from an older render is not a disable", () => {
|
|
1095
1167
|
test("reviewer S3: a teammate drops a plugin from another profile while an edit is pending", async () => {
|
|
1096
1168
|
const manifest: ProfileManifest = {
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B6 — a profile enables what its plugins depend on.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code will not load a plugin whose dependency is disabled. Every
|
|
5
|
+
* profile renders the plugins it does not want as `false` so switching is
|
|
6
|
+
* exclusive — and on magus-src that switched `claudish@magus` off under the
|
|
7
|
+
* `lean` profile, whose dev@magus depends on it: the loader logged
|
|
8
|
+
* `Dependency "claudish@magus" is disabled` and dev never loaded.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
12
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import fs from "fs-extra";
|
|
16
|
+
import { reportProfileState } from "../cli/doctor.js";
|
|
17
|
+
import { readManifest, writeManifest } from "../services/manifest.js";
|
|
18
|
+
import {
|
|
19
|
+
type PluginDependencyReader,
|
|
20
|
+
setPluginDependencyReader,
|
|
21
|
+
} from "../services/plugin-dependencies.js";
|
|
22
|
+
import {
|
|
23
|
+
type PluginManifestSources,
|
|
24
|
+
parsePluginDependencies,
|
|
25
|
+
readPluginManifest,
|
|
26
|
+
resolvePluginDependencies,
|
|
27
|
+
} from "../services/plugin-requires.js";
|
|
28
|
+
import { reconcileActiveProfile } from "../services/profile-absorb.js";
|
|
29
|
+
import {
|
|
30
|
+
activateProfile,
|
|
31
|
+
activationNotes,
|
|
32
|
+
} from "../services/profile-activate.js";
|
|
33
|
+
import { renderProfile } from "../services/profile-render.js";
|
|
34
|
+
import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
|
|
35
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
36
|
+
import { NO_DEPENDENCIES } from "./support/preload.js";
|
|
37
|
+
|
|
38
|
+
/** What dev@magus and multimodel@magus declare in the real marketplace. */
|
|
39
|
+
const DECLARED: Record<string, string[] | null> = {
|
|
40
|
+
"dev@magus": ["claudish@magus", "mnemex@magus", "multimodel@magus"],
|
|
41
|
+
"multimodel@magus": ["claudish@magus"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** magus-src's shape: `lean` has dev without two of its dependencies. */
|
|
45
|
+
function leanShaped(): ProfileManifest {
|
|
46
|
+
return {
|
|
47
|
+
version: 2,
|
|
48
|
+
profiles: {
|
|
49
|
+
maintainer: {
|
|
50
|
+
name: "Maintainer",
|
|
51
|
+
plugins: {
|
|
52
|
+
"dev@magus": "latest",
|
|
53
|
+
"claudish@magus": "latest",
|
|
54
|
+
"multimodel@magus": "latest",
|
|
55
|
+
"mnemex@magus": "latest",
|
|
56
|
+
"terminal@magus": "latest",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
lean: {
|
|
60
|
+
name: "Lean",
|
|
61
|
+
plugins: { "dev@magus": "latest", "mnemex@magus": "latest" },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function reader(
|
|
68
|
+
declared: Record<string, string[] | null>,
|
|
69
|
+
): PluginDependencyReader {
|
|
70
|
+
return async (id) => (id in declared ? (declared[id] ?? null) : []);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let project: string;
|
|
74
|
+
let log: ReturnType<typeof spyOn>;
|
|
75
|
+
const settingsPath = () => join(project, ".claude", "settings.json");
|
|
76
|
+
const manifestPath = () => join(project, ".claude", "profiles.json");
|
|
77
|
+
const enabled = async () =>
|
|
78
|
+
(await fs.readJson(settingsPath())).enabledPlugins as Record<string, boolean>;
|
|
79
|
+
const printed = () => log.mock.calls.map((c) => String(c[0])).join("\n");
|
|
80
|
+
|
|
81
|
+
beforeEach(async () => {
|
|
82
|
+
project = await mkdtemp(join(tmpdir(), "deps-"));
|
|
83
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
84
|
+
setPluginDependencyReader(reader(DECLARED));
|
|
85
|
+
});
|
|
86
|
+
afterEach(async () => {
|
|
87
|
+
setPluginDependencyReader(NO_DEPENDENCIES);
|
|
88
|
+
log.mockRestore();
|
|
89
|
+
await rm(project, { recursive: true, force: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
async function start(manifest: ProfileManifest, profile: string) {
|
|
93
|
+
await writeManifest(manifest, project);
|
|
94
|
+
return activateProfile(manifest, profile, project, { onDisplace: () => {} });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe("the render enables what the profile's plugins depend on", () => {
|
|
98
|
+
test("B6: lean enables claudish and multimodel for dev@magus, though maintainer lists them", async () => {
|
|
99
|
+
await start(leanShaped(), "lean");
|
|
100
|
+
|
|
101
|
+
expect(await enabled()).toEqual({
|
|
102
|
+
"dev@magus": true,
|
|
103
|
+
"mnemex@magus": true,
|
|
104
|
+
"claudish@magus": true,
|
|
105
|
+
"multimodel@magus": true,
|
|
106
|
+
"terminal@magus": false,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("B6: dependencies are followed transitively, each in its dependent's marketplace", async () => {
|
|
111
|
+
// Real plugin.json files, read by the real parser.
|
|
112
|
+
const plugins = join(project, "plugins");
|
|
113
|
+
const write = (name: string, dependencies: unknown[]) =>
|
|
114
|
+
fs.outputJson(join(plugins, name, ".claude-plugin", "plugin.json"), {
|
|
115
|
+
name,
|
|
116
|
+
dependencies,
|
|
117
|
+
});
|
|
118
|
+
await write("app", [{ name: "lib", version: "^1.0" }]);
|
|
119
|
+
await write("lib", ["core"]);
|
|
120
|
+
await write("core", [{ name: "shared", marketplace: "other" }]);
|
|
121
|
+
setPluginDependencyReader(async (id) => {
|
|
122
|
+
const manifest = await readPluginManifest(
|
|
123
|
+
join(plugins, id.slice(0, id.lastIndexOf("@"))),
|
|
124
|
+
);
|
|
125
|
+
return manifest === null ? [] : parsePluginDependencies(manifest, id);
|
|
126
|
+
});
|
|
127
|
+
const manifest: ProfileManifest = {
|
|
128
|
+
version: 2,
|
|
129
|
+
profiles: { only: { name: "Only", plugins: { "app@mkt": "latest" } } },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const closure = await resolveProfile(manifest, "only");
|
|
133
|
+
expect(closure.plugins).toEqual({
|
|
134
|
+
"app@mkt": "latest",
|
|
135
|
+
"lib@mkt": "latest",
|
|
136
|
+
"core@mkt": "latest",
|
|
137
|
+
"shared@other": "latest",
|
|
138
|
+
});
|
|
139
|
+
expect(closure.dependencies?.derived).toEqual([
|
|
140
|
+
"core@mkt",
|
|
141
|
+
"lib@mkt",
|
|
142
|
+
"shared@other",
|
|
143
|
+
]);
|
|
144
|
+
await start(manifest, "only");
|
|
145
|
+
expect(await enabled()).toEqual({
|
|
146
|
+
"app@mkt": true,
|
|
147
|
+
"lib@mkt": true,
|
|
148
|
+
"core@mkt": true,
|
|
149
|
+
"shared@other": true,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("B6: a dependency the profile itself turns off stays off, and the note names both plugins", async () => {
|
|
154
|
+
const manifest = leanShaped();
|
|
155
|
+
(manifest.profiles.lean?.plugins as Record<string, string | boolean>)[
|
|
156
|
+
"claudish@magus"
|
|
157
|
+
] = false;
|
|
158
|
+
|
|
159
|
+
const result = await start(manifest, "lean");
|
|
160
|
+
|
|
161
|
+
expect((await enabled())["claudish@magus"]).toBe(false);
|
|
162
|
+
expect((await enabled())["multimodel@magus"]).toBe(true);
|
|
163
|
+
const notes = activationNotes(result);
|
|
164
|
+
expect(notes).toContain(
|
|
165
|
+
'dev@magus will not load: it depends on claudish@magus, which profile "lean" turns off — remove "claudish@magus": false from the profile to let it load',
|
|
166
|
+
);
|
|
167
|
+
expect(notes).toContain(
|
|
168
|
+
'multimodel@magus will not load: it depends on claudish@magus, which profile "lean" turns off — remove "claudish@magus": false from the profile to let it load',
|
|
169
|
+
);
|
|
170
|
+
// doctor counts it: the plugin will not load.
|
|
171
|
+
const { unfixable } = await reportProfileState(project, false);
|
|
172
|
+
expect(unfixable).toBe(2);
|
|
173
|
+
expect(printed()).toContain("✗ dev@magus will not load");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("B6: a plugin.json that cannot be read is reported, not guessed, and activation proceeds", async () => {
|
|
177
|
+
setPluginDependencyReader(reader({ ...DECLARED, "dev@magus": null }));
|
|
178
|
+
|
|
179
|
+
const result = await start(leanShaped(), "lean");
|
|
180
|
+
|
|
181
|
+
expect((await enabled())["dev@magus"]).toBe(true);
|
|
182
|
+
expect((await enabled())["claudish@magus"]).toBe(false);
|
|
183
|
+
expect(activationNotes(result)).toContain(
|
|
184
|
+
"Could not read the plugin.json of dev@magus on this machine, so nothing it depends on was enabled — run `magus install` to fetch it",
|
|
185
|
+
);
|
|
186
|
+
// doctor reports it, and does not count it as a failure.
|
|
187
|
+
const { unfixable } = await reportProfileState(project, false);
|
|
188
|
+
expect(unfixable).toBe(0);
|
|
189
|
+
expect(printed()).toContain(
|
|
190
|
+
"⚠ Could not read the plugin.json of dev@magus",
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("B6: `magus install` and `magus update` resolve the dependencies as part of the closure", async () => {
|
|
195
|
+
const lean = await resolveProfile(leanShaped(), "lean");
|
|
196
|
+
expect(lean.plugins).toEqual({
|
|
197
|
+
"dev@magus": "latest",
|
|
198
|
+
"mnemex@magus": "latest",
|
|
199
|
+
"claudish@magus": "latest",
|
|
200
|
+
"multimodel@magus": "latest",
|
|
201
|
+
});
|
|
202
|
+
const all = await resolveAllProfiles({
|
|
203
|
+
version: 2,
|
|
204
|
+
profiles: { lean: leanShaped().profiles.lean as never },
|
|
205
|
+
});
|
|
206
|
+
expect(Object.keys(all.plugins).sort()).toEqual([
|
|
207
|
+
"claudish@magus",
|
|
208
|
+
"dev@magus",
|
|
209
|
+
"mnemex@magus",
|
|
210
|
+
"multimodel@magus",
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("dependencies are derived, never recorded", () => {
|
|
216
|
+
test("B6: an edit elsewhere in settings.json records the edit and no dependency", async () => {
|
|
217
|
+
await start(leanShaped(), "lean");
|
|
218
|
+
const committed = await fs.readFile(manifestPath(), "utf8");
|
|
219
|
+
const live = await fs.readJson(settingsPath());
|
|
220
|
+
live.permissions = { allow: ["Bash(ls)"] };
|
|
221
|
+
await fs.writeJson(settingsPath(), live);
|
|
222
|
+
|
|
223
|
+
const result = await reconcileActiveProfile(project);
|
|
224
|
+
|
|
225
|
+
expect(result.lines).toEqual(['Updated settings of profile "lean"']);
|
|
226
|
+
const lean = (await readManifest(project)).profiles.lean;
|
|
227
|
+
expect(lean?.plugins).toEqual({
|
|
228
|
+
"dev@magus": "latest",
|
|
229
|
+
"mnemex@magus": "latest",
|
|
230
|
+
});
|
|
231
|
+
expect(lean?.settings).toEqual({ permissions: { allow: ["Bash(ls)"] } });
|
|
232
|
+
// The only change to the file is lean's new settings.
|
|
233
|
+
const after = await fs.readFile(manifestPath(), "utf8");
|
|
234
|
+
expect(after.split("claudish").length).toBe(
|
|
235
|
+
committed.split("claudish").length,
|
|
236
|
+
);
|
|
237
|
+
expect(after.split("multimodel").length).toBe(
|
|
238
|
+
committed.split("multimodel").length,
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("review LOW 1: Claude Code disabling a dependency another profile uses is the user's `false`, and it stays", async () => {
|
|
243
|
+
await start(leanShaped(), "lean");
|
|
244
|
+
expect((await enabled())["claudish@magus"]).toBe(true);
|
|
245
|
+
// Claude Code turns claudish off; maintainer uses claudish too.
|
|
246
|
+
const live = await fs.readJson(settingsPath());
|
|
247
|
+
live.enabledPlugins["claudish@magus"] = false;
|
|
248
|
+
await fs.writeJson(settingsPath(), live);
|
|
249
|
+
|
|
250
|
+
const result = await reconcileActiveProfile(project);
|
|
251
|
+
|
|
252
|
+
expect(result.lines).toContain('Disabled claudish@magus in profile "lean"');
|
|
253
|
+
expect((await readManifest(project)).profiles.lean?.plugins).toEqual({
|
|
254
|
+
"dev@magus": "latest",
|
|
255
|
+
"mnemex@magus": "latest",
|
|
256
|
+
"claudish@magus": false,
|
|
257
|
+
});
|
|
258
|
+
expect((await enabled())["claudish@magus"]).toBe(false);
|
|
259
|
+
// What will not load is reported from here on.
|
|
260
|
+
const { dependencies } = await renderProfile(
|
|
261
|
+
await readManifest(project),
|
|
262
|
+
"lean",
|
|
263
|
+
);
|
|
264
|
+
expect(dependencies?.blocked).toContainEqual({
|
|
265
|
+
plugin: "dev@magus",
|
|
266
|
+
dependency: "claudish@magus",
|
|
267
|
+
});
|
|
268
|
+
// A later regeneration (a teammate's change to maintainer) keeps it off.
|
|
269
|
+
const pulled = await readManifest(project);
|
|
270
|
+
(pulled.profiles.maintainer?.plugins as Record<string, string>)[
|
|
271
|
+
"designer@magus"
|
|
272
|
+
] = "latest";
|
|
273
|
+
await writeManifest(pulled, project);
|
|
274
|
+
const next = await reconcileActiveProfile(project);
|
|
275
|
+
expect(next.lines.join("\n")).toContain("regenerated");
|
|
276
|
+
expect((await enabled())["claudish@magus"]).toBe(false);
|
|
277
|
+
expect(
|
|
278
|
+
(await readManifest(project)).profiles.lean?.plugins?.["claudish@magus"],
|
|
279
|
+
).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("B6: the `false` an older render wrote for a dependency is not taken as a disable", async () => {
|
|
283
|
+
// What 7.5.0 left: lean rendered with claudish and multimodel `false`,
|
|
284
|
+
// then an unrelated edit, so the file is absorbed.
|
|
285
|
+
setPluginDependencyReader(NO_DEPENDENCIES);
|
|
286
|
+
await start(leanShaped(), "lean");
|
|
287
|
+
expect((await enabled())["claudish@magus"]).toBe(false);
|
|
288
|
+
const live = await fs.readJson(settingsPath());
|
|
289
|
+
live.permissions = { allow: ["Bash(ls)"] };
|
|
290
|
+
await fs.writeJson(settingsPath(), live);
|
|
291
|
+
setPluginDependencyReader(reader(DECLARED));
|
|
292
|
+
|
|
293
|
+
const result = await reconcileActiveProfile(project);
|
|
294
|
+
|
|
295
|
+
expect(result.lines).toContain('Updated settings of profile "lean"');
|
|
296
|
+
expect((await readManifest(project)).profiles.lean?.plugins).toEqual({
|
|
297
|
+
"dev@magus": "latest",
|
|
298
|
+
"mnemex@magus": "latest",
|
|
299
|
+
});
|
|
300
|
+
expect((await enabled())["claudish@magus"]).toBe(true);
|
|
301
|
+
expect((await enabled())["multimodel@magus"]).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("B6: a plugin.json that starts declaring a dependency regenerates the live config", async () => {
|
|
305
|
+
setPluginDependencyReader(NO_DEPENDENCIES);
|
|
306
|
+
await start(leanShaped(), "lean");
|
|
307
|
+
const before = (await renderProfile(leanShaped(), "lean")).definition;
|
|
308
|
+
setPluginDependencyReader(reader(DECLARED));
|
|
309
|
+
expect((await renderProfile(leanShaped(), "lean")).definition).not.toBe(
|
|
310
|
+
before,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const result = await reconcileActiveProfile(project);
|
|
314
|
+
|
|
315
|
+
expect(result.lines.join("\n")).toContain("regenerated");
|
|
316
|
+
expect((await enabled())["claudish@magus"]).toBe(true);
|
|
317
|
+
expect((await readManifest(project)).profiles.lean?.plugins).toEqual({
|
|
318
|
+
"dev@magus": "latest",
|
|
319
|
+
"mnemex@magus": "latest",
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("reading dependencies from plugin.json", () => {
|
|
325
|
+
test("every form Claude Code accepts, resolved in the dependent's marketplace", () => {
|
|
326
|
+
expect(
|
|
327
|
+
parsePluginDependencies(
|
|
328
|
+
{
|
|
329
|
+
dependencies: [
|
|
330
|
+
{ name: "claudish", version: "^2.0" },
|
|
331
|
+
"mnemex",
|
|
332
|
+
"other@elsewhere",
|
|
333
|
+
{ name: "x", marketplace: "mp" },
|
|
334
|
+
{ version: "^1.0" },
|
|
335
|
+
42,
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
"dev@magus",
|
|
339
|
+
),
|
|
340
|
+
).toEqual(["claudish@magus", "mnemex@magus", "other@elsewhere", "x@mp"]);
|
|
341
|
+
expect(parsePluginDependencies({ name: "none" }, "dev@magus")).toEqual([]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("a plugin.json that does not parse reads as unreadable, not as no dependencies", async () => {
|
|
345
|
+
const dir = join(project, "broken");
|
|
346
|
+
await fs.outputFile(join(dir, ".claude-plugin", "plugin.json"), "{ nope");
|
|
347
|
+
await fs.outputJson(join(dir, "plugin.json"), { dependencies: ["x"] });
|
|
348
|
+
expect(await readPluginManifest(dir)).toBeNull();
|
|
349
|
+
expect(await readPluginManifest(join(project, "absent"))).toBeNull();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe("the real reader: the installed copy Claude Code loads decides", () => {
|
|
354
|
+
/**
|
|
355
|
+
* Fixture machine state, all under the test's temp dir: a plugins root with
|
|
356
|
+
* a registry and an installed app@fixture 1.0.0, and a marketplace clone
|
|
357
|
+
* whose app is 2.0.0. The preload's reader is bypassed — this runs
|
|
358
|
+
* `resolvePluginDependencies` itself against these directories.
|
|
359
|
+
*/
|
|
360
|
+
async function machine(): Promise<{
|
|
361
|
+
here: string;
|
|
362
|
+
sources: PluginManifestSources;
|
|
363
|
+
}> {
|
|
364
|
+
const here = await fs.realpath(project);
|
|
365
|
+
const root = join(here, "plugins-root");
|
|
366
|
+
const installed = join(root, "cache", "fixture", "app", "1.0.0");
|
|
367
|
+
const clone = join(here, "marketplace-clone");
|
|
368
|
+
await fs.outputJson(join(installed, ".claude-plugin", "plugin.json"), {
|
|
369
|
+
name: "app",
|
|
370
|
+
version: "1.0.0",
|
|
371
|
+
dependencies: [{ name: "lib", version: "^1.0" }],
|
|
372
|
+
});
|
|
373
|
+
// The marketplace's newer app dropped the dependency.
|
|
374
|
+
await fs.outputJson(join(clone, "app", ".claude-plugin", "plugin.json"), {
|
|
375
|
+
name: "app",
|
|
376
|
+
version: "2.0.0",
|
|
377
|
+
});
|
|
378
|
+
await fs.outputJson(join(clone, "lib", ".claude-plugin", "plugin.json"), {
|
|
379
|
+
name: "lib",
|
|
380
|
+
});
|
|
381
|
+
await fs.outputJson(join(root, "installed_plugins.json"), {
|
|
382
|
+
version: 2,
|
|
383
|
+
plugins: {
|
|
384
|
+
"app@fixture": [
|
|
385
|
+
{
|
|
386
|
+
scope: "project",
|
|
387
|
+
projectPath: here,
|
|
388
|
+
installPath: installed,
|
|
389
|
+
version: "1.0.0",
|
|
390
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
391
|
+
lastUpdated: "2026-01-01T00:00:00.000Z",
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
return {
|
|
397
|
+
here,
|
|
398
|
+
sources: {
|
|
399
|
+
root,
|
|
400
|
+
clone: async (id) => join(clone, id.slice(0, id.lastIndexOf("@"))),
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
test("review LOW 2: a dependency the installed version declares stays enabled when the clone's newer version dropped it", async () => {
|
|
406
|
+
const { here, sources } = await machine();
|
|
407
|
+
expect(
|
|
408
|
+
await resolvePluginDependencies("app@fixture", here, sources),
|
|
409
|
+
).toEqual(["lib@fixture"]);
|
|
410
|
+
// Not installed: the clone answers.
|
|
411
|
+
expect(
|
|
412
|
+
await resolvePluginDependencies("lib@fixture", here, sources),
|
|
413
|
+
).toEqual([]);
|
|
414
|
+
|
|
415
|
+
setPluginDependencyReader((id, at) =>
|
|
416
|
+
resolvePluginDependencies(id, at, sources),
|
|
417
|
+
);
|
|
418
|
+
const manifest: ProfileManifest = {
|
|
419
|
+
version: 2,
|
|
420
|
+
profiles: {
|
|
421
|
+
only: { name: "Only", plugins: { "app@fixture": "latest" } },
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
await writeManifest(manifest, here);
|
|
425
|
+
await activateProfile(manifest, "only", here, { onDisplace: () => {} });
|
|
426
|
+
|
|
427
|
+
expect(await enabled()).toEqual({
|
|
428
|
+
"app@fixture": true,
|
|
429
|
+
"lib@fixture": true,
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('review LOW 2: nothing installed and no clone is unreadable, not "no dependencies"', async () => {
|
|
434
|
+
const { here, sources } = await machine();
|
|
435
|
+
expect(
|
|
436
|
+
await resolvePluginDependencies("ghost@fixture", here, {
|
|
437
|
+
...sources,
|
|
438
|
+
clone: async () => null,
|
|
439
|
+
}),
|
|
440
|
+
).toBeNull();
|
|
441
|
+
});
|
|
442
|
+
});
|