magus-cli 7.4.0 → 7.5.0
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 +4 -4
- package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- 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 +1138 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- 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 +425 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +795 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/cli/bootstrap.ts +87 -27
- package/src/cli/doctor.ts +152 -44
- package/src/cli/install.ts +92 -127
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +32 -6
- package/src/cli/update.ts +43 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +283 -125
- package/src/services/cli-tool-commands.ts +30 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +21 -0
- package/src/services/gitignore-service.ts +39 -22
- package/src/services/manifest.ts +4 -5
- package/src/services/models-manager.ts +36 -73
- package/src/services/profile-absorb.ts +1532 -0
- package/src/services/profile-activate.ts +348 -96
- package/src/services/profile-live.ts +825 -0
- package/src/services/profile-render.ts +115 -0
- package/src/services/profile-skills.ts +462 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +622 -0
- package/src/services/profiles.ts +20 -32
- package/src/services/resolver.ts +6 -1
- package/src/services/settings-file.ts +14 -8
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tool-probe.ts +48 -3
- package/src/types/index.ts +15 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/components/modals/ModalContainer.tsx +9 -1
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/input/KeyRouter.tsx +147 -23
- package/src/ui/input/key-dispatch.ts +48 -0
- package/src/ui/keymap.ts +8 -1
- package/src/ui/renderers/cliToolRenderers.tsx +65 -27
- package/src/ui/screens/CliToolsScreen.tsx +74 -13
- package/src/ui/screens/PluginsScreen.tsx +6 -2
- package/src/ui/screens/ProfilesScreen.tsx +75 -27
- 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
|
@@ -1,49 +1,165 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Activation writes the live config directly, and backs up only a genuine
|
|
3
|
+
* pre-magus hand-written file at first adoption.
|
|
3
4
|
*
|
|
4
|
-
* REGRESSION: adopting a project wrote
|
|
5
|
-
* `activeProfile()` still returned null
|
|
6
|
-
*
|
|
7
|
-
* /dev:fix session dev-fix-20260918-migration.
|
|
5
|
+
* REGRESSION (dev-fix-20260918-migration): adopting a project wrote
|
|
6
|
+
* `.claude/profiles.json` and stopped, so `activeProfile()` still returned null
|
|
7
|
+
* and every later run asked which profile to use.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* REGRESSION (this change): once a project was magus-managed, a Claude Code
|
|
10
|
+
* edit to `.claude/settings.json` was moved into `.magus-backups/` on the next
|
|
11
|
+
* switch, silently disabling the plugin it had just installed. A managed
|
|
12
|
+
* project's live files are the active profile's — they are absorbed, never
|
|
13
|
+
* backed up.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
16
|
-
import { lstat, mkdtemp, rm } from "node:fs/promises";
|
|
17
|
+
import { lstat, mkdtemp, readdir, rm } from "node:fs/promises";
|
|
17
18
|
import { tmpdir } from "node:os";
|
|
18
19
|
import { join } from "node:path";
|
|
19
20
|
import fs from "fs-extra";
|
|
20
|
-
import {
|
|
21
|
+
import { writeManifest } from "../services/manifest.js";
|
|
22
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
21
23
|
import { adoptCurrentProject } from "../services/profile-adopt.js";
|
|
22
24
|
import {
|
|
23
|
-
|
|
25
|
+
activeMarkerPath,
|
|
24
26
|
activeProfile,
|
|
25
27
|
backupRoot,
|
|
26
|
-
profileDir,
|
|
27
28
|
profilesRoot,
|
|
28
|
-
|
|
29
|
+
readActiveMarker,
|
|
30
|
+
} from "../services/profile-live.js";
|
|
29
31
|
import type { ProfileManifest } from "../types/index.js";
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
const MANIFEST: ProfileManifest = {
|
|
34
|
+
version: 2,
|
|
35
|
+
profiles: {
|
|
36
|
+
team: {
|
|
37
|
+
name: "Team",
|
|
38
|
+
plugins: { "dev@magus": "latest" },
|
|
39
|
+
mcpServers: { srv: { command: "srv" } },
|
|
40
|
+
},
|
|
41
|
+
other: { name: "Other", plugins: { "terminal@magus": "latest" } },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
async function setup(project: string, manifest = MANIFEST): Promise<void> {
|
|
46
|
+
await writeManifest(manifest, project);
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
describe("activation
|
|
49
|
+
describe("activation writes the live config directly", () => {
|
|
44
50
|
let project: string;
|
|
45
51
|
beforeEach(async () => {
|
|
46
|
-
project = await mkdtemp(join(tmpdir(), "
|
|
52
|
+
project = await mkdtemp(join(tmpdir(), "activate-"));
|
|
53
|
+
await setup(project);
|
|
54
|
+
});
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
await rm(project, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("settings.json and .mcp.json are real files rendered from the manifest", async () => {
|
|
60
|
+
await activateProfile(MANIFEST, "team", project);
|
|
61
|
+
|
|
62
|
+
const settingsPath = join(project, ".claude", "settings.json");
|
|
63
|
+
const mcpPath = join(project, ".mcp.json");
|
|
64
|
+
expect((await lstat(settingsPath)).isFile()).toBe(true);
|
|
65
|
+
expect((await lstat(mcpPath)).isFile()).toBe(true);
|
|
66
|
+
expect(await fs.readJson(settingsPath)).toEqual({
|
|
67
|
+
enabledPlugins: { "dev@magus": true, "terminal@magus": false },
|
|
68
|
+
});
|
|
69
|
+
expect(await fs.readJson(mcpPath)).toEqual({
|
|
70
|
+
mcpServers: { srv: { command: "srv" } },
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("no per-profile directory and no copies: .claude/profiles/ holds only the marker", async () => {
|
|
75
|
+
await activateProfile(MANIFEST, "team", project);
|
|
76
|
+
|
|
77
|
+
expect(await readdir(profilesRoot(project))).toEqual(["active.json"]);
|
|
78
|
+
expect(await fs.pathExists(join(profilesRoot(project), "team"))).toBe(
|
|
79
|
+
false,
|
|
80
|
+
);
|
|
81
|
+
expect(
|
|
82
|
+
await fs.pathExists(join(profilesRoot(project), "team", "settings.json")),
|
|
83
|
+
).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("nothing generated is a symlink", async () => {
|
|
87
|
+
await activateProfile(
|
|
88
|
+
{
|
|
89
|
+
...MANIFEST,
|
|
90
|
+
profiles: {
|
|
91
|
+
...MANIFEST.profiles,
|
|
92
|
+
team: {
|
|
93
|
+
...(MANIFEST.profiles.team as never),
|
|
94
|
+
models: {
|
|
95
|
+
version: 1,
|
|
96
|
+
preset: "t",
|
|
97
|
+
main: { model: "opus", effort: "medium" },
|
|
98
|
+
grades: {
|
|
99
|
+
smart: { model: "opus", effort: "high" },
|
|
100
|
+
normal: { model: "opus", effort: "medium" },
|
|
101
|
+
cheap: { model: "sonnet", effort: "low" },
|
|
102
|
+
},
|
|
103
|
+
agents: {},
|
|
104
|
+
fallback: "normal",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
"team",
|
|
110
|
+
project,
|
|
111
|
+
);
|
|
112
|
+
for (const rel of [
|
|
113
|
+
".claude/settings.json",
|
|
114
|
+
".mcp.json",
|
|
115
|
+
".claude/models.json",
|
|
116
|
+
]) {
|
|
117
|
+
expect((await lstat(join(project, rel))).isSymbolicLink()).toBe(false);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("the marker records the profile, the definition, and each file's hash", async () => {
|
|
122
|
+
await activateProfile(MANIFEST, "team", project);
|
|
123
|
+
const marker = await readActiveMarker(project);
|
|
124
|
+
expect(marker?.profile).toBe("team");
|
|
125
|
+
expect(marker?.definition).toMatch(/^[0-9a-f]{64}$/);
|
|
126
|
+
expect(marker?.base).toEqual(MANIFEST.profiles.team as never);
|
|
127
|
+
expect(Object.keys(marker?.files ?? {}).sort()).toEqual([
|
|
128
|
+
"mcp",
|
|
129
|
+
"settings",
|
|
130
|
+
]);
|
|
131
|
+
expect(activeMarkerPath(project)).toBe(
|
|
132
|
+
join(project, ".claude", "profiles", "active.json"),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("switching between profiles backs up nothing", async () => {
|
|
137
|
+
await activateProfile(MANIFEST, "team", project);
|
|
138
|
+
const second = await activateProfile(MANIFEST, "other", project);
|
|
139
|
+
|
|
140
|
+
expect(second.displaced).toEqual([]);
|
|
141
|
+
expect(await fs.pathExists(backupRoot(project))).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("switching over a live file Claude Code edited backs up nothing", async () => {
|
|
145
|
+
await activateProfile(MANIFEST, "team", project);
|
|
146
|
+
const settingsPath = join(project, ".claude", "settings.json");
|
|
147
|
+
const live = await fs.readJson(settingsPath);
|
|
148
|
+
live.enabledPlugins["code-search@magus"] = true;
|
|
149
|
+
await fs.writeJson(settingsPath, live);
|
|
150
|
+
|
|
151
|
+
const second = await activateProfile(MANIFEST, "other", project);
|
|
152
|
+
|
|
153
|
+
expect(second.displaced).toEqual([]);
|
|
154
|
+
expect(await fs.pathExists(backupRoot(project))).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("first adoption keeps a hand-written file", () => {
|
|
159
|
+
let project: string;
|
|
160
|
+
beforeEach(async () => {
|
|
161
|
+
project = await mkdtemp(join(tmpdir(), "adopt-"));
|
|
162
|
+
await setup(project);
|
|
47
163
|
});
|
|
48
164
|
afterEach(async () => {
|
|
49
165
|
await rm(project, { recursive: true, force: true });
|
|
@@ -52,14 +168,11 @@ describe("activation never deletes a real file", () => {
|
|
|
52
168
|
test("a hand-written settings.json is moved aside, not destroyed", async () => {
|
|
53
169
|
const settingsPath = join(project, ".claude", "settings.json");
|
|
54
170
|
await fs.outputJson(settingsPath, { hand: "written", keep: "me" });
|
|
55
|
-
await materialize(project, "team", { enabledPlugins: {} });
|
|
56
171
|
|
|
57
|
-
const
|
|
172
|
+
const { displaced } = await activateProfile(MANIFEST, "team", project);
|
|
58
173
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// ...and the file it displaced is still readable somewhere.
|
|
62
|
-
const saved = backups.find((b) => b.link === settingsPath);
|
|
174
|
+
expect((await lstat(settingsPath)).isFile()).toBe(true);
|
|
175
|
+
const saved = displaced.find((b) => b.link === settingsPath);
|
|
63
176
|
expect(saved).toBeDefined();
|
|
64
177
|
expect(await fs.readJson(saved?.savedTo ?? "")).toEqual({
|
|
65
178
|
hand: "written",
|
|
@@ -67,81 +180,50 @@ describe("activation never deletes a real file", () => {
|
|
|
67
180
|
});
|
|
68
181
|
});
|
|
69
182
|
|
|
70
|
-
test("
|
|
71
|
-
await materialize(project, "a", { enabledPlugins: {} });
|
|
72
|
-
await materialize(project, "b", { enabledPlugins: {} });
|
|
73
|
-
|
|
74
|
-
await activateProfile("a", project);
|
|
75
|
-
// Everything at the link paths is now a symlink magus itself wrote.
|
|
76
|
-
// Backing those up on every switch would fill the repo with copies of
|
|
77
|
-
// generated state that the manifest can regenerate at any time.
|
|
78
|
-
const second = await activateProfile("b", project);
|
|
79
|
-
|
|
80
|
-
expect(second).toEqual([]);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("a symlink magus does not own is preserved too", async () => {
|
|
84
|
-
// Someone pointed .mcp.json at a shared config outside the project. It is
|
|
85
|
-
// not generated state, so it gets the same protection a real file gets.
|
|
183
|
+
test("a symlink magus does not own is preserved, its target untouched", async () => {
|
|
86
184
|
const outside = join(project, "shared-mcp.json");
|
|
87
185
|
await fs.writeJson(outside, { mcpServers: { shared: {} } });
|
|
88
186
|
await fs.symlink(outside, join(project, ".mcp.json"));
|
|
89
|
-
await materialize(project, "team", { enabledPlugins: {} });
|
|
90
187
|
|
|
91
|
-
const
|
|
188
|
+
const { displaced } = await activateProfile(MANIFEST, "team", project);
|
|
92
189
|
|
|
93
|
-
expect(
|
|
94
|
-
// The target was never touched — only the link moved.
|
|
190
|
+
expect(displaced.map((b) => b.link)).toContain(join(project, ".mcp.json"));
|
|
95
191
|
expect(await fs.readJson(outside)).toEqual({ mcpServers: { shared: {} } });
|
|
96
192
|
});
|
|
97
193
|
|
|
98
194
|
test("a RELATIVE user symlink still resolves from the backup", async () => {
|
|
99
|
-
// A symlink's target resolves against the directory the link sits in, so
|
|
100
|
-
// moving the link into a backup two levels deeper silently repoints it.
|
|
101
|
-
// Preserving a link that no longer resolves preserves nothing.
|
|
102
195
|
await fs.writeJson(join(project, "shared-mcp.json"), { mcpServers: {} });
|
|
103
196
|
await fs.symlink("shared-mcp.json", join(project, ".mcp.json"));
|
|
104
|
-
await materialize(project, "team", { enabledPlugins: {} });
|
|
105
197
|
|
|
106
|
-
const
|
|
107
|
-
const saved =
|
|
198
|
+
const { displaced } = await activateProfile(MANIFEST, "team", project);
|
|
199
|
+
const saved = displaced.find((b) => b.link === join(project, ".mcp.json"));
|
|
108
200
|
|
|
109
201
|
expect(saved).toBeDefined();
|
|
110
|
-
// readJson follows the link: if it were still relative this would throw.
|
|
111
202
|
expect(await fs.readJson(saved?.savedTo ?? "")).toEqual({ mcpServers: {} });
|
|
112
203
|
});
|
|
113
204
|
|
|
114
|
-
test("backups live OUTSIDE the regenerable
|
|
115
|
-
// `_profiles/` is generated and documented as safe to delete. A backup kept
|
|
116
|
-
// there is the only copy of a hand-written file, stored in the one
|
|
117
|
-
// directory everybody is told they can remove.
|
|
205
|
+
test("backups live OUTSIDE the regenerable .claude/profiles/ tree", async () => {
|
|
118
206
|
await fs.outputJson(join(project, ".claude", "settings.json"), { a: 1 });
|
|
119
|
-
await materialize(project, "team", { enabledPlugins: {} });
|
|
120
207
|
|
|
121
|
-
const
|
|
122
|
-
const saved =
|
|
208
|
+
const { displaced } = await activateProfile(MANIFEST, "team", project);
|
|
209
|
+
const saved = displaced[0]?.savedTo ?? "";
|
|
123
210
|
|
|
124
211
|
expect(saved.startsWith(profilesRoot(project))).toBe(false);
|
|
125
212
|
expect(saved.startsWith(backupRoot(project))).toBe(true);
|
|
126
|
-
|
|
127
213
|
await fs.remove(profilesRoot(project));
|
|
128
214
|
expect(await fs.readJson(saved)).toEqual({ a: 1 });
|
|
129
215
|
});
|
|
130
216
|
|
|
131
|
-
test("every displaced
|
|
132
|
-
// The returned array is lost if a later link throws, and `magus install`
|
|
133
|
-
// and `profile switch` discard it outright. The callback is what makes the
|
|
134
|
-
// move visible at the moment it happens.
|
|
217
|
+
test("every displaced file is announced as it moves", async () => {
|
|
135
218
|
await fs.outputJson(join(project, ".claude", "settings.json"), { a: 1 });
|
|
136
219
|
await fs.writeJson(join(project, ".mcp.json"), { mcpServers: {} });
|
|
137
|
-
await materialize(project, "team", { enabledPlugins: {} });
|
|
138
220
|
|
|
139
221
|
const announced: string[] = [];
|
|
140
|
-
const
|
|
141
|
-
announced.push(d.link),
|
|
142
|
-
);
|
|
222
|
+
const { displaced } = await activateProfile(MANIFEST, "team", project, {
|
|
223
|
+
onDisplace: (d) => announced.push(d.link),
|
|
224
|
+
});
|
|
143
225
|
|
|
144
|
-
expect(announced.sort()).toEqual(
|
|
226
|
+
expect(announced.sort()).toEqual(displaced.map((d) => d.link).sort());
|
|
145
227
|
expect(announced).toHaveLength(2);
|
|
146
228
|
});
|
|
147
229
|
});
|
|
@@ -155,20 +237,24 @@ describe("migration leaves the project ON the profile it created", () => {
|
|
|
155
237
|
await rm(project, { recursive: true, force: true });
|
|
156
238
|
});
|
|
157
239
|
|
|
158
|
-
|
|
159
|
-
await fs.outputJson(join(project, ".claude", "settings.json"), {
|
|
160
|
-
enabledPlugins: { "dev@magus": true, "code-search@magus": true },
|
|
161
|
-
});
|
|
162
|
-
|
|
240
|
+
async function adoptAndActivate(existing?: ProfileManifest) {
|
|
163
241
|
const result = await adoptCurrentProject(
|
|
164
242
|
project,
|
|
165
243
|
undefined,
|
|
166
|
-
|
|
244
|
+
existing,
|
|
167
245
|
"default",
|
|
168
246
|
);
|
|
169
|
-
await
|
|
247
|
+
await writeManifest(result.manifest, project);
|
|
248
|
+
await activateProfile(result.manifest, "default", project, {
|
|
249
|
+
onDisplace: () => {},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
170
252
|
|
|
171
|
-
|
|
253
|
+
test("after adopting, activeProfile() names the adopted profile", async () => {
|
|
254
|
+
await fs.outputJson(join(project, ".claude", "settings.json"), {
|
|
255
|
+
enabledPlugins: { "dev@magus": true, "code-search@magus": true },
|
|
256
|
+
});
|
|
257
|
+
await adoptAndActivate();
|
|
172
258
|
expect(await activeProfile(project)).toBe("default");
|
|
173
259
|
});
|
|
174
260
|
|
|
@@ -177,16 +263,8 @@ describe("migration leaves the project ON the profile it created", () => {
|
|
|
177
263
|
enabledPlugins: { "dev@magus": true, "code-search@magus": true },
|
|
178
264
|
someOtherSetting: 42,
|
|
179
265
|
});
|
|
266
|
+
await adoptAndActivate();
|
|
180
267
|
|
|
181
|
-
const result = await adoptCurrentProject(
|
|
182
|
-
project,
|
|
183
|
-
undefined,
|
|
184
|
-
undefined,
|
|
185
|
-
"default",
|
|
186
|
-
);
|
|
187
|
-
await materializeAndActivate(result.manifest, "default", project);
|
|
188
|
-
|
|
189
|
-
// Read through the live symlink — this is what Claude Code will load.
|
|
190
268
|
const live = await fs.readJson(join(project, ".claude", "settings.json"));
|
|
191
269
|
expect(live.enabledPlugins).toEqual({
|
|
192
270
|
"dev@magus": true,
|
|
@@ -199,44 +277,95 @@ describe("migration leaves the project ON the profile it created", () => {
|
|
|
199
277
|
await fs.outputJson(join(project, ".claude", "settings.json"), {
|
|
200
278
|
enabledPlugins: { "dev@magus": true },
|
|
201
279
|
});
|
|
202
|
-
|
|
280
|
+
await adoptAndActivate({
|
|
203
281
|
version: 2,
|
|
204
282
|
profiles: {
|
|
205
283
|
lean: { name: "Lean", plugins: { "terminal@magus": "latest" } },
|
|
206
284
|
},
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
const result = await adoptCurrentProject(
|
|
210
|
-
project,
|
|
211
|
-
undefined,
|
|
212
|
-
existing,
|
|
213
|
-
"default",
|
|
214
|
-
);
|
|
215
|
-
await materializeAndActivate(result.manifest, "default", project);
|
|
285
|
+
});
|
|
216
286
|
|
|
217
287
|
const live = await fs.readJson(join(project, ".claude", "settings.json"));
|
|
218
|
-
// `terminal@magus` belongs to another profile, so this one must say so
|
|
219
|
-
// explicitly — an omitted id falls through to user scope and stays on.
|
|
220
288
|
expect(live.enabledPlugins["terminal@magus"]).toBe(false);
|
|
221
289
|
expect(live.enabledPlugins["dev@magus"]).toBe(true);
|
|
222
290
|
});
|
|
223
291
|
|
|
224
|
-
test("the
|
|
292
|
+
test("the marker dir and live config are gitignored; .claude/skills is not", async () => {
|
|
225
293
|
await fs.outputJson(join(project, ".claude", "settings.json"), {
|
|
226
294
|
enabledPlugins: { "dev@magus": true },
|
|
227
295
|
});
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
296
|
+
await adoptAndActivate();
|
|
297
|
+
|
|
298
|
+
const lines = (await fs.readFile(join(project, ".gitignore"), "utf8"))
|
|
299
|
+
.split("\n")
|
|
300
|
+
.map((l) => l.trim());
|
|
301
|
+
expect(lines).toContain(".claude/profiles/");
|
|
302
|
+
expect(lines).toContain(".claude/settings.json");
|
|
303
|
+
expect(lines).toContain(".mcp.json");
|
|
304
|
+
// Custom skills in .claude/skills are committed; a blanket ignore hid them.
|
|
305
|
+
expect(lines).not.toContain(".claude/skills");
|
|
306
|
+
expect(lines).not.toContain(".claude/_profiles/");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("a user's own .claude/skills/ line outside magus's block survives activation and switching", async () => {
|
|
310
|
+
await fs.writeFile(
|
|
311
|
+
join(project, ".gitignore"),
|
|
312
|
+
"node_modules\n# my skills are local\n.claude/skills/\n.claude/skills\n",
|
|
313
|
+
);
|
|
314
|
+
await fs.outputJson(join(project, ".claude", "settings.json"), {
|
|
315
|
+
enabledPlugins: { "dev@magus": true },
|
|
316
|
+
});
|
|
317
|
+
await adoptAndActivate();
|
|
318
|
+
|
|
319
|
+
const lines = (await fs.readFile(join(project, ".gitignore"), "utf8"))
|
|
320
|
+
.split("\n")
|
|
321
|
+
.map((l) => l.trim());
|
|
322
|
+
expect(lines).toContain("node_modules");
|
|
323
|
+
expect(lines).toContain(".claude/skills/");
|
|
324
|
+
expect(lines).toContain(".claude/skills");
|
|
325
|
+
expect(lines).toContain(".claude/profiles/");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("converting a 7.4.1 layout removes the blanket skills line from magus's block only", async () => {
|
|
329
|
+
// magus 7.4.1 wrote this block; the user's own line sits elsewhere.
|
|
330
|
+
await fs.writeFile(
|
|
331
|
+
join(project, ".gitignore"),
|
|
332
|
+
[
|
|
333
|
+
"node_modules",
|
|
334
|
+
".claude/skills/",
|
|
335
|
+
"",
|
|
336
|
+
"# magus: generated profile build output + active-profile config",
|
|
337
|
+
".claude/_profiles/",
|
|
338
|
+
".claude/.magus-backups/",
|
|
339
|
+
".claude/settings.json",
|
|
340
|
+
".claude/skills",
|
|
341
|
+
".claude/models.json",
|
|
342
|
+
".mcp.json",
|
|
343
|
+
"",
|
|
344
|
+
].join("\n"),
|
|
345
|
+
);
|
|
346
|
+
await writeManifest(MANIFEST, project);
|
|
347
|
+
const old = join(project, ".claude", "_profiles", "team");
|
|
348
|
+
await fs.outputJson(join(old, "settings.json"), { enabledPlugins: {} });
|
|
349
|
+
await fs.ensureDir(join(old, "skills"));
|
|
350
|
+
await fs.symlink(
|
|
351
|
+
"_profiles/team/settings.json",
|
|
352
|
+
join(project, ".claude", "settings.json"),
|
|
353
|
+
);
|
|
354
|
+
await fs.symlink(
|
|
355
|
+
"_profiles/team/skills",
|
|
356
|
+
join(project, ".claude", "skills"),
|
|
233
357
|
);
|
|
234
|
-
await materializeAndActivate(result.manifest, "default", project);
|
|
235
358
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
359
|
+
await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
|
|
360
|
+
|
|
361
|
+
const text = await fs.readFile(join(project, ".gitignore"), "utf8");
|
|
362
|
+
const lines = text.split("\n").map((l) => l.trim());
|
|
363
|
+
// The user's line, outside the block: kept.
|
|
364
|
+
expect(lines.slice(0, 2)).toEqual(["node_modules", ".claude/skills/"]);
|
|
365
|
+
// magus's obsolete lines, inside its block: gone.
|
|
366
|
+
expect(lines).not.toContain(".claude/skills");
|
|
367
|
+
expect(lines).not.toContain(".claude/_profiles/");
|
|
368
|
+
expect(lines).toContain(".claude/settings.json");
|
|
369
|
+
expect(lines).toContain(".claude/profiles/");
|
|
241
370
|
});
|
|
242
371
|
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ModelsConfig } from "../services/models-core.js";
|
|
3
|
+
import {
|
|
4
|
+
buildProfileMcp,
|
|
5
|
+
buildProfileSettings,
|
|
6
|
+
renderProfile,
|
|
7
|
+
} from "../services/profile-render.js";
|
|
8
|
+
import type { ProfileManifest, ResolvedClosure } from "../types/index.js";
|
|
9
|
+
|
|
10
|
+
const ROUTING: ModelsConfig = {
|
|
11
|
+
version: 1,
|
|
12
|
+
preset: "test",
|
|
13
|
+
main: { model: "opus", effort: "medium" },
|
|
14
|
+
grades: {
|
|
15
|
+
smart: { model: "fable", effort: "xhigh" },
|
|
16
|
+
normal: { model: "opus", effort: "medium" },
|
|
17
|
+
cheap: { model: "sonnet", effort: "low" },
|
|
18
|
+
},
|
|
19
|
+
agents: { "dev:architect": "smart" },
|
|
20
|
+
fallback: "normal",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
|
|
24
|
+
return {
|
|
25
|
+
marketplaces: {},
|
|
26
|
+
plugins: {},
|
|
27
|
+
mcpServers: {},
|
|
28
|
+
bins: [],
|
|
29
|
+
skills: [],
|
|
30
|
+
settings: {},
|
|
31
|
+
env: { required: [], optional: [] },
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("buildProfileSettings", () => {
|
|
37
|
+
test("enables every resolved plugin and merges declared settings", () => {
|
|
38
|
+
const s = buildProfileSettings(
|
|
39
|
+
closure({
|
|
40
|
+
plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
|
|
41
|
+
settings: { enableAllProjectMcpServers: true },
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
expect(s.enabledPlugins).toEqual({
|
|
45
|
+
"dev@magus": true,
|
|
46
|
+
"terminal@magus": true,
|
|
47
|
+
});
|
|
48
|
+
expect(s.enableAllProjectMcpServers).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Claude Code resolves enabledPlugins per plugin id — an id the project
|
|
52
|
+
// scope omits falls through to user scope. A profile that merely omits
|
|
53
|
+
// another profile's plugins would never disable them, so `profile switch`
|
|
54
|
+
// would be additive instead of exclusive.
|
|
55
|
+
test("explicitly disables union plugins the profile excludes", () => {
|
|
56
|
+
const s = buildProfileSettings(
|
|
57
|
+
closure({ plugins: { "designer@magus": "latest" } }),
|
|
58
|
+
["designer@magus", "terminal@magus", "madbench@magus"],
|
|
59
|
+
);
|
|
60
|
+
expect(s.enabledPlugins).toEqual({
|
|
61
|
+
"designer@magus": true,
|
|
62
|
+
"terminal@magus": false,
|
|
63
|
+
"madbench@magus": false,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a profile's own plugin wins over its union entry", () => {
|
|
68
|
+
const s = buildProfileSettings(
|
|
69
|
+
closure({ plugins: { "terminal@magus": "4.1.4" } }),
|
|
70
|
+
["terminal@magus"],
|
|
71
|
+
);
|
|
72
|
+
expect(s.enabledPlugins).toEqual({ "terminal@magus": true });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a plugin the profile declares false is rendered false", () => {
|
|
76
|
+
const s = buildProfileSettings(
|
|
77
|
+
closure({
|
|
78
|
+
plugins: { "dev@magus": "latest" },
|
|
79
|
+
disabledPlugins: ["noisy@elsewhere"],
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
expect(s.enabledPlugins).toEqual({
|
|
83
|
+
"dev@magus": true,
|
|
84
|
+
"noisy@elsewhere": false,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("buildProfileMcp", () => {
|
|
90
|
+
test("wraps the closure's mcpServers", () => {
|
|
91
|
+
const m = buildProfileMcp(
|
|
92
|
+
closure({ mcpServers: { "chrome-devtools": { command: "npx" } } }),
|
|
93
|
+
);
|
|
94
|
+
expect(m.mcpServers["chrome-devtools"]).toEqual({ command: "npx" });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("renderProfile", () => {
|
|
99
|
+
const manifest: ProfileManifest = {
|
|
100
|
+
version: 2,
|
|
101
|
+
profiles: {
|
|
102
|
+
frontend: {
|
|
103
|
+
name: "Frontend",
|
|
104
|
+
plugins: { "designer@magus": "latest", "off@elsewhere": false },
|
|
105
|
+
mcpServers: { x: { command: "y" } },
|
|
106
|
+
settings: { outputStyle: "composed-frontend" },
|
|
107
|
+
models: ROUTING,
|
|
108
|
+
},
|
|
109
|
+
backend: { name: "Backend", plugins: { "terminal@magus": "latest" } },
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
test("renders settings, mcp and models in memory from the entry", async () => {
|
|
114
|
+
const r = await renderProfile(manifest, "frontend");
|
|
115
|
+
expect(r.settings).toEqual({
|
|
116
|
+
enabledPlugins: {
|
|
117
|
+
"designer@magus": true,
|
|
118
|
+
"terminal@magus": false,
|
|
119
|
+
"off@elsewhere": false,
|
|
120
|
+
},
|
|
121
|
+
outputStyle: "composed-frontend",
|
|
122
|
+
});
|
|
123
|
+
expect(r.mcp).toEqual({ mcpServers: { x: { command: "y" } } });
|
|
124
|
+
expect(r.models).toEqual(ROUTING as unknown as Record<string, unknown>);
|
|
125
|
+
expect(r.entry).toBe(manifest.profiles.frontend as never);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a profile without routing renders no models.json", async () => {
|
|
129
|
+
const r = await renderProfile(manifest, "backend");
|
|
130
|
+
expect(r.models).toBeNull();
|
|
131
|
+
expect(r.settings.enabledPlugins).toEqual({
|
|
132
|
+
"designer@magus": false,
|
|
133
|
+
"terminal@magus": true,
|
|
134
|
+
});
|
|
135
|
+
expect(r.mcp).toEqual({ mcpServers: {} });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("an unknown profile throws", async () => {
|
|
139
|
+
await expect(renderProfile(manifest, "nope")).rejects.toThrow(/nope/);
|
|
140
|
+
});
|
|
141
|
+
});
|