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
|
@@ -14,8 +14,11 @@ import {
|
|
|
14
14
|
readSettings,
|
|
15
15
|
writeSettings,
|
|
16
16
|
} from "../../services/claude-settings.js";
|
|
17
|
+
import { reconcileActiveProfile } from "../../services/profile-absorb.js";
|
|
18
|
+
import { activationNotes } from "../../services/profile-activate.js";
|
|
19
|
+
import { switchToProfile } from "../../services/profile-state.js";
|
|
17
20
|
import {
|
|
18
|
-
|
|
21
|
+
applyUserProfile,
|
|
19
22
|
deleteProfile,
|
|
20
23
|
exportProfileToJson,
|
|
21
24
|
importProfileFromJson,
|
|
@@ -29,6 +32,7 @@ import {
|
|
|
29
32
|
writeClipboard,
|
|
30
33
|
} from "../../utils/clipboard.js";
|
|
31
34
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
35
|
+
import { describeOutcome } from "../hooks/useProfileStartupModal.js";
|
|
32
36
|
import {
|
|
33
37
|
type ProfileListItem,
|
|
34
38
|
buildProfileListItems,
|
|
@@ -156,11 +160,17 @@ export function ProfilesScreen() {
|
|
|
156
160
|
}
|
|
157
161
|
}
|
|
158
162
|
await writeSettings(settings, state.projectPath);
|
|
163
|
+
// In a profiled project the merge is a change to the active profile:
|
|
164
|
+
// record it there now, rather than at the next magus command.
|
|
165
|
+
const recorded = await reconcileActiveProfile(state.projectPath);
|
|
159
166
|
modal.hideModal();
|
|
160
167
|
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
161
168
|
await modal.message(
|
|
162
169
|
"Applied",
|
|
163
|
-
|
|
170
|
+
[
|
|
171
|
+
`Profile "${profile.name}" merged into project settings.`,
|
|
172
|
+
...recorded.lines,
|
|
173
|
+
].join("\n"),
|
|
164
174
|
"success",
|
|
165
175
|
);
|
|
166
176
|
} catch (error) {
|
|
@@ -173,28 +183,71 @@ export function ProfilesScreen() {
|
|
|
173
183
|
}
|
|
174
184
|
};
|
|
175
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Apply a saved profile.
|
|
188
|
+
*
|
|
189
|
+
* A PROJECT profile is switched to, through the same path as `magus profile
|
|
190
|
+
* switch`: what was changed in the live config goes into the profile being
|
|
191
|
+
* left, then this one is rendered. Writing its enabledPlugins straight into
|
|
192
|
+
* `.claude/settings.json` made the apply invisible to the profile and undone
|
|
193
|
+
* by the next render.
|
|
194
|
+
*
|
|
195
|
+
* A USER-scope profile is a template in `~/.claude/profiles.json` and applies
|
|
196
|
+
* to `~/.claude/settings.json` only — never to this project's live files,
|
|
197
|
+
* which belong to its active profile.
|
|
198
|
+
*/
|
|
176
199
|
const handleApply = async () => {
|
|
177
200
|
if (selectedItem?.kind !== "saved") return;
|
|
178
201
|
const selectedProfile = selectedItem.entry;
|
|
179
202
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
label: "Project — .claude/settings.json (this project)",
|
|
187
|
-
value: "project",
|
|
188
|
-
},
|
|
189
|
-
],
|
|
190
|
-
);
|
|
191
|
-
if (scopeChoice === null) return;
|
|
203
|
+
if (selectedProfile.scope === "project") {
|
|
204
|
+
const confirmed = await modal.confirm(
|
|
205
|
+
"Switch profile",
|
|
206
|
+
`Make "${selectedProfile.name}" this project's active profile?\n\nIts plugins, MCP servers, model routing and skills replace the current ones. Anything changed in the current config is saved into the profile you are leaving first.`,
|
|
207
|
+
);
|
|
208
|
+
if (!confirmed) return;
|
|
192
209
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
210
|
+
modal.loading(`Switching to "${selectedProfile.name}"...`);
|
|
211
|
+
try {
|
|
212
|
+
const result = await switchToProfile(
|
|
213
|
+
state.projectPath,
|
|
214
|
+
selectedProfile.id,
|
|
215
|
+
{ onDisplace: () => {} },
|
|
216
|
+
);
|
|
217
|
+
modal.hideModal();
|
|
218
|
+
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
219
|
+
if (result.problems.length > 0 || !result.activation) {
|
|
220
|
+
await modal.message(
|
|
221
|
+
"Could not switch",
|
|
222
|
+
[...result.lines, ...result.problems].join("\n"),
|
|
223
|
+
"error",
|
|
224
|
+
);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const outcome = describeOutcome(
|
|
228
|
+
result.activation.profileId,
|
|
229
|
+
result.activation.displaced,
|
|
230
|
+
state.projectPath,
|
|
231
|
+
false,
|
|
232
|
+
activationNotes(result.activation),
|
|
233
|
+
);
|
|
234
|
+
await modal.message(
|
|
235
|
+
"Profile active",
|
|
236
|
+
result.lines.length > 0
|
|
237
|
+
? `${result.lines.join("\n")}\n\n${outcome}`
|
|
238
|
+
: outcome,
|
|
239
|
+
"success",
|
|
240
|
+
);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
modal.hideModal();
|
|
243
|
+
await modal.message(
|
|
244
|
+
"Error",
|
|
245
|
+
`Failed to switch profile: ${error}`,
|
|
246
|
+
"error",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
198
251
|
|
|
199
252
|
const pluginCount = Object.keys(selectedProfile.plugins ?? {}).length;
|
|
200
253
|
const emptyWarning =
|
|
@@ -204,23 +257,18 @@ export function ProfilesScreen() {
|
|
|
204
257
|
|
|
205
258
|
const confirmed = await modal.confirm(
|
|
206
259
|
"Confirm Apply",
|
|
207
|
-
`This will REPLACE all enabledPlugins in
|
|
260
|
+
`This will REPLACE all enabledPlugins in ~/.claude/settings.json (every project on this machine).${emptyWarning}\n\nContinue?`,
|
|
208
261
|
);
|
|
209
262
|
if (!confirmed) return;
|
|
210
263
|
|
|
211
264
|
modal.loading(`Applying "${selectedProfile.name}"...`);
|
|
212
265
|
try {
|
|
213
|
-
await
|
|
214
|
-
selectedProfile.id,
|
|
215
|
-
selectedProfile.scope,
|
|
216
|
-
targetScope,
|
|
217
|
-
state.projectPath,
|
|
218
|
-
);
|
|
266
|
+
await applyUserProfile(selectedProfile);
|
|
219
267
|
modal.hideModal();
|
|
220
268
|
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
221
269
|
await modal.message(
|
|
222
270
|
"Applied",
|
|
223
|
-
`Profile "${selectedProfile.name}" applied to
|
|
271
|
+
`Profile "${selectedProfile.name}" applied to ~/.claude/settings.json.`,
|
|
224
272
|
"success",
|
|
225
273
|
);
|
|
226
274
|
} catch (error) {
|
|
@@ -535,7 +535,8 @@ export function StylesScreen() {
|
|
|
535
535
|
? `profile "${result.profile}"`
|
|
536
536
|
: "this project";
|
|
537
537
|
// Say plainly when the manifest was NOT updated: without that record a
|
|
538
|
-
//
|
|
538
|
+
// the next activation renders settings.json from the manifest and
|
|
539
|
+
// drops the style.
|
|
539
540
|
const durability =
|
|
540
541
|
result.profile && !result.recordedInManifest
|
|
541
542
|
? " — not in profiles.json, so `magus install` will drop it"
|
package/src/ui/state/types.ts
CHANGED
|
@@ -306,7 +306,7 @@ export interface ModelsScreenState {
|
|
|
306
306
|
* True while an apply or a clear is writing.
|
|
307
307
|
*
|
|
308
308
|
* App state, not screen state, because the screen UNMOUNTS on a tab switch.
|
|
309
|
-
* An apply rewrites the profile, re-
|
|
309
|
+
* An apply rewrites the profile, re-renders its live files and registers a hook —
|
|
310
310
|
* two of those running at once would interleave writes to the same files.
|
|
311
311
|
*/
|
|
312
312
|
isApplying: boolean;
|
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import fs from "fs-extra";
|
|
6
|
-
import type { ModelsConfig } from "../services/models-core.js";
|
|
7
|
-
import {
|
|
8
|
-
buildProfileMcp,
|
|
9
|
-
buildProfileSettings,
|
|
10
|
-
materializeProfile,
|
|
11
|
-
} from "../services/profile-materializer.js";
|
|
12
|
-
import { profileDir } from "../services/symlink-manager.js";
|
|
13
|
-
import type { ResolvedClosure } from "../types/index.js";
|
|
14
|
-
|
|
15
|
-
const ROUTING: ModelsConfig = {
|
|
16
|
-
version: 1,
|
|
17
|
-
preset: "test",
|
|
18
|
-
main: { model: "opus", effort: "medium" },
|
|
19
|
-
grades: {
|
|
20
|
-
smart: { model: "fable", effort: "xhigh" },
|
|
21
|
-
normal: { model: "opus", effort: "medium" },
|
|
22
|
-
cheap: { model: "sonnet", effort: "low" },
|
|
23
|
-
},
|
|
24
|
-
agents: { "dev:architect": "smart" },
|
|
25
|
-
fallback: "normal",
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
|
|
29
|
-
return {
|
|
30
|
-
marketplaces: {},
|
|
31
|
-
plugins: {},
|
|
32
|
-
mcpServers: {},
|
|
33
|
-
bins: [],
|
|
34
|
-
skills: [],
|
|
35
|
-
settings: {},
|
|
36
|
-
env: { required: [], optional: [] },
|
|
37
|
-
...overrides,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe("buildProfileSettings", () => {
|
|
42
|
-
test("enables every resolved plugin and merges declared settings", () => {
|
|
43
|
-
const s = buildProfileSettings(
|
|
44
|
-
closure({
|
|
45
|
-
plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
|
|
46
|
-
settings: { enableAllProjectMcpServers: true },
|
|
47
|
-
}),
|
|
48
|
-
);
|
|
49
|
-
expect(s.enabledPlugins).toEqual({
|
|
50
|
-
"dev@magus": true,
|
|
51
|
-
"terminal@magus": true,
|
|
52
|
-
});
|
|
53
|
-
expect(s.enableAllProjectMcpServers).toBe(true);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// Regression: `install` enables the manifest-wide union at USER scope, and
|
|
57
|
-
// Claude Code resolves enabledPlugins per plugin id — an id the project
|
|
58
|
-
// scope omits falls through to the user scope's `true`. A profile that
|
|
59
|
-
// merely omits another profile's plugins therefore never disables them, so
|
|
60
|
-
// `profile switch` silently becomes additive instead of exclusive.
|
|
61
|
-
test("explicitly disables union plugins the profile excludes", () => {
|
|
62
|
-
const s = buildProfileSettings(
|
|
63
|
-
closure({ plugins: { "designer@magus": "latest" } }),
|
|
64
|
-
["designer@magus", "terminal@magus", "madbench@magus"],
|
|
65
|
-
);
|
|
66
|
-
expect(s.enabledPlugins).toEqual({
|
|
67
|
-
"designer@magus": true,
|
|
68
|
-
"terminal@magus": false,
|
|
69
|
-
"madbench@magus": false,
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("a profile's own plugin wins over its union entry", () => {
|
|
74
|
-
const s = buildProfileSettings(
|
|
75
|
-
closure({ plugins: { "terminal@magus": "4.1.4" } }),
|
|
76
|
-
["terminal@magus"],
|
|
77
|
-
);
|
|
78
|
-
expect(s.enabledPlugins).toEqual({ "terminal@magus": true });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("no union given -> no false entries (unchanged legacy shape)", () => {
|
|
82
|
-
const s = buildProfileSettings(
|
|
83
|
-
closure({ plugins: { "dev@magus": "latest" } }),
|
|
84
|
-
);
|
|
85
|
-
expect(s.enabledPlugins).toEqual({ "dev@magus": true });
|
|
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("materializeProfile", () => {
|
|
99
|
-
let project: string;
|
|
100
|
-
beforeEach(async () => {
|
|
101
|
-
project = await mkdtemp(join(tmpdir(), "materialize-"));
|
|
102
|
-
});
|
|
103
|
-
afterEach(async () => {
|
|
104
|
-
await rm(project, { recursive: true, force: true });
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("writes settings.json, mcp.json, and an empty skills/ dir", async () => {
|
|
108
|
-
const dir = await materializeProfile(
|
|
109
|
-
"frontend",
|
|
110
|
-
closure({
|
|
111
|
-
plugins: { "dev@magus": "latest" },
|
|
112
|
-
mcpServers: { x: { command: "y" } },
|
|
113
|
-
}),
|
|
114
|
-
project,
|
|
115
|
-
);
|
|
116
|
-
expect(dir).toBe(profileDir("frontend", project));
|
|
117
|
-
expect(
|
|
118
|
-
(await fs.readJson(join(dir, "settings.json"))).enabledPlugins[
|
|
119
|
-
"dev@magus"
|
|
120
|
-
],
|
|
121
|
-
).toBe(true);
|
|
122
|
-
expect(
|
|
123
|
-
(await fs.readJson(join(dir, "mcp.json"))).mcpServers.x.command,
|
|
124
|
-
).toBe("y");
|
|
125
|
-
expect(await fs.pathExists(join(dir, "skills"))).toBe(true);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("is idempotent (re-materialize overwrites cleanly)", async () => {
|
|
129
|
-
await materializeProfile(
|
|
130
|
-
"p",
|
|
131
|
-
closure({ plugins: { "a@m": "latest" } }),
|
|
132
|
-
project,
|
|
133
|
-
);
|
|
134
|
-
const dir = await materializeProfile(
|
|
135
|
-
"p",
|
|
136
|
-
closure({ plugins: { "b@m": "latest" } }),
|
|
137
|
-
project,
|
|
138
|
-
);
|
|
139
|
-
const settings = await fs.readJson(join(dir, "settings.json"));
|
|
140
|
-
expect(settings.enabledPlugins).toEqual({ "b@m": true });
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// Regression: activating a profile replaces .claude/skills with a symlink,
|
|
144
|
-
// and replacing means fs.remove first. A repo that committed its own project
|
|
145
|
-
// skills before adopting profiles would lose them at that moment.
|
|
146
|
-
test("seeds skills/ from the project's pre-existing .claude/skills", async () => {
|
|
147
|
-
const src = join(project, ".claude", "skills", "systematic-debugging");
|
|
148
|
-
await fs.outputFile(join(src, "SKILL.md"), "# committed project skill\n");
|
|
149
|
-
|
|
150
|
-
const dir = await materializeProfile("p", closure(), project);
|
|
151
|
-
|
|
152
|
-
expect(
|
|
153
|
-
await fs.readFile(
|
|
154
|
-
join(dir, "skills", "systematic-debugging", "SKILL.md"),
|
|
155
|
-
"utf8",
|
|
156
|
-
),
|
|
157
|
-
).toContain("committed project skill");
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("does not re-seed skills/ once the profile dir exists", async () => {
|
|
161
|
-
await fs.outputFile(
|
|
162
|
-
join(project, ".claude", "skills", "gone", "SKILL.md"),
|
|
163
|
-
"# removed later\n",
|
|
164
|
-
);
|
|
165
|
-
const dir = await materializeProfile("p", closure(), project);
|
|
166
|
-
await fs.remove(join(dir, "skills", "gone"));
|
|
167
|
-
|
|
168
|
-
await materializeProfile("p", closure(), project);
|
|
169
|
-
|
|
170
|
-
expect(await fs.pathExists(join(dir, "skills", "gone"))).toBe(false);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("still creates an empty skills/ when the project has none", async () => {
|
|
174
|
-
const dir = await materializeProfile("p", closure(), project);
|
|
175
|
-
expect(await fs.pathExists(join(dir, "skills"))).toBe(true);
|
|
176
|
-
expect(await fs.readdir(join(dir, "skills"))).toEqual([]);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test("writes models.json when the closure carries routing", async () => {
|
|
180
|
-
const dir = await materializeProfile(
|
|
181
|
-
"p",
|
|
182
|
-
closure({ models: ROUTING }),
|
|
183
|
-
project,
|
|
184
|
-
);
|
|
185
|
-
expect(await fs.readJson(join(dir, "models.json"))).toEqual(ROUTING);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
// The one artifact materialization DELETES. Every other file here is
|
|
189
|
-
// unconditional, so overwriting is enough; routing is optional, and a
|
|
190
|
-
// profile that dropped it would otherwise keep routing from a file nothing
|
|
191
|
-
// writes any more — `magus models off` could not turn routing off,
|
|
192
|
-
// because the hook walks up to the nearest models.json and finds this one.
|
|
193
|
-
test("deletes a stale models.json when the closure has no routing", async () => {
|
|
194
|
-
const dir = await materializeProfile(
|
|
195
|
-
"p",
|
|
196
|
-
closure({ models: ROUTING }),
|
|
197
|
-
project,
|
|
198
|
-
);
|
|
199
|
-
expect(await fs.pathExists(join(dir, "models.json"))).toBe(true);
|
|
200
|
-
|
|
201
|
-
await materializeProfile("p", closure(), project);
|
|
202
|
-
|
|
203
|
-
expect(await fs.pathExists(join(dir, "models.json"))).toBe(false);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test("a profile that never had routing gets no models.json", async () => {
|
|
207
|
-
const dir = await materializeProfile("p", closure(), project);
|
|
208
|
-
expect(await fs.pathExists(join(dir, "models.json"))).toBe(false);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
test("two profiles from one union each disable the other's plugins", async () => {
|
|
212
|
-
const union = ["designer@magus", "terminal@magus"];
|
|
213
|
-
const fe = await materializeProfile(
|
|
214
|
-
"frontend",
|
|
215
|
-
closure({ plugins: { "designer@magus": "latest" } }),
|
|
216
|
-
project,
|
|
217
|
-
union,
|
|
218
|
-
);
|
|
219
|
-
const be = await materializeProfile(
|
|
220
|
-
"backend",
|
|
221
|
-
closure({ plugins: { "terminal@magus": "latest" } }),
|
|
222
|
-
project,
|
|
223
|
-
union,
|
|
224
|
-
);
|
|
225
|
-
expect(
|
|
226
|
-
(await fs.readJson(join(fe, "settings.json"))).enabledPlugins,
|
|
227
|
-
).toEqual({
|
|
228
|
-
"designer@magus": true,
|
|
229
|
-
"terminal@magus": false,
|
|
230
|
-
});
|
|
231
|
-
expect(
|
|
232
|
-
(await fs.readJson(join(be, "settings.json"))).enabledPlugins,
|
|
233
|
-
).toEqual({
|
|
234
|
-
"designer@magus": false,
|
|
235
|
-
"terminal@magus": true,
|
|
236
|
-
});
|
|
237
|
-
});
|
|
238
|
-
});
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import fs from "fs-extra";
|
|
6
|
-
import { computeSync, syncProfile } from "../services/profile-sync.js";
|
|
7
|
-
import { profileDir } from "../services/symlink-manager.js";
|
|
8
|
-
import type { ProfileManifestEntry } from "../types/index.js";
|
|
9
|
-
|
|
10
|
-
describe("computeSync (pure)", () => {
|
|
11
|
-
const entry: ProfileManifestEntry = {
|
|
12
|
-
name: "Frontend",
|
|
13
|
-
plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
|
|
14
|
-
settings: { effortLevel: "high" },
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
test("adds a newly-enabled plugin, preserving existing pins", () => {
|
|
18
|
-
const { entry: next, changes } = computeSync(
|
|
19
|
-
entry,
|
|
20
|
-
{
|
|
21
|
-
enabledPlugins: {
|
|
22
|
-
"dev@magus": true,
|
|
23
|
-
"terminal@magus": true,
|
|
24
|
-
"designer@magus": true,
|
|
25
|
-
},
|
|
26
|
-
effortLevel: "high",
|
|
27
|
-
},
|
|
28
|
-
{ mcpServers: {} },
|
|
29
|
-
);
|
|
30
|
-
// existing pin preserved (not downgraded to latest)
|
|
31
|
-
expect(next.plugins!["dev@magus"]).toBe("2.9.0");
|
|
32
|
-
// new plugin added as latest
|
|
33
|
-
expect(next.plugins!["designer@magus"]).toBe("latest");
|
|
34
|
-
expect(changes).toContainEqual({
|
|
35
|
-
kind: "plugin-added",
|
|
36
|
-
detail: "designer@magus",
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("removes a plugin that was disabled", () => {
|
|
41
|
-
const { entry: next, changes } = computeSync(
|
|
42
|
-
entry,
|
|
43
|
-
{ enabledPlugins: { "dev@magus": true }, effortLevel: "high" },
|
|
44
|
-
{ mcpServers: {} },
|
|
45
|
-
);
|
|
46
|
-
expect(next.plugins!["terminal@magus"]).toBeUndefined();
|
|
47
|
-
expect(changes).toContainEqual({
|
|
48
|
-
kind: "plugin-removed",
|
|
49
|
-
detail: "terminal@magus",
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("captures a settings change and an mcp change", () => {
|
|
54
|
-
const { entry: next, changes } = computeSync(
|
|
55
|
-
entry,
|
|
56
|
-
{
|
|
57
|
-
enabledPlugins: { "dev@magus": true, "terminal@magus": true },
|
|
58
|
-
effortLevel: "max",
|
|
59
|
-
},
|
|
60
|
-
{ mcpServers: { "chrome-devtools": { command: "npx" } } },
|
|
61
|
-
);
|
|
62
|
-
expect(next.settings!.effortLevel).toBe("max");
|
|
63
|
-
expect(next.mcpServers!["chrome-devtools"]).toEqual({ command: "npx" });
|
|
64
|
-
expect(changes.some((c) => c.kind === "settings-changed")).toBe(true);
|
|
65
|
-
expect(changes.some((c) => c.kind === "mcp-changed")).toBe(true);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("no changes when the materialized state matches the manifest", () => {
|
|
69
|
-
const { changes } = computeSync(
|
|
70
|
-
entry,
|
|
71
|
-
{
|
|
72
|
-
enabledPlugins: { "dev@magus": true, "terminal@magus": true },
|
|
73
|
-
effortLevel: "high",
|
|
74
|
-
},
|
|
75
|
-
{ mcpServers: {} },
|
|
76
|
-
);
|
|
77
|
-
expect(changes).toEqual([]);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("refreshes updatedAt to the injected timestamp when changed", () => {
|
|
81
|
-
const withStamp: ProfileManifestEntry = { ...entry, updatedAt: "OLD" };
|
|
82
|
-
const { entry: changed } = computeSync(
|
|
83
|
-
withStamp,
|
|
84
|
-
{
|
|
85
|
-
enabledPlugins: {
|
|
86
|
-
"dev@magus": true,
|
|
87
|
-
"terminal@magus": true,
|
|
88
|
-
"new@magus": true,
|
|
89
|
-
},
|
|
90
|
-
effortLevel: "high",
|
|
91
|
-
},
|
|
92
|
-
{ mcpServers: {} },
|
|
93
|
-
"2026-07-27T00:00:00.000Z",
|
|
94
|
-
);
|
|
95
|
-
expect(changed.updatedAt).toBe("2026-07-27T00:00:00.000Z");
|
|
96
|
-
|
|
97
|
-
// Unchanged sync leaves updatedAt alone.
|
|
98
|
-
const { entry: same } = computeSync(
|
|
99
|
-
withStamp,
|
|
100
|
-
{
|
|
101
|
-
enabledPlugins: { "dev@magus": true, "terminal@magus": true },
|
|
102
|
-
effortLevel: "high",
|
|
103
|
-
},
|
|
104
|
-
{ mcpServers: {} },
|
|
105
|
-
"2026-07-27T00:00:00.000Z",
|
|
106
|
-
);
|
|
107
|
-
expect(same.updatedAt).toBe("OLD");
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
describe("syncProfile (io)", () => {
|
|
112
|
-
let dir: string;
|
|
113
|
-
beforeEach(async () => {
|
|
114
|
-
dir = await mkdtemp(join(tmpdir(), "sync-"));
|
|
115
|
-
await fs.ensureDir(join(dir, ".claude"));
|
|
116
|
-
await fs.writeJson(join(dir, ".claude", "profiles.json"), {
|
|
117
|
-
version: 2,
|
|
118
|
-
profiles: {
|
|
119
|
-
frontend: { name: "Frontend", plugins: { "dev@magus": "2.9.0" } },
|
|
120
|
-
},
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
afterEach(async () => {
|
|
124
|
-
await rm(dir, { recursive: true, force: true });
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("promotes an edit in the materialized dir into the manifest file", async () => {
|
|
128
|
-
// A teammate enabled a new plugin directly in the active profile's settings.
|
|
129
|
-
const pd = profileDir("frontend", dir);
|
|
130
|
-
await fs.ensureDir(pd);
|
|
131
|
-
await fs.writeJson(join(pd, "settings.json"), {
|
|
132
|
-
enabledPlugins: { "dev@magus": true, "designer@magus": true },
|
|
133
|
-
});
|
|
134
|
-
await fs.writeJson(join(pd, "mcp.json"), { mcpServers: {} });
|
|
135
|
-
|
|
136
|
-
const { changes, wrote } = await syncProfile("frontend", dir);
|
|
137
|
-
expect(wrote).toBe(true);
|
|
138
|
-
expect(changes.some((c) => c.detail === "designer@magus")).toBe(true);
|
|
139
|
-
|
|
140
|
-
const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
|
|
141
|
-
expect(manifest.profiles.frontend.plugins["designer@magus"]).toBe("latest");
|
|
142
|
-
expect(manifest.profiles.frontend.plugins["dev@magus"]).toBe("2.9.0"); // pin kept
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test("no-op when nothing drifted", async () => {
|
|
146
|
-
const pd = profileDir("frontend", dir);
|
|
147
|
-
await fs.ensureDir(pd);
|
|
148
|
-
await fs.writeJson(join(pd, "settings.json"), {
|
|
149
|
-
enabledPlugins: { "dev@magus": true },
|
|
150
|
-
});
|
|
151
|
-
const { wrote } = await syncProfile("frontend", dir);
|
|
152
|
-
expect(wrote).toBe(false);
|
|
153
|
-
});
|
|
154
|
-
});
|