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
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Claude Code edit to the live config in a magus project IS a change to the
|
|
3
|
+
* active profile. It is written into `.claude/profiles.json` — never moved into
|
|
4
|
+
* a backup — and a change to profiles.json itself (a teammate's commit) is
|
|
5
|
+
* regenerated into the live config. Both at once are merged.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
9
|
+
import { mkdtemp, rm, stat } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import fs from "fs-extra";
|
|
13
|
+
import { switchProfile } from "../cli/profile.js";
|
|
14
|
+
import { readManifest, writeManifest } from "../services/manifest.js";
|
|
15
|
+
import type { ModelsConfig } from "../services/models-core.js";
|
|
16
|
+
import { clearModels } from "../services/models-manager.js";
|
|
17
|
+
import {
|
|
18
|
+
computeAbsorb,
|
|
19
|
+
losesNothing,
|
|
20
|
+
merge3,
|
|
21
|
+
reconcileActiveProfile,
|
|
22
|
+
} from "../services/profile-absorb.js";
|
|
23
|
+
import {
|
|
24
|
+
activateProfile,
|
|
25
|
+
regenerateLiveFiles,
|
|
26
|
+
} from "../services/profile-activate.js";
|
|
27
|
+
import {
|
|
28
|
+
backupRoot,
|
|
29
|
+
jsonEqual,
|
|
30
|
+
readActiveMarker,
|
|
31
|
+
writeActiveMarker,
|
|
32
|
+
} from "../services/profile-live.js";
|
|
33
|
+
import { renderProfile } from "../services/profile-render.js";
|
|
34
|
+
import { withCliBookkeeping } from "../services/profile-state.js";
|
|
35
|
+
import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
|
|
36
|
+
|
|
37
|
+
const ROUTING: ModelsConfig = {
|
|
38
|
+
version: 1,
|
|
39
|
+
preset: "test",
|
|
40
|
+
main: { model: "opus", effort: "medium" },
|
|
41
|
+
grades: {
|
|
42
|
+
smart: { model: "fable", effort: "xhigh" },
|
|
43
|
+
normal: { model: "opus", effort: "medium" },
|
|
44
|
+
cheap: { model: "sonnet", effort: "low" },
|
|
45
|
+
},
|
|
46
|
+
agents: { "dev:architect": "smart" },
|
|
47
|
+
fallback: "normal",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function baseManifest(): ProfileManifest {
|
|
51
|
+
return {
|
|
52
|
+
version: 2,
|
|
53
|
+
profiles: {
|
|
54
|
+
team: {
|
|
55
|
+
name: "Team",
|
|
56
|
+
plugins: { "dev@magus": "2.1.0", "terminal@magus": "latest" },
|
|
57
|
+
mcpServers: { srv: { command: "srv" } },
|
|
58
|
+
},
|
|
59
|
+
other: { name: "Other", plugins: { "designer@magus": "latest" } },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let project: string;
|
|
65
|
+
let log: ReturnType<typeof spyOn>;
|
|
66
|
+
|
|
67
|
+
const settingsPath = () => join(project, ".claude", "settings.json");
|
|
68
|
+
const mcpPath = () => join(project, ".mcp.json");
|
|
69
|
+
const modelsPath = () => join(project, ".claude", "models.json");
|
|
70
|
+
|
|
71
|
+
async function start(manifest: ProfileManifest, profile = "team") {
|
|
72
|
+
await writeManifest(manifest, project);
|
|
73
|
+
await activateProfile(manifest, profile, project, { onDisplace: () => {} });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function editLive(
|
|
77
|
+
file: string,
|
|
78
|
+
mutate: (json: Record<string, unknown>) => void,
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const json = await fs.readJson(file);
|
|
81
|
+
mutate(json);
|
|
82
|
+
// Claude Code's writer formats differently from magus; the bytes change.
|
|
83
|
+
await fs.writeFile(file, JSON.stringify(json));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function entry(name = "team"): Promise<ProfileManifestEntry> {
|
|
87
|
+
const manifest = await readManifest(project);
|
|
88
|
+
return manifest.profiles[name] as ProfileManifestEntry;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beforeEach(async () => {
|
|
92
|
+
project = await mkdtemp(join(tmpdir(), "absorb-"));
|
|
93
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
94
|
+
});
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
log.mockRestore();
|
|
97
|
+
await rm(project, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("absorbing Claude Code's edits into the active profile", () => {
|
|
101
|
+
test("a plugin Claude Code installed is added to the profile", async () => {
|
|
102
|
+
await start(baseManifest());
|
|
103
|
+
await editLive(settingsPath(), (s) => {
|
|
104
|
+
(s.enabledPlugins as Record<string, boolean>)["code-search@magus"] = true;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = await reconcileActiveProfile(project);
|
|
108
|
+
|
|
109
|
+
expect(result.lines).toEqual(['Added code-search@magus to profile "team"']);
|
|
110
|
+
expect((await entry()).plugins).toEqual({
|
|
111
|
+
"dev@magus": "2.1.0",
|
|
112
|
+
"terminal@magus": "latest",
|
|
113
|
+
"code-search@magus": "latest",
|
|
114
|
+
});
|
|
115
|
+
// The live file still enables it — nothing was lost or backed up.
|
|
116
|
+
const live = await fs.readJson(settingsPath());
|
|
117
|
+
expect(live.enabledPlugins["code-search@magus"]).toBe(true);
|
|
118
|
+
expect(await fs.pathExists(backupRoot(project))).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("an uninstalled plugin is removed; a pin elsewhere is kept", async () => {
|
|
122
|
+
await start(baseManifest());
|
|
123
|
+
await editLive(settingsPath(), (s) => {
|
|
124
|
+
// biome-ignore lint/performance/noDelete: `claude plugin uninstall` removes the key; undefined would not serialise the same
|
|
125
|
+
delete (s.enabledPlugins as Record<string, boolean>)["terminal@magus"];
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const result = await reconcileActiveProfile(project);
|
|
129
|
+
|
|
130
|
+
expect(result.lines).toEqual([
|
|
131
|
+
'Removed terminal@magus from profile "team"',
|
|
132
|
+
]);
|
|
133
|
+
expect((await entry()).plugins).toEqual({ "dev@magus": "2.1.0" });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a plugin disabled here is recorded as false so it stays off", async () => {
|
|
137
|
+
await start(baseManifest());
|
|
138
|
+
await editLive(settingsPath(), (s) => {
|
|
139
|
+
(s.enabledPlugins as Record<string, boolean>)["terminal@magus"] = false;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await reconcileActiveProfile(project);
|
|
143
|
+
|
|
144
|
+
expect((await entry()).plugins?.["terminal@magus"]).toBe(false);
|
|
145
|
+
const rendered = await renderProfile(await readManifest(project), "team");
|
|
146
|
+
expect(
|
|
147
|
+
(rendered.settings.enabledPlugins as Record<string, boolean>)[
|
|
148
|
+
"terminal@magus"
|
|
149
|
+
],
|
|
150
|
+
).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a changed setting updates the profile's settings", async () => {
|
|
154
|
+
await start(baseManifest());
|
|
155
|
+
await editLive(settingsPath(), (s) => {
|
|
156
|
+
s.permissions = { allow: ["Bash(ls:*)"] };
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const result = await reconcileActiveProfile(project);
|
|
160
|
+
|
|
161
|
+
expect(result.lines).toEqual(['Updated settings of profile "team"']);
|
|
162
|
+
expect((await entry()).settings).toEqual({
|
|
163
|
+
permissions: { allow: ["Bash(ls:*)"] },
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("an MCP server Claude Code added updates the profile's servers", async () => {
|
|
168
|
+
await start(baseManifest());
|
|
169
|
+
await editLive(mcpPath(), (m) => {
|
|
170
|
+
(m.mcpServers as Record<string, unknown>).added = { command: "added" };
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const result = await reconcileActiveProfile(project);
|
|
174
|
+
|
|
175
|
+
expect(result.lines).toEqual(['Updated MCP servers of profile "team"']);
|
|
176
|
+
expect((await entry()).mcpServers).toEqual({
|
|
177
|
+
srv: { command: "srv" },
|
|
178
|
+
added: { command: "added" },
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("an edited models.json maps back to the profile's models field", async () => {
|
|
183
|
+
const manifest = baseManifest();
|
|
184
|
+
(manifest.profiles.team as ProfileManifestEntry).models = ROUTING;
|
|
185
|
+
await start(manifest);
|
|
186
|
+
await editLive(modelsPath(), (m) => {
|
|
187
|
+
(m.agents as Record<string, string>)["dev:developer"] = "cheap";
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const result = await reconcileActiveProfile(project);
|
|
191
|
+
|
|
192
|
+
expect(result.lines).toEqual(['Updated model routing of profile "team"']);
|
|
193
|
+
const e = await entry();
|
|
194
|
+
expect(e.models?.agents).toEqual({
|
|
195
|
+
"dev:architect": "smart",
|
|
196
|
+
"dev:developer": "cheap",
|
|
197
|
+
});
|
|
198
|
+
expect(e.settings).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("the marker records the absorbed file, so a second pass is a no-op", async () => {
|
|
202
|
+
await start(baseManifest());
|
|
203
|
+
await editLive(settingsPath(), (s) => {
|
|
204
|
+
(s.enabledPlugins as Record<string, boolean>)["code-search@magus"] = true;
|
|
205
|
+
});
|
|
206
|
+
await reconcileActiveProfile(project);
|
|
207
|
+
const manifestMtime = (
|
|
208
|
+
await stat(join(project, ".claude", "profiles.json"))
|
|
209
|
+
).mtimeMs;
|
|
210
|
+
|
|
211
|
+
const again = await reconcileActiveProfile(project);
|
|
212
|
+
|
|
213
|
+
expect(again.lines).toEqual([]);
|
|
214
|
+
expect(again.changes).toEqual([]);
|
|
215
|
+
expect(
|
|
216
|
+
(await stat(join(project, ".claude", "profiles.json"))).mtimeMs,
|
|
217
|
+
).toBe(manifestMtime);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("machine-local installedPluginVersions is never absorbed and survives re-render", async () => {
|
|
221
|
+
await start(baseManifest());
|
|
222
|
+
await editLive(settingsPath(), (s) => {
|
|
223
|
+
s.installedPluginVersions = { "dev@magus": "2.1.0" };
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const result = await reconcileActiveProfile(project);
|
|
227
|
+
|
|
228
|
+
expect(result.changes).toEqual([]);
|
|
229
|
+
expect((await entry()).settings).toBeUndefined();
|
|
230
|
+
expect((await fs.readJson(settingsPath())).installedPluginVersions).toEqual(
|
|
231
|
+
{ "dev@magus": "2.1.0" },
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("an unparseable live file is reported and left exactly as it is", async () => {
|
|
236
|
+
await start(baseManifest());
|
|
237
|
+
await fs.writeFile(settingsPath(), "{ not json");
|
|
238
|
+
const before = JSON.stringify(await readManifest(project));
|
|
239
|
+
|
|
240
|
+
const result = await reconcileActiveProfile(project);
|
|
241
|
+
|
|
242
|
+
expect(result.problems).toHaveLength(1);
|
|
243
|
+
expect(result.problems[0]).toContain(".claude/settings.json");
|
|
244
|
+
expect(await fs.readFile(settingsPath(), "utf8")).toBe("{ not json");
|
|
245
|
+
expect(JSON.stringify(await readManifest(project))).toBe(before);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a deleted live file is written again from the profile", async () => {
|
|
249
|
+
await start(baseManifest());
|
|
250
|
+
await fs.remove(mcpPath());
|
|
251
|
+
|
|
252
|
+
const result = await reconcileActiveProfile(project);
|
|
253
|
+
|
|
254
|
+
expect(result.lines).toEqual(['Restored .mcp.json from profile "team"']);
|
|
255
|
+
expect(await fs.readJson(mcpPath())).toEqual({
|
|
256
|
+
mcpServers: { srv: { command: "srv" } },
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("round trip: render(absorb(live)) equals live", () => {
|
|
262
|
+
/** An entry as `magus models use` and a style leave it. */
|
|
263
|
+
function routedEntry(): ProfileManifestEntry {
|
|
264
|
+
return {
|
|
265
|
+
name: "Team",
|
|
266
|
+
plugins: { "dev@magus": "latest" },
|
|
267
|
+
settings: {
|
|
268
|
+
model: "opus",
|
|
269
|
+
effortLevel: "medium",
|
|
270
|
+
modelSettings: { opus: { effortLevel: "medium" } },
|
|
271
|
+
outputStyle: "composed-team",
|
|
272
|
+
},
|
|
273
|
+
models: ROUTING,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
test("models routing and outputStyle map back to their own fields, once", async () => {
|
|
278
|
+
const manifest: ProfileManifest = {
|
|
279
|
+
version: 2,
|
|
280
|
+
profiles: { team: routedEntry() },
|
|
281
|
+
};
|
|
282
|
+
await start(manifest);
|
|
283
|
+
await editLive(settingsPath(), (s) => {
|
|
284
|
+
(s.enabledPlugins as Record<string, boolean>)["code-search@magus"] = true;
|
|
285
|
+
s.permissions = { allow: ["Read"] };
|
|
286
|
+
s.effortLevel = "high";
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
await reconcileActiveProfile(project);
|
|
290
|
+
|
|
291
|
+
const e = await entry();
|
|
292
|
+
// Routing stays in `models`, untouched; nothing of it leaks into settings.
|
|
293
|
+
expect(e.models).toEqual(ROUTING);
|
|
294
|
+
expect(Object.keys(e.settings ?? {}).sort()).toEqual([
|
|
295
|
+
"effortLevel",
|
|
296
|
+
"model",
|
|
297
|
+
"modelSettings",
|
|
298
|
+
"outputStyle",
|
|
299
|
+
"permissions",
|
|
300
|
+
]);
|
|
301
|
+
expect(e.settings?.outputStyle).toBe("composed-team");
|
|
302
|
+
expect(e.settings?.effortLevel).toBe("high");
|
|
303
|
+
expect("models" in (e.settings ?? {})).toBe(false);
|
|
304
|
+
|
|
305
|
+
// And rendering the absorbed entry reproduces the live config.
|
|
306
|
+
const rendered = await renderProfile(await readManifest(project), "team");
|
|
307
|
+
expect(
|
|
308
|
+
jsonEqual(rendered.settings, await fs.readJson(settingsPath())),
|
|
309
|
+
).toBe(true);
|
|
310
|
+
expect(jsonEqual(rendered.models, await fs.readJson(modelsPath()))).toBe(
|
|
311
|
+
true,
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("keys inherited through extends are not duplicated into settings", async () => {
|
|
316
|
+
const withExtends: ProfileManifestEntry = {
|
|
317
|
+
name: "Team",
|
|
318
|
+
extends: "must-have",
|
|
319
|
+
plugins: { "dev@magus": "latest" },
|
|
320
|
+
settings: { outputStyle: "composed-team" },
|
|
321
|
+
};
|
|
322
|
+
const manifest: ProfileManifest = {
|
|
323
|
+
version: 2,
|
|
324
|
+
profiles: { team: withExtends },
|
|
325
|
+
};
|
|
326
|
+
const rendered = await renderProfile(manifest, "team");
|
|
327
|
+
const live = structuredClone(rendered.settings);
|
|
328
|
+
(live.enabledPlugins as Record<string, boolean>)["code-search@magus"] =
|
|
329
|
+
true;
|
|
330
|
+
live.permissions = { allow: ["Read"] };
|
|
331
|
+
|
|
332
|
+
const { entry: absorbed } = computeAbsorb(
|
|
333
|
+
withExtends,
|
|
334
|
+
{ settings: live, mcp: rendered.mcp, models: null },
|
|
335
|
+
new Set(),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// Only what the profile itself says — never must-have's inherited keys.
|
|
339
|
+
expect(absorbed.settings).toEqual({
|
|
340
|
+
outputStyle: "composed-team",
|
|
341
|
+
permissions: { allow: ["Read"] },
|
|
342
|
+
});
|
|
343
|
+
expect(absorbed.plugins).toEqual({
|
|
344
|
+
"dev@magus": "latest",
|
|
345
|
+
"code-search@magus": "latest",
|
|
346
|
+
});
|
|
347
|
+
const again = await renderProfile(
|
|
348
|
+
{ version: 2, profiles: { team: absorbed } },
|
|
349
|
+
"team",
|
|
350
|
+
);
|
|
351
|
+
expect(jsonEqual(again.settings, live)).toBe(true);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("disabling an inherited plugin is expressed as false and round-trips", () => {
|
|
355
|
+
const withExtends: ProfileManifestEntry = {
|
|
356
|
+
name: "Team",
|
|
357
|
+
extends: "must-have",
|
|
358
|
+
};
|
|
359
|
+
const live = {
|
|
360
|
+
enabledPlugins: { "setup@magus": false },
|
|
361
|
+
};
|
|
362
|
+
const { entry: absorbed, changes } = computeAbsorb(
|
|
363
|
+
withExtends,
|
|
364
|
+
{ settings: live, mcp: { mcpServers: {} }, models: null },
|
|
365
|
+
new Set(),
|
|
366
|
+
);
|
|
367
|
+
expect(absorbed.plugins?.["setup@magus"]).toBe(false);
|
|
368
|
+
expect(changes).toContainEqual({
|
|
369
|
+
kind: "plugin-removed",
|
|
370
|
+
plugin: "setup@magus",
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe("losesNothing", () => {
|
|
376
|
+
test("rendered may add false entries, never drop or change a key", () => {
|
|
377
|
+
const live = { enabledPlugins: { a: true }, x: 1 };
|
|
378
|
+
expect(
|
|
379
|
+
losesNothing("settings", live, {
|
|
380
|
+
enabledPlugins: { a: true, b: false },
|
|
381
|
+
x: 1,
|
|
382
|
+
}),
|
|
383
|
+
).toBe(true);
|
|
384
|
+
expect(
|
|
385
|
+
losesNothing("settings", live, { enabledPlugins: { a: true } }),
|
|
386
|
+
).toBe(false);
|
|
387
|
+
expect(
|
|
388
|
+
losesNothing("settings", live, {
|
|
389
|
+
enabledPlugins: { a: true, b: true },
|
|
390
|
+
x: 1,
|
|
391
|
+
}),
|
|
392
|
+
).toBe(false);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe("merge3", () => {
|
|
397
|
+
test("local and remote changes to different keys both survive", () => {
|
|
398
|
+
const base: ProfileManifestEntry = {
|
|
399
|
+
name: "T",
|
|
400
|
+
plugins: { a: "latest" },
|
|
401
|
+
settings: { k: 1 },
|
|
402
|
+
};
|
|
403
|
+
const local: ProfileManifestEntry = {
|
|
404
|
+
name: "T",
|
|
405
|
+
plugins: { a: "latest", mine: "latest" },
|
|
406
|
+
settings: { k: 1 },
|
|
407
|
+
};
|
|
408
|
+
const remote: ProfileManifestEntry = {
|
|
409
|
+
name: "T renamed",
|
|
410
|
+
plugins: { a: "latest", theirs: "latest" },
|
|
411
|
+
settings: { k: 2 },
|
|
412
|
+
};
|
|
413
|
+
expect(merge3(base, local, remote)).toEqual({
|
|
414
|
+
entry: {
|
|
415
|
+
name: "T renamed",
|
|
416
|
+
plugins: { a: "latest", mine: "latest", theirs: "latest" },
|
|
417
|
+
settings: { k: 2 },
|
|
418
|
+
},
|
|
419
|
+
conflicts: [],
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Reviewer S7: `permissions` used to merge as one value, so a local allow
|
|
424
|
+
// rule and a teammate's deny rule could not both survive.
|
|
425
|
+
test("changes under the same top-level key merge recursively", () => {
|
|
426
|
+
const base: ProfileManifestEntry = {
|
|
427
|
+
name: "T",
|
|
428
|
+
settings: { permissions: { allow: ["Bash(bun test:*)"] } },
|
|
429
|
+
};
|
|
430
|
+
const local: ProfileManifestEntry = {
|
|
431
|
+
name: "T",
|
|
432
|
+
settings: { permissions: { allow: ["Bash(bun test:*)", "Bash(ls)"] } },
|
|
433
|
+
};
|
|
434
|
+
const remote: ProfileManifestEntry = {
|
|
435
|
+
name: "T",
|
|
436
|
+
settings: {
|
|
437
|
+
permissions: {
|
|
438
|
+
allow: ["Bash(bun test:*)"],
|
|
439
|
+
deny: ["Bash(rm -rf:*)"],
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
const { entry, conflicts } = merge3(base, local, remote);
|
|
444
|
+
expect(entry.settings).toEqual({
|
|
445
|
+
permissions: {
|
|
446
|
+
allow: ["Bash(bun test:*)", "Bash(ls)"],
|
|
447
|
+
deny: ["Bash(rm -rf:*)"],
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
expect(conflicts).toEqual([]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("arrays merge as sets: remote's additions, local's additions, local's removals", () => {
|
|
454
|
+
const base: ProfileManifestEntry = {
|
|
455
|
+
name: "T",
|
|
456
|
+
settings: { permissions: { allow: ["A", "B", "C"] } },
|
|
457
|
+
};
|
|
458
|
+
// Locally: removed B, added D. Remotely: removed C, added E.
|
|
459
|
+
const local: ProfileManifestEntry = {
|
|
460
|
+
name: "T",
|
|
461
|
+
settings: { permissions: { allow: ["A", "C", "D"] } },
|
|
462
|
+
};
|
|
463
|
+
const remote: ProfileManifestEntry = {
|
|
464
|
+
name: "T",
|
|
465
|
+
settings: { permissions: { allow: ["A", "B", "E"] } },
|
|
466
|
+
};
|
|
467
|
+
expect(merge3(base, local, remote).entry.settings).toEqual({
|
|
468
|
+
permissions: { allow: ["A", "E", "D"] },
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("a true leaf conflict keeps local and names the key path", () => {
|
|
473
|
+
const base: ProfileManifestEntry = {
|
|
474
|
+
name: "T",
|
|
475
|
+
settings: { permissions: { defaultMode: "default", allow: ["A"] } },
|
|
476
|
+
};
|
|
477
|
+
const local: ProfileManifestEntry = {
|
|
478
|
+
name: "T",
|
|
479
|
+
settings: { permissions: { defaultMode: "acceptEdits", allow: ["A"] } },
|
|
480
|
+
};
|
|
481
|
+
const remote: ProfileManifestEntry = {
|
|
482
|
+
name: "T",
|
|
483
|
+
settings: {
|
|
484
|
+
permissions: { defaultMode: "plan", allow: ["A", "B"] },
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
const { entry, conflicts } = merge3(base, local, remote);
|
|
488
|
+
expect(entry.settings).toEqual({
|
|
489
|
+
permissions: { defaultMode: "acceptEdits", allow: ["A", "B"] },
|
|
490
|
+
});
|
|
491
|
+
expect(conflicts).toEqual(["settings.permissions.defaultMode"]);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("reviewer r11: an array of objects is one value — one hook edited on both sides is not duplicated", () => {
|
|
495
|
+
const hook = (command: string, timeout = 10) => ({
|
|
496
|
+
matcher: "Bash",
|
|
497
|
+
hooks: [{ type: "command", command, timeout }],
|
|
498
|
+
});
|
|
499
|
+
const base: ProfileManifestEntry = {
|
|
500
|
+
name: "T",
|
|
501
|
+
settings: { hooks: { PreToolUse: [hook("guard.sh")] } },
|
|
502
|
+
};
|
|
503
|
+
const local: ProfileManifestEntry = {
|
|
504
|
+
name: "T",
|
|
505
|
+
settings: { hooks: { PreToolUse: [hook("guard.sh --strict")] } },
|
|
506
|
+
};
|
|
507
|
+
const remote: ProfileManifestEntry = {
|
|
508
|
+
name: "T",
|
|
509
|
+
settings: { hooks: { PreToolUse: [hook("guard.sh", 30)] } },
|
|
510
|
+
};
|
|
511
|
+
const { entry: merged, conflicts } = merge3(base, local, remote);
|
|
512
|
+
expect(merged.settings).toEqual({
|
|
513
|
+
hooks: { PreToolUse: [hook("guard.sh --strict")] },
|
|
514
|
+
});
|
|
515
|
+
expect(conflicts).toEqual(["settings.hooks.PreToolUse"]);
|
|
516
|
+
// Only one side changed: that side's list, whole.
|
|
517
|
+
expect(merge3(base, base, remote).entry.settings).toEqual(remote.settings);
|
|
518
|
+
expect(merge3(base, local, base).entry.settings).toEqual(local.settings);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('reviewer r11: a teammate\'s version pin beats an absorbed "latest" and is no conflict', () => {
|
|
522
|
+
const { entry: merged, conflicts } = merge3(
|
|
523
|
+
{ name: "T", plugins: {} },
|
|
524
|
+
{ name: "T", plugins: { "x@m": "latest" } },
|
|
525
|
+
{ name: "T", plugins: { "x@m": "1.4.0" } },
|
|
526
|
+
);
|
|
527
|
+
expect(merged.plugins).toEqual({ "x@m": "1.4.0" });
|
|
528
|
+
expect(conflicts).toEqual([]);
|
|
529
|
+
// Two different pins are still a conflict.
|
|
530
|
+
expect(
|
|
531
|
+
merge3(
|
|
532
|
+
{ name: "T", plugins: {} },
|
|
533
|
+
{ name: "T", plugins: { "x@m": "1.3.0" } },
|
|
534
|
+
{ name: "T", plugins: { "x@m": "1.4.0" } },
|
|
535
|
+
).conflicts,
|
|
536
|
+
).toEqual(["plugins.x@m"]);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("an MCP server is merged whole: its args are a command line, not a set", () => {
|
|
540
|
+
const base: ProfileManifestEntry = {
|
|
541
|
+
name: "T",
|
|
542
|
+
mcpServers: { s: { command: "x", args: ["-a"] } },
|
|
543
|
+
};
|
|
544
|
+
const local: ProfileManifestEntry = {
|
|
545
|
+
name: "T",
|
|
546
|
+
mcpServers: { s: { command: "x", args: ["-a", "-b"] } },
|
|
547
|
+
};
|
|
548
|
+
const remote: ProfileManifestEntry = {
|
|
549
|
+
name: "T",
|
|
550
|
+
mcpServers: { s: { command: "x", args: ["-c"] } },
|
|
551
|
+
};
|
|
552
|
+
const { entry, conflicts } = merge3(base, local, remote);
|
|
553
|
+
expect(entry.mcpServers).toEqual({
|
|
554
|
+
s: { command: "x", args: ["-a", "-b"] },
|
|
555
|
+
});
|
|
556
|
+
expect(conflicts).toEqual(["mcpServers.s"]);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
describe("a pulled change and a local edit under the same settings key", () => {
|
|
561
|
+
test("reviewer S7: the local allow rule and the teammate's deny rule both survive", async () => {
|
|
562
|
+
const manifest: ProfileManifest = {
|
|
563
|
+
version: 2,
|
|
564
|
+
profiles: {
|
|
565
|
+
team: {
|
|
566
|
+
name: "Team",
|
|
567
|
+
plugins: { "dev@magus": "latest" },
|
|
568
|
+
settings: { permissions: { allow: ["Bash(bun test:*)"] } },
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
await start(manifest);
|
|
573
|
+
await editLive(settingsPath(), (s) => {
|
|
574
|
+
(s.permissions as { allow: string[] }).allow.push("Bash(ls)");
|
|
575
|
+
});
|
|
576
|
+
const pulled = structuredClone(manifest);
|
|
577
|
+
(
|
|
578
|
+
pulled.profiles.team?.settings as { permissions: Record<string, unknown> }
|
|
579
|
+
).permissions.deny = ["Bash(rm -rf:*)"];
|
|
580
|
+
await writeManifest(pulled, project);
|
|
581
|
+
|
|
582
|
+
const result = await reconcileActiveProfile(project);
|
|
583
|
+
|
|
584
|
+
expect((await entry()).settings).toEqual({
|
|
585
|
+
permissions: {
|
|
586
|
+
allow: ["Bash(bun test:*)", "Bash(ls)"],
|
|
587
|
+
deny: ["Bash(rm -rf:*)"],
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
expect((await fs.readJson(settingsPath())).permissions).toEqual({
|
|
591
|
+
allow: ["Bash(bun test:*)", "Bash(ls)"],
|
|
592
|
+
deny: ["Bash(rm -rf:*)"],
|
|
593
|
+
});
|
|
594
|
+
expect(result.conflicts).toEqual([]);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("reconcile: a plugin Claude Code enabled keeps the version a teammate pinned meanwhile", async () => {
|
|
598
|
+
await start(baseManifest());
|
|
599
|
+
await editLive(settingsPath(), (s) => {
|
|
600
|
+
(s.enabledPlugins as Record<string, boolean>)["code-search@magus"] = true;
|
|
601
|
+
});
|
|
602
|
+
const pulled = baseManifest();
|
|
603
|
+
(pulled.profiles.team as ProfileManifestEntry).plugins = {
|
|
604
|
+
...(pulled.profiles.team as ProfileManifestEntry).plugins,
|
|
605
|
+
"code-search@magus": "8.1.0",
|
|
606
|
+
};
|
|
607
|
+
await writeManifest(pulled, project);
|
|
608
|
+
|
|
609
|
+
const result = await reconcileActiveProfile(project);
|
|
610
|
+
|
|
611
|
+
expect((await entry()).plugins?.["code-search@magus"]).toBe("8.1.0");
|
|
612
|
+
expect(result.conflicts).toEqual([]);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("a leaf changed differently on both sides keeps local, and says which key", async () => {
|
|
616
|
+
const manifest: ProfileManifest = {
|
|
617
|
+
version: 2,
|
|
618
|
+
profiles: {
|
|
619
|
+
team: {
|
|
620
|
+
name: "Team",
|
|
621
|
+
settings: { permissions: { defaultMode: "default" } },
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
await start(manifest);
|
|
626
|
+
await editLive(settingsPath(), (s) => {
|
|
627
|
+
(s.permissions as Record<string, unknown>).defaultMode = "acceptEdits";
|
|
628
|
+
});
|
|
629
|
+
const pulled = structuredClone(manifest);
|
|
630
|
+
(
|
|
631
|
+
pulled.profiles.team?.settings as { permissions: Record<string, unknown> }
|
|
632
|
+
).permissions.defaultMode = "plan";
|
|
633
|
+
await writeManifest(pulled, project);
|
|
634
|
+
|
|
635
|
+
const result = await reconcileActiveProfile(project);
|
|
636
|
+
|
|
637
|
+
expect(
|
|
638
|
+
((await entry()).settings as { permissions: Record<string, unknown> })
|
|
639
|
+
.permissions.defaultMode,
|
|
640
|
+
).toBe("acceptEdits");
|
|
641
|
+
expect(result.lines).toContain(
|
|
642
|
+
'Profile "team": settings.permissions.defaultMode was changed both here and in profiles.json — kept the local value; the other is in git history',
|
|
643
|
+
);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
describe("regenerating when profiles.json changes underneath", () => {
|
|
648
|
+
test("a pulled change is regenerated into the live config, without a prompt or backup", async () => {
|
|
649
|
+
await start(baseManifest());
|
|
650
|
+
// A teammate's commit arrives by git pull.
|
|
651
|
+
const pulled = baseManifest();
|
|
652
|
+
(pulled.profiles.team as ProfileManifestEntry).plugins = {
|
|
653
|
+
...(pulled.profiles.team as ProfileManifestEntry).plugins,
|
|
654
|
+
"mnemex@magus": "latest",
|
|
655
|
+
};
|
|
656
|
+
await writeManifest(pulled, project);
|
|
657
|
+
|
|
658
|
+
const result = await reconcileActiveProfile(project);
|
|
659
|
+
|
|
660
|
+
expect(result.lines).toEqual([
|
|
661
|
+
'Profile "team" changed in profiles.json — regenerated .claude/settings.json',
|
|
662
|
+
]);
|
|
663
|
+
expect(
|
|
664
|
+
(await fs.readJson(settingsPath())).enabledPlugins["mnemex@magus"],
|
|
665
|
+
).toBe(true);
|
|
666
|
+
expect(await fs.pathExists(backupRoot(project))).toBe(false);
|
|
667
|
+
// Recorded: the next pass has nothing to do.
|
|
668
|
+
expect((await reconcileActiveProfile(project)).lines).toEqual([]);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("a local edit alone is absorbed and nothing is regenerated", async () => {
|
|
672
|
+
await start(baseManifest());
|
|
673
|
+
await editLive(settingsPath(), (s) => {
|
|
674
|
+
(s.enabledPlugins as Record<string, boolean>)["code-search@magus"] = true;
|
|
675
|
+
});
|
|
676
|
+
const result = await reconcileActiveProfile(project);
|
|
677
|
+
expect(result.regenerated).toEqual([]);
|
|
678
|
+
expect(result.changes).toHaveLength(1);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("both at once, touching different plugins: both survive", async () => {
|
|
682
|
+
await start(baseManifest());
|
|
683
|
+
// Locally, Claude Code installs code-search...
|
|
684
|
+
await editLive(settingsPath(), (s) => {
|
|
685
|
+
(s.enabledPlugins as Record<string, boolean>)["code-search@magus"] = true;
|
|
686
|
+
});
|
|
687
|
+
// ...while a teammate's commit adds mnemex and drops terminal.
|
|
688
|
+
const pulled = baseManifest();
|
|
689
|
+
(pulled.profiles.team as ProfileManifestEntry).plugins = {
|
|
690
|
+
"dev@magus": "2.1.0",
|
|
691
|
+
"mnemex@magus": "latest",
|
|
692
|
+
};
|
|
693
|
+
await writeManifest(pulled, project);
|
|
694
|
+
|
|
695
|
+
const result = await reconcileActiveProfile(project);
|
|
696
|
+
|
|
697
|
+
expect(result.lines[0]).toBe('Added code-search@magus to profile "team"');
|
|
698
|
+
expect(result.lines[1]).toContain(
|
|
699
|
+
'Profile "team" changed in profiles.json',
|
|
700
|
+
);
|
|
701
|
+
expect((await entry()).plugins).toEqual({
|
|
702
|
+
"dev@magus": "2.1.0",
|
|
703
|
+
"mnemex@magus": "latest",
|
|
704
|
+
"code-search@magus": "latest",
|
|
705
|
+
});
|
|
706
|
+
const enabled = (await fs.readJson(settingsPath())).enabledPlugins;
|
|
707
|
+
expect(enabled["code-search@magus"]).toBe(true);
|
|
708
|
+
expect(enabled["mnemex@magus"]).toBe(true);
|
|
709
|
+
expect(enabled["terminal@magus"]).toBeUndefined();
|
|
710
|
+
expect(enabled["dev@magus"]).toBe(true);
|
|
711
|
+
const marker = await readActiveMarker(project);
|
|
712
|
+
expect(marker?.base).toEqual(await entry());
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
describe("profile switch", () => {
|
|
717
|
+
test("absorbs into the OLD profile, then renders the new one with zero backups", async () => {
|
|
718
|
+
await start(baseManifest());
|
|
719
|
+
await editLive(settingsPath(), (s) => {
|
|
720
|
+
(s.enabledPlugins as Record<string, boolean>)["code-search@magus"] = true;
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const code = await switchProfile("other", project);
|
|
724
|
+
|
|
725
|
+
expect(code).toBe(0);
|
|
726
|
+
expect((await entry("team")).plugins?.["code-search@magus"]).toBe("latest");
|
|
727
|
+
expect((await entry("other")).plugins).toEqual({
|
|
728
|
+
"designer@magus": "latest",
|
|
729
|
+
});
|
|
730
|
+
const live = await fs.readJson(settingsPath());
|
|
731
|
+
expect(live.enabledPlugins["designer@magus"]).toBe(true);
|
|
732
|
+
// code-search now belongs to "team", so "other" turns it off explicitly.
|
|
733
|
+
expect(live.enabledPlugins["code-search@magus"]).toBe(false);
|
|
734
|
+
expect(await fs.pathExists(backupRoot(project))).toBe(false);
|
|
735
|
+
expect((await readActiveMarker(project))?.profile).toBe("other");
|
|
736
|
+
const printed = log.mock.calls.map((c) => String(c[0])).join("\n");
|
|
737
|
+
expect(printed).toContain('Added code-search@magus to profile "team"');
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
describe("magus's own Claude CLI writes are not profile changes", () => {
|
|
742
|
+
test("regenerateLiveFiles renders over `claude plugin install` bookkeeping", async () => {
|
|
743
|
+
await start(baseManifest());
|
|
744
|
+
// `magus install` with no argument installs EVERY profile's plugins at
|
|
745
|
+
// project scope; the CLI records each in the live settings.json.
|
|
746
|
+
await editLive(settingsPath(), (s) => {
|
|
747
|
+
(s.enabledPlugins as Record<string, boolean>)["designer@magus"] = true;
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
expect(await regenerateLiveFiles(project)).toBe(true);
|
|
751
|
+
|
|
752
|
+
// "other" owns designer; "team" keeps it off, and the profile is unchanged.
|
|
753
|
+
expect(
|
|
754
|
+
(await fs.readJson(settingsPath())).enabledPlugins["designer@magus"],
|
|
755
|
+
).toBe(false);
|
|
756
|
+
expect((await entry()).plugins).toEqual(
|
|
757
|
+
baseManifest().profiles.team?.plugins as never,
|
|
758
|
+
);
|
|
759
|
+
expect((await reconcileActiveProfile(project)).lines).toEqual([]);
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
describe("the definition covers the whole render input", () => {
|
|
764
|
+
test("a pulled change to ANOTHER profile's plugins regenerates the active live file", async () => {
|
|
765
|
+
await start(baseManifest());
|
|
766
|
+
const before = await fs.readFile(settingsPath(), "utf8");
|
|
767
|
+
// A teammate adds a plugin to "other". "team" did not change, but its
|
|
768
|
+
// settings.json must now say `false` for it, or the plugin would fall
|
|
769
|
+
// through to user scope and stay on here.
|
|
770
|
+
const pulled = baseManifest();
|
|
771
|
+
(pulled.profiles.other as ProfileManifestEntry).plugins = {
|
|
772
|
+
"designer@magus": "latest",
|
|
773
|
+
"mnemex@magus": "latest",
|
|
774
|
+
};
|
|
775
|
+
await writeManifest(pulled, project);
|
|
776
|
+
|
|
777
|
+
const result = await reconcileActiveProfile(project);
|
|
778
|
+
|
|
779
|
+
expect(result.lines).toEqual([
|
|
780
|
+
'Another profile\'s plugins changed in profiles.json — regenerated .claude/settings.json for profile "team"',
|
|
781
|
+
]);
|
|
782
|
+
const live = await fs.readJson(settingsPath());
|
|
783
|
+
expect(live.enabledPlugins["mnemex@magus"]).toBe(false);
|
|
784
|
+
expect(await fs.readFile(settingsPath(), "utf8")).not.toBe(before);
|
|
785
|
+
// The active entry itself was not touched.
|
|
786
|
+
expect(await entry()).toEqual(baseManifest().profiles.team as never);
|
|
787
|
+
expect(await fs.pathExists(backupRoot(project))).toBe(false);
|
|
788
|
+
// Recorded: the next pass has nothing to do.
|
|
789
|
+
expect((await reconcileActiveProfile(project)).lines).toEqual([]);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("a change to another profile that renders nothing here regenerates nothing", async () => {
|
|
793
|
+
await start(baseManifest());
|
|
794
|
+
const before = await fs.readFile(settingsPath(), "utf8");
|
|
795
|
+
const pulled = baseManifest();
|
|
796
|
+
(pulled.profiles.other as ProfileManifestEntry).mcpServers = {
|
|
797
|
+
theirs: { command: "theirs" },
|
|
798
|
+
};
|
|
799
|
+
await writeManifest(pulled, project);
|
|
800
|
+
|
|
801
|
+
const result = await reconcileActiveProfile(project);
|
|
802
|
+
|
|
803
|
+
expect(result.lines).toEqual([]);
|
|
804
|
+
expect(result.regenerated).toEqual([]);
|
|
805
|
+
expect(await fs.readFile(settingsPath(), "utf8")).toBe(before);
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
describe("the recorded profile no longer exists in profiles.json", () => {
|
|
810
|
+
/** Activate "ghost", then remove it from profiles.json, as a pull might. */
|
|
811
|
+
async function orphan(): Promise<void> {
|
|
812
|
+
const manifest: ProfileManifest = {
|
|
813
|
+
version: 2,
|
|
814
|
+
profiles: {
|
|
815
|
+
default: { name: "Default", plugins: { "dev@magus": "latest" } },
|
|
816
|
+
ghost: { name: "Ghost", plugins: { "terminal@magus": "latest" } },
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
await start(manifest, "ghost");
|
|
820
|
+
await writeManifest(
|
|
821
|
+
{
|
|
822
|
+
version: 2,
|
|
823
|
+
profiles: { default: manifest.profiles.default as never },
|
|
824
|
+
},
|
|
825
|
+
project,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
test("switching keeps Claude Code's edit, in the target profile and the live file", async () => {
|
|
830
|
+
await orphan();
|
|
831
|
+
// Claude Code edits the live file while "ghost" is recorded.
|
|
832
|
+
await editLive(settingsPath(), (s) => {
|
|
833
|
+
s.spinnerTipsEnabled = false;
|
|
834
|
+
(s.enabledPlugins as Record<string, boolean>)["code-search@magus"] = true;
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
expect(await switchProfile("default", project)).toBe(0);
|
|
838
|
+
|
|
839
|
+
const e = await entry("default");
|
|
840
|
+
expect(e.settings).toEqual({ spinnerTipsEnabled: false });
|
|
841
|
+
expect(e.plugins).toEqual({
|
|
842
|
+
"dev@magus": "latest",
|
|
843
|
+
"code-search@magus": "latest",
|
|
844
|
+
});
|
|
845
|
+
// Only the EDIT moved over: ghost's own plugin did not.
|
|
846
|
+
expect(e.plugins?.["terminal@magus"]).toBeUndefined();
|
|
847
|
+
const live = await fs.readJson(settingsPath());
|
|
848
|
+
expect(live.spinnerTipsEnabled).toBe(false);
|
|
849
|
+
expect(live.enabledPlugins["code-search@magus"]).toBe(true);
|
|
850
|
+
expect(live.enabledPlugins["dev@magus"]).toBe(true);
|
|
851
|
+
expect((await readActiveMarker(project))?.profile).toBe("default");
|
|
852
|
+
expect(await fs.pathExists(backupRoot(project))).toBe(false);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test("the message names the vanished profile and the one that received its changes", async () => {
|
|
856
|
+
await orphan();
|
|
857
|
+
await editLive(settingsPath(), (s) => {
|
|
858
|
+
s.spinnerTipsEnabled = false;
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
await switchProfile("default", project);
|
|
862
|
+
|
|
863
|
+
const printed = log.mock.calls.map((c) => String(c[0])).join("\n");
|
|
864
|
+
expect(printed).toContain(
|
|
865
|
+
'Profile "ghost" no longer exists in profiles.json — its unsaved changes were added to profile "default"',
|
|
866
|
+
);
|
|
867
|
+
expect(printed).toContain('Updated settings of profile "default"');
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
test("with no recorded base, every key the live file holds is kept, and it says so", async () => {
|
|
871
|
+
await writeManifest(
|
|
872
|
+
{
|
|
873
|
+
version: 2,
|
|
874
|
+
profiles: {
|
|
875
|
+
default: { name: "Default", plugins: { "dev@magus": "latest" } },
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
project,
|
|
879
|
+
);
|
|
880
|
+
// An earlier release's marker: no definition, no base, stale hashes.
|
|
881
|
+
await fs.outputJson(join(project, ".claude", "_profiles", ".active"), {
|
|
882
|
+
version: 1,
|
|
883
|
+
profile: "ghost",
|
|
884
|
+
files: { settings: "stale" },
|
|
885
|
+
});
|
|
886
|
+
await fs.outputJson(settingsPath(), {
|
|
887
|
+
enabledPlugins: { "x@y": true },
|
|
888
|
+
spinnerTipsEnabled: false,
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
expect(await switchProfile("default", project)).toBe(0);
|
|
892
|
+
|
|
893
|
+
const e = await entry("default");
|
|
894
|
+
expect(e.plugins).toEqual({ "dev@magus": "latest", "x@y": "latest" });
|
|
895
|
+
expect(e.settings).toEqual({ spinnerTipsEnabled: false });
|
|
896
|
+
const live = await fs.readJson(settingsPath());
|
|
897
|
+
expect(live.spinnerTipsEnabled).toBe(false);
|
|
898
|
+
expect(live.enabledPlugins["x@y"]).toBe(true);
|
|
899
|
+
const printed = log.mock.calls.map((c) => String(c[0])).join("\n");
|
|
900
|
+
expect(printed).toContain('Profile "ghost" no longer exists');
|
|
901
|
+
expect(printed).toContain('There is no record of what "ghost" contained');
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("with nothing edited, switching changes no profile and says nothing about it", async () => {
|
|
905
|
+
await orphan();
|
|
906
|
+
await switchProfile("default", project);
|
|
907
|
+
const printed = log.mock.calls.map((c) => String(c[0])).join("\n");
|
|
908
|
+
expect(printed).not.toContain("no longer exists");
|
|
909
|
+
expect(await entry("default")).toEqual({
|
|
910
|
+
name: "Default",
|
|
911
|
+
plugins: { "dev@magus": "latest" },
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
describe("a live file that cannot be read is left exactly as it is", () => {
|
|
917
|
+
const broken =
|
|
918
|
+
'{\n "enabledPlugins": { "team@magus": true },\n "permissions": { "allow": ["Bash(make deploy)",] }\n}\n';
|
|
919
|
+
|
|
920
|
+
test("unparseable settings.json + a changed profiles.json: bytes unchanged, nothing written", async () => {
|
|
921
|
+
await start(baseManifest());
|
|
922
|
+
await fs.writeFile(settingsPath(), broken);
|
|
923
|
+
// A teammate's pull changes profiles.json — the case that used to
|
|
924
|
+
// regenerate right over the file the problem said was left alone.
|
|
925
|
+
const pulled = baseManifest();
|
|
926
|
+
(pulled.profiles.other as ProfileManifestEntry).plugins = {
|
|
927
|
+
"designer@magus": "latest",
|
|
928
|
+
"y@magus": "latest",
|
|
929
|
+
};
|
|
930
|
+
await writeManifest(pulled, project);
|
|
931
|
+
const manifestBefore = await fs.readFile(
|
|
932
|
+
join(project, ".claude", "profiles.json"),
|
|
933
|
+
"utf8",
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
const result = await reconcileActiveProfile(project);
|
|
937
|
+
|
|
938
|
+
expect(result.problems).toHaveLength(1);
|
|
939
|
+
expect(result.unreadable).toHaveLength(1);
|
|
940
|
+
expect(result.regenerated).toEqual([]);
|
|
941
|
+
expect(await fs.readFile(settingsPath(), "utf8")).toBe(broken);
|
|
942
|
+
expect(
|
|
943
|
+
await fs.readFile(join(project, ".claude", "profiles.json"), "utf8"),
|
|
944
|
+
).toBe(manifestBefore);
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
test("regenerateLiveFiles refuses rather than render over it", async () => {
|
|
948
|
+
await start(baseManifest());
|
|
949
|
+
await fs.writeFile(settingsPath(), broken);
|
|
950
|
+
await expect(regenerateLiveFiles(project)).rejects.toThrow(
|
|
951
|
+
/refusing to overwrite/,
|
|
952
|
+
);
|
|
953
|
+
expect(await fs.readFile(settingsPath(), "utf8")).toBe(broken);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test("clearModels (the TUI Models screen's call) refuses and writes nothing", async () => {
|
|
957
|
+
const manifest = baseManifest();
|
|
958
|
+
(manifest.profiles.team as ProfileManifestEntry).models = ROUTING;
|
|
959
|
+
await start(manifest);
|
|
960
|
+
await fs.writeFile(settingsPath(), broken);
|
|
961
|
+
const modelsBefore = await fs.readFile(modelsPath(), "utf8");
|
|
962
|
+
const configDir = await mkdtemp(join(tmpdir(), "absorb-config-"));
|
|
963
|
+
const previous = process.env.CLAUDE_CONFIG_DIR;
|
|
964
|
+
process.env.CLAUDE_CONFIG_DIR = configDir;
|
|
965
|
+
try {
|
|
966
|
+
await expect(clearModels(project)).rejects.toThrow(
|
|
967
|
+
/refusing to overwrite/,
|
|
968
|
+
);
|
|
969
|
+
} finally {
|
|
970
|
+
// biome-ignore lint/performance/noDelete: an unset env var must be absent, not "undefined"
|
|
971
|
+
if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
972
|
+
else process.env.CLAUDE_CONFIG_DIR = previous;
|
|
973
|
+
await rm(configDir, { recursive: true, force: true });
|
|
974
|
+
}
|
|
975
|
+
expect(await fs.readFile(settingsPath(), "utf8")).toBe(broken);
|
|
976
|
+
expect(await fs.readFile(modelsPath(), "utf8")).toBe(modelsBefore);
|
|
977
|
+
expect((await entry()).models).toEqual(ROUTING);
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
describe("the Claude CLI's install bookkeeping is never absorbed", () => {
|
|
982
|
+
/** What `claude plugin install --scope project` does to the live file. */
|
|
983
|
+
async function cliInstall(id: string): Promise<void> {
|
|
984
|
+
await editLive(settingsPath(), (s) => {
|
|
985
|
+
(s.enabledPlugins as Record<string, boolean>)[id] = true;
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
test("a throw part-way through the install loop still renders the profile over it", async () => {
|
|
990
|
+
await start(baseManifest());
|
|
991
|
+
await expect(
|
|
992
|
+
withCliBookkeeping(project, async () => {
|
|
993
|
+
await cliInstall("designer@magus");
|
|
994
|
+
throw new Error("interrupted");
|
|
995
|
+
}),
|
|
996
|
+
).rejects.toThrow("interrupted");
|
|
997
|
+
|
|
998
|
+
expect(
|
|
999
|
+
(await fs.readJson(settingsPath())).enabledPlugins["designer@magus"],
|
|
1000
|
+
).toBe(false);
|
|
1001
|
+
expect((await readActiveMarker(project))?.cliBookkeeping).toBeUndefined();
|
|
1002
|
+
const next = await reconcileActiveProfile(project);
|
|
1003
|
+
expect(next.lines).toEqual([]);
|
|
1004
|
+
expect((await entry()).plugins).toEqual(
|
|
1005
|
+
baseManifest().profiles.team?.plugins as never,
|
|
1006
|
+
);
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
test("a run killed part-way leaves the flag, and the next reconcile discards only the bookkeeping", async () => {
|
|
1010
|
+
await start(baseManifest());
|
|
1011
|
+
const marker = await readActiveMarker(project);
|
|
1012
|
+
if (!marker) throw new Error("no marker");
|
|
1013
|
+
// What withCliBookkeeping writes before the loop; `finally` never ran.
|
|
1014
|
+
await writeActiveMarker({ ...marker, cliBookkeeping: true }, project);
|
|
1015
|
+
// The CLI's record: a plugin another profile names.
|
|
1016
|
+
await cliInstall("designer@magus");
|
|
1017
|
+
// ...and a real edit made meanwhile, which must still be kept.
|
|
1018
|
+
await editLive(settingsPath(), (s) => {
|
|
1019
|
+
s.permissions = { allow: ["Bash(ls)"] };
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
const result = await reconcileActiveProfile(project);
|
|
1023
|
+
|
|
1024
|
+
expect(result.lines[0]).toBe(
|
|
1025
|
+
"An interrupted magus install left the Claude CLI's plugin records in .claude/settings.json — discarded them (designer@magus)",
|
|
1026
|
+
);
|
|
1027
|
+
const e = await entry();
|
|
1028
|
+
expect(e.plugins).toEqual(baseManifest().profiles.team?.plugins as never);
|
|
1029
|
+
expect(e.settings).toEqual({ permissions: { allow: ["Bash(ls)"] } });
|
|
1030
|
+
const live = await fs.readJson(settingsPath());
|
|
1031
|
+
expect(live.enabledPlugins["designer@magus"]).toBe(false);
|
|
1032
|
+
expect(live.permissions).toEqual({ allow: ["Bash(ls)"] });
|
|
1033
|
+
expect((await readActiveMarker(project))?.cliBookkeeping).toBeUndefined();
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
test("reviewer r9a: after a killed run, a plugin the user installed themselves is kept", async () => {
|
|
1037
|
+
await start(baseManifest());
|
|
1038
|
+
const marker = await readActiveMarker(project);
|
|
1039
|
+
if (!marker) throw new Error("no marker");
|
|
1040
|
+
await writeActiveMarker({ ...marker, cliBookkeeping: true }, project);
|
|
1041
|
+
await cliInstall("designer@magus"); // the CLI, before the kill
|
|
1042
|
+
await cliInstall("my-own@market"); // the user, afterwards — no profile names it
|
|
1043
|
+
// ...and a `false` the user set is theirs too.
|
|
1044
|
+
await editLive(settingsPath(), (s) => {
|
|
1045
|
+
(s.enabledPlugins as Record<string, boolean>)["terminal@magus"] = false;
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
const result = await reconcileActiveProfile(project);
|
|
1049
|
+
|
|
1050
|
+
expect(result.lines).toContain('Added my-own@market to profile "team"');
|
|
1051
|
+
expect(result.lines).toContain(
|
|
1052
|
+
'Removed terminal@magus from profile "team"',
|
|
1053
|
+
);
|
|
1054
|
+
expect(result.lines.join("\n")).toContain(
|
|
1055
|
+
"discarded them (designer@magus)",
|
|
1056
|
+
);
|
|
1057
|
+
expect((await entry()).plugins).toEqual({
|
|
1058
|
+
"dev@magus": "2.1.0",
|
|
1059
|
+
"terminal@magus": false,
|
|
1060
|
+
"my-own@market": "latest",
|
|
1061
|
+
});
|
|
1062
|
+
const live = await fs.readJson(settingsPath());
|
|
1063
|
+
expect(live.enabledPlugins["my-own@market"]).toBe(true);
|
|
1064
|
+
expect(live.enabledPlugins["designer@magus"]).toBe(false);
|
|
1065
|
+
expect(live.enabledPlugins["terminal@magus"]).toBe(false);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
test("reviewer r9b: an edit Claude Code writes while the installs run is kept, not overwritten", async () => {
|
|
1069
|
+
await start(baseManifest());
|
|
1070
|
+
const reported: string[] = [];
|
|
1071
|
+
await withCliBookkeeping(
|
|
1072
|
+
project,
|
|
1073
|
+
async () => {
|
|
1074
|
+
await cliInstall("designer@magus"); // the CLI's record
|
|
1075
|
+
await editLive(settingsPath(), (s) => {
|
|
1076
|
+
s.permissions = { allow: ["Bash(make deploy)"] }; // "always allow"
|
|
1077
|
+
});
|
|
1078
|
+
},
|
|
1079
|
+
{ report: (line) => reported.push(line) },
|
|
1080
|
+
);
|
|
1081
|
+
|
|
1082
|
+
expect(reported).toEqual(['Updated settings of profile "team"']);
|
|
1083
|
+
expect((await entry()).settings).toEqual({
|
|
1084
|
+
permissions: { allow: ["Bash(make deploy)"] },
|
|
1085
|
+
});
|
|
1086
|
+
const live = await fs.readJson(settingsPath());
|
|
1087
|
+
expect(live.permissions).toEqual({ allow: ["Bash(make deploy)"] });
|
|
1088
|
+
expect(live.enabledPlugins["designer@magus"]).toBe(false);
|
|
1089
|
+
expect((await readActiveMarker(project))?.cliBookkeeping).toBeUndefined();
|
|
1090
|
+
expect((await reconcileActiveProfile(project)).lines).toEqual([]);
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
describe("a false left over from an older render is not a disable", () => {
|
|
1095
|
+
test("reviewer S3: a teammate drops a plugin from another profile while an edit is pending", async () => {
|
|
1096
|
+
const manifest: ProfileManifest = {
|
|
1097
|
+
version: 2,
|
|
1098
|
+
profiles: {
|
|
1099
|
+
team: { name: "Team", plugins: { "dev@magus": "latest" } },
|
|
1100
|
+
other: {
|
|
1101
|
+
name: "Other",
|
|
1102
|
+
plugins: { "terminal@magus": "latest", "x@magus": "latest" },
|
|
1103
|
+
},
|
|
1104
|
+
},
|
|
1105
|
+
};
|
|
1106
|
+
await start(manifest);
|
|
1107
|
+
// The live file says `x@magus: false` — the render's own output.
|
|
1108
|
+
await editLive(settingsPath(), (s) => {
|
|
1109
|
+
s.permissions = { allow: ["Bash(ls)"] };
|
|
1110
|
+
});
|
|
1111
|
+
const pulled = structuredClone(manifest);
|
|
1112
|
+
// biome-ignore lint/performance/noDelete: the teammate's edit removes the key from profiles.json
|
|
1113
|
+
delete (pulled.profiles.other?.plugins as Record<string, unknown>)[
|
|
1114
|
+
"x@magus"
|
|
1115
|
+
];
|
|
1116
|
+
await writeManifest(pulled, project);
|
|
1117
|
+
|
|
1118
|
+
const result = await reconcileActiveProfile(project);
|
|
1119
|
+
|
|
1120
|
+
expect(result.lines.join("\n")).not.toContain("Disabled x@magus");
|
|
1121
|
+
expect((await entry()).plugins).toEqual({ "dev@magus": "latest" });
|
|
1122
|
+
expect((await entry()).settings).toEqual({
|
|
1123
|
+
permissions: { allow: ["Bash(ls)"] },
|
|
1124
|
+
});
|
|
1125
|
+
expect(
|
|
1126
|
+
(await fs.readJson(settingsPath())).enabledPlugins["x@magus"],
|
|
1127
|
+
).toBeUndefined();
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
test("the marker records the plugin ids the render wrote", async () => {
|
|
1131
|
+
await start(baseManifest());
|
|
1132
|
+
expect((await readActiveMarker(project))?.allPluginIds).toEqual([
|
|
1133
|
+
"designer@magus",
|
|
1134
|
+
"dev@magus",
|
|
1135
|
+
"terminal@magus",
|
|
1136
|
+
]);
|
|
1137
|
+
});
|
|
1138
|
+
});
|