magus-cli 7.4.1 → 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/README.md +9 -8
- package/package.json +5 -4
- package/src/__tests__/install-command.test.ts +4 -3
- package/src/__tests__/models-cli.test.ts +4 -7
- package/src/__tests__/models-manager.test.ts +52 -40
- package/src/__tests__/profile-absorb.test.ts +1210 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- package/src/__tests__/profile-dependencies.test.ts +442 -0
- package/src/__tests__/profile-manifest-write.test.ts +231 -0
- package/src/__tests__/profile-migration-offer.test.ts +18 -15
- package/src/__tests__/profile-migration.test.ts +246 -117
- package/src/__tests__/profile-render.test.ts +141 -0
- package/src/__tests__/profile-skills.test.ts +524 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +1110 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/stale-load-repair.test.ts +105 -0
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/__tests__/support/preload.ts +14 -0
- package/src/__tests__/update-apply.test.ts +145 -0
- package/src/cli/bootstrap.ts +91 -27
- package/src/cli/doctor.ts +165 -38
- package/src/cli/install.ts +126 -122
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +39 -6
- package/src/cli/update.ts +46 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-cli.ts +24 -0
- package/src/services/claude-settings.ts +27 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +113 -9
- package/src/services/manifest.ts +224 -8
- package/src/services/models-manager.ts +36 -73
- package/src/services/plugin-dependencies.ts +48 -0
- package/src/services/plugin-requires.ts +137 -14
- package/src/services/profile-absorb.ts +1586 -0
- package/src/services/profile-activate.ts +492 -94
- package/src/services/profile-live.ts +884 -0
- package/src/services/profile-render.ts +182 -0
- package/src/services/profile-skills.ts +487 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +706 -0
- package/src/services/profiles.ts +26 -37
- package/src/services/resolver.ts +100 -2
- package/src/services/settings-file.ts +5 -5
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tracked-live-files.ts +125 -0
- package/src/services/update-engine.ts +87 -2
- package/src/types/index.ts +34 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/screens/PluginsScreen.tsx +11 -2
- package/src/ui/screens/ProfilesScreen.tsx +84 -29
- package/src/ui/screens/StylesScreen.tsx +2 -1
- package/src/ui/state/types.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +0 -238
- package/src/__tests__/profile-sync.test.ts +0 -154
- package/src/__tests__/symlink-manager.test.ts +0 -180
- package/src/services/profile-materializer.ts +0 -133
- package/src/services/profile-sync.ts +0 -158
- package/src/services/symlink-manager.ts +0 -343
|
@@ -3,8 +3,14 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import fs from "fs-extra";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
listProfiles,
|
|
8
|
+
runProfileCommand,
|
|
9
|
+
showProfile,
|
|
10
|
+
switchProfile,
|
|
11
|
+
} from "../cli/profile.js";
|
|
12
|
+
import { readManifest } from "../services/manifest.js";
|
|
13
|
+
import { activeProfile, writeActiveMarker } from "../services/profile-live.js";
|
|
8
14
|
import type { ProfileManifest } from "../types/index.js";
|
|
9
15
|
|
|
10
16
|
let logs: string[];
|
|
@@ -18,19 +24,10 @@ beforeEach(() => {
|
|
|
18
24
|
});
|
|
19
25
|
afterEach(() => mock.restore());
|
|
20
26
|
|
|
21
|
-
async function setupProject(
|
|
22
|
-
manifest: ProfileManifest,
|
|
23
|
-
materialize: string[] = [],
|
|
24
|
-
): Promise<string> {
|
|
27
|
+
async function setupProject(manifest: ProfileManifest): Promise<string> {
|
|
25
28
|
const dir = await mkdtemp(join(tmpdir(), "profcmd-"));
|
|
26
29
|
await fs.ensureDir(join(dir, ".claude"));
|
|
27
30
|
await fs.writeJson(join(dir, ".claude", "profiles.json"), manifest);
|
|
28
|
-
for (const name of materialize) {
|
|
29
|
-
const pd = join(dir, ".claude", "_profiles", name);
|
|
30
|
-
await fs.ensureDir(pd);
|
|
31
|
-
await fs.writeJson(join(pd, "settings.json"), { which: name });
|
|
32
|
-
await fs.writeJson(join(pd, "mcp.json"), { mcpServers: {} });
|
|
33
|
-
}
|
|
34
31
|
return dir;
|
|
35
32
|
}
|
|
36
33
|
|
|
@@ -38,7 +35,7 @@ const twoProfiles: ProfileManifest = {
|
|
|
38
35
|
version: 2,
|
|
39
36
|
profiles: {
|
|
40
37
|
frontend: { name: "Frontend", plugins: { "dev@magus": "latest" } },
|
|
41
|
-
backend: { name: "Backend", plugins: { "
|
|
38
|
+
backend: { name: "Backend", plugins: { "terminal@magus": "2.9.0" } },
|
|
42
39
|
},
|
|
43
40
|
};
|
|
44
41
|
|
|
@@ -48,18 +45,41 @@ describe("profile list", () => {
|
|
|
48
45
|
if (dir) await rm(dir, { recursive: true, force: true });
|
|
49
46
|
});
|
|
50
47
|
|
|
51
|
-
test("lists profiles
|
|
52
|
-
dir = await setupProject(twoProfiles
|
|
53
|
-
await switchProfile("frontend", dir);
|
|
48
|
+
test("lists profiles and marks the active one", async () => {
|
|
49
|
+
dir = await setupProject(twoProfiles);
|
|
50
|
+
await switchProfile("frontend", dir);
|
|
54
51
|
logs = [];
|
|
55
|
-
|
|
56
|
-
expect(code).toBe(0);
|
|
52
|
+
expect(await listProfiles(dir)).toBe(0);
|
|
57
53
|
const out = logs.join("\n");
|
|
58
|
-
expect(out).toContain("frontend");
|
|
54
|
+
expect(out).toContain("● frontend");
|
|
59
55
|
expect(out).toContain("Backend");
|
|
60
|
-
expect(out).toContain("(not installed)"); // backend isn't materialized
|
|
61
56
|
expect(out).toContain("Active: frontend");
|
|
62
57
|
});
|
|
58
|
+
|
|
59
|
+
test("a recorded profile the manifest no longer defines is named, with the fix", async () => {
|
|
60
|
+
dir = await setupProject(twoProfiles);
|
|
61
|
+
await writeActiveMarker(
|
|
62
|
+
{ version: 1, profile: "gone", files: {}, skills: {} },
|
|
63
|
+
dir,
|
|
64
|
+
);
|
|
65
|
+
expect(await listProfiles(dir)).toBe(0);
|
|
66
|
+
// No default and two profiles: `<name>`, with the choices spelled out.
|
|
67
|
+
expect(logs.join("\n")).toContain(
|
|
68
|
+
'recorded as "gone", which is not defined above. Run: `magus profile switch <name>` (profiles: frontend, backend)',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the fix names the declared default, never a real name in angle brackets", async () => {
|
|
73
|
+
dir = await setupProject({ ...twoProfiles, default: "backend" });
|
|
74
|
+
await writeActiveMarker(
|
|
75
|
+
{ version: 1, profile: "gone", files: {}, skills: {} },
|
|
76
|
+
dir,
|
|
77
|
+
);
|
|
78
|
+
expect(await listProfiles(dir)).toBe(0);
|
|
79
|
+
const out = logs.join("\n");
|
|
80
|
+
expect(out).toContain("Run: `magus profile switch backend`");
|
|
81
|
+
expect(out).not.toContain("<backend>");
|
|
82
|
+
});
|
|
63
83
|
});
|
|
64
84
|
|
|
65
85
|
describe("profile show", () => {
|
|
@@ -70,9 +90,8 @@ describe("profile show", () => {
|
|
|
70
90
|
|
|
71
91
|
test("prints a resolved closure summary", async () => {
|
|
72
92
|
dir = await setupProject(twoProfiles);
|
|
73
|
-
|
|
74
|
-
expect(
|
|
75
|
-
expect(logs.join("\n")).toContain("dev@magus@2.9.0");
|
|
93
|
+
expect(await showProfile("backend", dir)).toBe(0);
|
|
94
|
+
expect(logs.join("\n")).toContain("terminal@magus@2.9.0");
|
|
76
95
|
});
|
|
77
96
|
|
|
78
97
|
test("unknown profile → exit 1", async () => {
|
|
@@ -87,20 +106,51 @@ describe("profile switch", () => {
|
|
|
87
106
|
if (dir) await rm(dir, { recursive: true, force: true });
|
|
88
107
|
});
|
|
89
108
|
|
|
90
|
-
test("switches
|
|
91
|
-
dir = await setupProject(twoProfiles
|
|
109
|
+
test("switches and records the active profile", async () => {
|
|
110
|
+
dir = await setupProject(twoProfiles);
|
|
92
111
|
expect(await switchProfile("backend", dir)).toBe(0);
|
|
93
112
|
expect(await activeProfile(dir)).toBe("backend");
|
|
94
|
-
|
|
95
|
-
expect(
|
|
96
|
-
|
|
113
|
+
const gitignore = await fs.readFile(join(dir, ".gitignore"), "utf8");
|
|
114
|
+
expect(gitignore).toContain(".claude/profiles/");
|
|
115
|
+
expect(gitignore).not.toContain(".claude/_profiles/");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("Claude Code's edits go into the profile being left; nothing is backed up", async () => {
|
|
119
|
+
dir = await setupProject(twoProfiles);
|
|
120
|
+
expect(await switchProfile("frontend", dir)).toBe(0);
|
|
121
|
+
const live = join(dir, ".claude", "settings.json");
|
|
122
|
+
const settings = await fs.readJson(live);
|
|
123
|
+
settings.enabledPlugins["code-search@magus"] = true;
|
|
124
|
+
await fs.writeFile(live, JSON.stringify(settings));
|
|
125
|
+
|
|
126
|
+
logs = [];
|
|
127
|
+
expect(await switchProfile("backend", dir)).toBe(0);
|
|
128
|
+
|
|
129
|
+
expect(logs.join("\n")).toContain(
|
|
130
|
+
'Added code-search@magus to profile "frontend"',
|
|
131
|
+
);
|
|
132
|
+
const manifest = await readManifest(dir);
|
|
133
|
+
expect(manifest.profiles.frontend?.plugins?.["code-search@magus"]).toBe(
|
|
134
|
+
"latest",
|
|
135
|
+
);
|
|
136
|
+
expect((await fs.readJson(live)).enabledPlugins["terminal@magus"]).toBe(
|
|
137
|
+
true,
|
|
138
|
+
);
|
|
139
|
+
expect(logs.some((l) => l.includes("kept at"))).toBe(false);
|
|
140
|
+
expect(await fs.pathExists(join(dir, ".claude", ".magus-backups"))).toBe(
|
|
141
|
+
false,
|
|
97
142
|
);
|
|
98
143
|
});
|
|
99
144
|
|
|
100
|
-
test("
|
|
101
|
-
dir = await setupProject(twoProfiles
|
|
102
|
-
|
|
103
|
-
|
|
145
|
+
test("switching back and forth backs nothing up", async () => {
|
|
146
|
+
dir = await setupProject(twoProfiles);
|
|
147
|
+
await switchProfile("frontend", dir);
|
|
148
|
+
await switchProfile("backend", dir);
|
|
149
|
+
await switchProfile("frontend", dir);
|
|
150
|
+
expect(logs.some((l) => l.includes("kept at"))).toBe(false);
|
|
151
|
+
expect(await fs.pathExists(join(dir, ".claude", ".magus-backups"))).toBe(
|
|
152
|
+
false,
|
|
153
|
+
);
|
|
104
154
|
});
|
|
105
155
|
|
|
106
156
|
test("unknown profile → exit 1", async () => {
|
|
@@ -108,3 +158,11 @@ describe("profile switch", () => {
|
|
|
108
158
|
expect(await switchProfile("ghost", dir)).toBe(1);
|
|
109
159
|
});
|
|
110
160
|
});
|
|
161
|
+
|
|
162
|
+
describe("profile sync is gone", () => {
|
|
163
|
+
test("is an unknown subcommand", async () => {
|
|
164
|
+
expect(await runProfileCommand(["sync"])).toBe(1);
|
|
165
|
+
expect(errs.join("\n")).toContain('Unknown profile command "sync"');
|
|
166
|
+
expect(errs.join("\n")).not.toContain("| sync");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -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
|
+
});
|