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,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.claude/skills/` is a real directory: magus-installed folders for the
|
|
3
|
+
* active profile's skills, and custom folders magus never touches.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { lstat, mkdtemp, rm } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import fs from "fs-extra";
|
|
12
|
+
import { switchProfile } from "../cli/profile.js";
|
|
13
|
+
import { readManifest, writeManifest } from "../services/manifest.js";
|
|
14
|
+
import { reconcileActiveProfile } from "../services/profile-absorb.js";
|
|
15
|
+
import {
|
|
16
|
+
activateProfile,
|
|
17
|
+
activationNotes,
|
|
18
|
+
recordRefetchedSkills,
|
|
19
|
+
trackFetchedSkills,
|
|
20
|
+
updatableSkills,
|
|
21
|
+
} from "../services/profile-activate.js";
|
|
22
|
+
import {
|
|
23
|
+
backupRoot,
|
|
24
|
+
folderHash,
|
|
25
|
+
readActiveMarker,
|
|
26
|
+
skillsRoot,
|
|
27
|
+
} from "../services/profile-live.js";
|
|
28
|
+
import {
|
|
29
|
+
SkillClashError,
|
|
30
|
+
type SkillInstaller,
|
|
31
|
+
} from "../services/profile-skills.js";
|
|
32
|
+
import { prepareProfileState } from "../services/profile-state.js";
|
|
33
|
+
import type { ProfileManifest, ProfileSkillRef } from "../types/index.js";
|
|
34
|
+
|
|
35
|
+
const ALPHA: ProfileSkillRef = {
|
|
36
|
+
name: "alpha",
|
|
37
|
+
repo: "acme/skills",
|
|
38
|
+
path: "skills/alpha",
|
|
39
|
+
};
|
|
40
|
+
const BETA: ProfileSkillRef = {
|
|
41
|
+
name: "beta",
|
|
42
|
+
repo: "acme/skills",
|
|
43
|
+
path: "skills/beta",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function manifest(): ProfileManifest {
|
|
47
|
+
return {
|
|
48
|
+
version: 2,
|
|
49
|
+
profiles: {
|
|
50
|
+
team: {
|
|
51
|
+
name: "Team",
|
|
52
|
+
plugins: { "dev@magus": "latest" },
|
|
53
|
+
skills: [ALPHA],
|
|
54
|
+
},
|
|
55
|
+
other: {
|
|
56
|
+
name: "Other",
|
|
57
|
+
plugins: { "terminal@magus": "latest" },
|
|
58
|
+
skills: [BETA],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Installs a skill offline: one SKILL.md naming where it came from. */
|
|
65
|
+
const fakeInstall: SkillInstaller = async (ref, projectPath) => {
|
|
66
|
+
await fs.outputFile(
|
|
67
|
+
join(skillsRoot(projectPath), ref.name, "SKILL.md"),
|
|
68
|
+
`# ${ref.name} from ${ref.repo}\n`,
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
let project: string;
|
|
73
|
+
let log: ReturnType<typeof spyOn>;
|
|
74
|
+
let err: ReturnType<typeof spyOn>;
|
|
75
|
+
|
|
76
|
+
const skill = (folder: string) => join(skillsRoot(project), folder);
|
|
77
|
+
const gitignoreLines = async () =>
|
|
78
|
+
(await fs.readFile(join(project, ".gitignore"), "utf8"))
|
|
79
|
+
.split("\n")
|
|
80
|
+
.map((l) => l.trim());
|
|
81
|
+
|
|
82
|
+
async function start(m = manifest(), profile = "team") {
|
|
83
|
+
await writeManifest(m, project);
|
|
84
|
+
return activateProfile(m, profile, project, {
|
|
85
|
+
onDisplace: () => {},
|
|
86
|
+
installSkill: fakeInstall,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
project = await mkdtemp(join(tmpdir(), "skills-"));
|
|
92
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
93
|
+
err = spyOn(console, "error").mockImplementation(() => {});
|
|
94
|
+
});
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
log.mockRestore();
|
|
97
|
+
err.mockRestore();
|
|
98
|
+
await rm(project, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("magus-installed skills", () => {
|
|
102
|
+
test("are installed into real folders, hashed in the marker, ignored one by one", async () => {
|
|
103
|
+
await fs.outputFile(join(skill("mine"), "SKILL.md"), "# custom\n");
|
|
104
|
+
|
|
105
|
+
await start();
|
|
106
|
+
|
|
107
|
+
expect((await lstat(skillsRoot(project))).isDirectory()).toBe(true);
|
|
108
|
+
expect((await lstat(skillsRoot(project))).isSymbolicLink()).toBe(false);
|
|
109
|
+
expect(await fs.readFile(join(skill("alpha"), "SKILL.md"), "utf8")).toBe(
|
|
110
|
+
"# alpha from acme/skills\n",
|
|
111
|
+
);
|
|
112
|
+
const marker = await readActiveMarker(project);
|
|
113
|
+
expect(marker?.skills).toEqual({
|
|
114
|
+
alpha: (await folderHash(skill("alpha"))) as string,
|
|
115
|
+
});
|
|
116
|
+
const lines = await gitignoreLines();
|
|
117
|
+
expect(lines).toContain(".claude/skills/alpha/");
|
|
118
|
+
expect(lines).not.toContain(".claude/skills/mine/");
|
|
119
|
+
expect(lines).not.toContain(".claude/skills");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("switching removes the old profile's skills, installs the new, leaves custom alone", async () => {
|
|
123
|
+
await fs.outputFile(join(skill("mine"), "SKILL.md"), "# custom\n");
|
|
124
|
+
await start();
|
|
125
|
+
|
|
126
|
+
expect(
|
|
127
|
+
await switchProfile("other", project, { installSkill: fakeInstall }),
|
|
128
|
+
).toBe(0);
|
|
129
|
+
|
|
130
|
+
expect(await fs.pathExists(skill("alpha"))).toBe(false);
|
|
131
|
+
expect(await fs.pathExists(skill("beta"))).toBe(true);
|
|
132
|
+
expect(await fs.readFile(join(skill("mine"), "SKILL.md"), "utf8")).toBe(
|
|
133
|
+
"# custom\n",
|
|
134
|
+
);
|
|
135
|
+
expect(
|
|
136
|
+
Object.keys((await readActiveMarker(project))?.skills ?? {}),
|
|
137
|
+
).toEqual(["beta"]);
|
|
138
|
+
const lines = await gitignoreLines();
|
|
139
|
+
expect(lines).toContain(".claude/skills/beta/");
|
|
140
|
+
expect(lines).not.toContain(".claude/skills/alpha/");
|
|
141
|
+
expect(await fs.pathExists(backupRoot(project))).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a user's own ignore line for a skill folder survives magus installing and removing it", async () => {
|
|
145
|
+
await fs.writeFile(
|
|
146
|
+
join(project, ".gitignore"),
|
|
147
|
+
"# local\n.claude/skills/alpha/\n",
|
|
148
|
+
);
|
|
149
|
+
await start();
|
|
150
|
+
expect(
|
|
151
|
+
await switchProfile("other", project, { installSkill: fakeInstall }),
|
|
152
|
+
).toBe(0);
|
|
153
|
+
|
|
154
|
+
const lines = await gitignoreLines();
|
|
155
|
+
// Theirs, outside magus's block: still there, exactly once.
|
|
156
|
+
expect(lines.filter((l) => l === ".claude/skills/alpha/")).toHaveLength(1);
|
|
157
|
+
expect(lines.slice(0, 2)).toEqual(["# local", ".claude/skills/alpha/"]);
|
|
158
|
+
expect(lines).toContain(".claude/skills/beta/");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a failed install leaves no folder behind and is reported", async () => {
|
|
162
|
+
await writeManifest(manifest(), project);
|
|
163
|
+
const result = await activateProfile(manifest(), "team", project, {
|
|
164
|
+
onDisplace: () => {},
|
|
165
|
+
installSkill: async (ref, p) => {
|
|
166
|
+
await fs.outputFile(join(skillsRoot(p), ref.name, "partial"), "x");
|
|
167
|
+
throw new Error("network down");
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(await fs.pathExists(skill("alpha"))).toBe(false);
|
|
172
|
+
expect(activationNotes(result)).toContain(
|
|
173
|
+
'Skill "alpha" could not be installed: network down',
|
|
174
|
+
);
|
|
175
|
+
expect((await readActiveMarker(project))?.skills).toEqual({});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("magus update re-fetching a skill", () => {
|
|
180
|
+
test("records the new contents, so the skill does not read as edited", async () => {
|
|
181
|
+
await start();
|
|
182
|
+
// What `magus update` does: re-fetch the skill in place.
|
|
183
|
+
await fs.outputFile(join(skill("alpha"), "SKILL.md"), "# alpha v2\n");
|
|
184
|
+
|
|
185
|
+
await recordRefetchedSkills(project, ["alpha"]);
|
|
186
|
+
|
|
187
|
+
const result = await reconcileActiveProfile(project, {
|
|
188
|
+
installSkill: fakeInstall,
|
|
189
|
+
});
|
|
190
|
+
expect(result.lines).toEqual([]);
|
|
191
|
+
expect((await readActiveMarker(project))?.skills.alpha).toBe(
|
|
192
|
+
(await folderHash(skill("alpha"))) as string,
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("only the skills the update actually fetched get new hashes", async () => {
|
|
197
|
+
const m = manifest();
|
|
198
|
+
(m.profiles.team as { skills?: ProfileSkillRef[] }).skills = [ALPHA, BETA];
|
|
199
|
+
await start(m);
|
|
200
|
+
// During the update: alpha is re-fetched; beta's fetch fails, and
|
|
201
|
+
// meanwhile someone edits beta by hand.
|
|
202
|
+
const reporter = trackFetchedSkills([ALPHA, BETA], {
|
|
203
|
+
begin: () => {},
|
|
204
|
+
finish: () => {},
|
|
205
|
+
detach: (work) => work(),
|
|
206
|
+
});
|
|
207
|
+
reporter.report.finish(true, "dev@magus", "installed", 1); // a plugin, not a skill
|
|
208
|
+
await fs.outputFile(join(skill("alpha"), "SKILL.md"), "# alpha v2\n");
|
|
209
|
+
reporter.report.finish(true, "alpha", "acme/skills", 1);
|
|
210
|
+
await fs.appendFile(join(skill("beta"), "SKILL.md"), "hand edit\n");
|
|
211
|
+
reporter.report.finish(false, "beta", "network down", 1);
|
|
212
|
+
expect(reporter.fetched()).toEqual(["alpha"]);
|
|
213
|
+
|
|
214
|
+
await recordRefetchedSkills(project, reporter.fetched());
|
|
215
|
+
const result = await reconcileActiveProfile(project, {
|
|
216
|
+
installSkill: fakeInstall,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// alpha's new contents are magus's; beta's edit is still an edit.
|
|
220
|
+
expect(result.lines).toEqual([
|
|
221
|
+
'Skill "beta" was edited — it is now a custom skill in .claude/skills/beta; commit it',
|
|
222
|
+
]);
|
|
223
|
+
expect((await readActiveMarker(project))?.skills.alpha).toBe(
|
|
224
|
+
(await folderHash(skill("alpha"))) as string,
|
|
225
|
+
);
|
|
226
|
+
expect((await readActiveMarker(project))?.skills.beta).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("updatableSkills never targets a custom folder or another profile", async () => {
|
|
230
|
+
await start();
|
|
231
|
+
await fs.outputFile(join(skill("beta"), "SKILL.md"), "# custom beta\n");
|
|
232
|
+
const m = manifest();
|
|
233
|
+
const teamSkills = m.profiles.team?.skills ?? [];
|
|
234
|
+
expect(
|
|
235
|
+
await updatableSkills(project, "team", [...teamSkills, BETA]),
|
|
236
|
+
).toEqual([ALPHA]);
|
|
237
|
+
expect(await updatableSkills(project, "other", [BETA])).toEqual([]);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("a profile skill whose folder is a custom skill", () => {
|
|
242
|
+
test("activation is refused, naming both, and nothing changes", async () => {
|
|
243
|
+
await fs.outputFile(join(skill("alpha"), "SKILL.md"), "# my own alpha\n");
|
|
244
|
+
await writeManifest(manifest(), project);
|
|
245
|
+
|
|
246
|
+
await expect(
|
|
247
|
+
activateProfile(manifest(), "team", project, {
|
|
248
|
+
onDisplace: () => {},
|
|
249
|
+
installSkill: fakeInstall,
|
|
250
|
+
}),
|
|
251
|
+
).rejects.toBeInstanceOf(SkillClashError);
|
|
252
|
+
|
|
253
|
+
expect(await fs.readFile(join(skill("alpha"), "SKILL.md"), "utf8")).toBe(
|
|
254
|
+
"# my own alpha\n",
|
|
255
|
+
);
|
|
256
|
+
expect(await fs.pathExists(join(project, ".claude", "settings.json"))).toBe(
|
|
257
|
+
false,
|
|
258
|
+
);
|
|
259
|
+
expect(await readActiveMarker(project)).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("a switch is refused and the current profile stays live", async () => {
|
|
263
|
+
await start();
|
|
264
|
+
await fs.outputFile(join(skill("beta"), "SKILL.md"), "# my own beta\n");
|
|
265
|
+
const before = await fs.readFile(
|
|
266
|
+
join(project, ".claude", "settings.json"),
|
|
267
|
+
"utf8",
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
expect(
|
|
271
|
+
await switchProfile("other", project, { installSkill: fakeInstall }),
|
|
272
|
+
).toBe(1);
|
|
273
|
+
|
|
274
|
+
const said = err.mock.calls.map((c) => String(c[0])).join("\n");
|
|
275
|
+
expect(said).toContain('profile skill "beta"');
|
|
276
|
+
expect(said).toContain("custom skill .claude/skills/beta/");
|
|
277
|
+
expect((await readActiveMarker(project))?.profile).toBe("team");
|
|
278
|
+
expect(
|
|
279
|
+
await fs.readFile(join(project, ".claude", "settings.json"), "utf8"),
|
|
280
|
+
).toBe(before);
|
|
281
|
+
expect(await fs.pathExists(skill("alpha"))).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("an edited magus-installed skill becomes a custom skill", () => {
|
|
286
|
+
test("dropped from the marker, the profile and the ignore file; kept on disk", async () => {
|
|
287
|
+
await start();
|
|
288
|
+
await fs.appendFile(join(skill("alpha"), "SKILL.md"), "my local change\n");
|
|
289
|
+
|
|
290
|
+
const result = await reconcileActiveProfile(project, {
|
|
291
|
+
installSkill: fakeInstall,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(result.lines).toContain(
|
|
295
|
+
'Skill "alpha" was edited — it is now a custom skill in .claude/skills/alpha; commit it',
|
|
296
|
+
);
|
|
297
|
+
expect((await readManifest(project)).profiles.team?.skills).toBeUndefined();
|
|
298
|
+
expect((await readActiveMarker(project))?.skills).toEqual({});
|
|
299
|
+
expect(await gitignoreLines()).not.toContain(".claude/skills/alpha/");
|
|
300
|
+
expect(
|
|
301
|
+
await fs.readFile(join(skill("alpha"), "SKILL.md"), "utf8"),
|
|
302
|
+
).toContain("my local change");
|
|
303
|
+
expect(await fs.pathExists(backupRoot(project))).toBe(false);
|
|
304
|
+
|
|
305
|
+
// And a later switch leaves it alone.
|
|
306
|
+
expect(
|
|
307
|
+
await switchProfile("other", project, { installSkill: fakeInstall }),
|
|
308
|
+
).toBe(0);
|
|
309
|
+
expect(await fs.pathExists(skill("alpha"))).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
function git(cwd: string, ...args: string[]) {
|
|
314
|
+
return spawnSync(
|
|
315
|
+
"git",
|
|
316
|
+
[
|
|
317
|
+
"-c",
|
|
318
|
+
"user.email=test@example.com",
|
|
319
|
+
"-c",
|
|
320
|
+
"user.name=test",
|
|
321
|
+
"-c",
|
|
322
|
+
"commit.gpgsign=false",
|
|
323
|
+
...args,
|
|
324
|
+
],
|
|
325
|
+
{ cwd, encoding: "utf8" },
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
describe("older layout: committed custom skills the seeding displaced come back", () => {
|
|
330
|
+
test("git status is clean for the committed skill after conversion", async () => {
|
|
331
|
+
expect(git(project, "init", "-q").status).toBe(0);
|
|
332
|
+
const committed = "---\nname: claudish-usage\n---\n# the team's skill\n";
|
|
333
|
+
await fs.outputFile(join(skill("claudish-usage"), "SKILL.md"), committed);
|
|
334
|
+
expect(git(project, "add", ".claude/skills").status).toBe(0);
|
|
335
|
+
expect(git(project, "commit", "-q", "-m", "skills").status).toBe(0);
|
|
336
|
+
|
|
337
|
+
// What the older release did: seed the project's skills into the profile
|
|
338
|
+
// dir, move the real .claude/skills into a backup, and link it instead.
|
|
339
|
+
const old = join(project, ".claude", "_profiles", "team");
|
|
340
|
+
await fs.copy(skillsRoot(project), join(old, "skills"));
|
|
341
|
+
await fs.outputFile(join(old, "skills", "alpha", "SKILL.md"), "# alpha\n");
|
|
342
|
+
await fs.move(
|
|
343
|
+
skillsRoot(project),
|
|
344
|
+
join(backupRoot(project), "2026-09-01T00-00-00-000Z", "skills"),
|
|
345
|
+
);
|
|
346
|
+
await fs.symlink("_profiles/team/skills", skillsRoot(project));
|
|
347
|
+
await fs.outputJson(join(old, "settings.json"), { enabledPlugins: {} });
|
|
348
|
+
await fs.symlink(
|
|
349
|
+
"_profiles/team/settings.json",
|
|
350
|
+
join(project, ".claude", "settings.json"),
|
|
351
|
+
);
|
|
352
|
+
await writeManifest(manifest(), project);
|
|
353
|
+
// The symptom: git sees the committed skill as gone.
|
|
354
|
+
expect(git(project, "status", "--porcelain").stdout).toContain(
|
|
355
|
+
".claude/skills",
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const prep = await prepareProfileState(project, {
|
|
359
|
+
installSkill: fakeInstall,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
expect(prep.lines).toContain(
|
|
363
|
+
'Converted profile "team" from the older layout to real files',
|
|
364
|
+
);
|
|
365
|
+
expect(
|
|
366
|
+
git(
|
|
367
|
+
project,
|
|
368
|
+
"status",
|
|
369
|
+
"--porcelain",
|
|
370
|
+
"--",
|
|
371
|
+
".claude/skills/claudish-usage",
|
|
372
|
+
).stdout,
|
|
373
|
+
).toBe("");
|
|
374
|
+
expect(
|
|
375
|
+
await fs.readFile(join(skill("claudish-usage"), "SKILL.md"), "utf8"),
|
|
376
|
+
).toBe(committed);
|
|
377
|
+
// The profile's own skill carried over as magus-installed, not re-fetched.
|
|
378
|
+
expect(
|
|
379
|
+
Object.keys((await readActiveMarker(project))?.skills ?? {}),
|
|
380
|
+
).toEqual(["alpha"]);
|
|
381
|
+
expect(await fs.readFile(join(skill("alpha"), "SKILL.md"), "utf8")).toBe(
|
|
382
|
+
"# alpha\n",
|
|
383
|
+
);
|
|
384
|
+
expect(await fs.pathExists(join(project, ".claude", "_profiles"))).toBe(
|
|
385
|
+
false,
|
|
386
|
+
);
|
|
387
|
+
// The backup it was restored from is untouched.
|
|
388
|
+
expect(
|
|
389
|
+
await fs.pathExists(
|
|
390
|
+
join(
|
|
391
|
+
backupRoot(project),
|
|
392
|
+
"2026-09-01T00-00-00-000Z",
|
|
393
|
+
"skills",
|
|
394
|
+
"claudish-usage",
|
|
395
|
+
"SKILL.md",
|
|
396
|
+
),
|
|
397
|
+
),
|
|
398
|
+
).toBe(true);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("an existing folder is never overwritten; a differing copy is reported with both paths", async () => {
|
|
402
|
+
await fs.outputFile(join(skill("shared"), "SKILL.md"), "# mine\n");
|
|
403
|
+
const old = join(project, ".claude", "_profiles", "other", "skills");
|
|
404
|
+
await fs.outputFile(join(old, "shared", "SKILL.md"), "# older copy\n");
|
|
405
|
+
await writeManifest(manifest(), project);
|
|
406
|
+
|
|
407
|
+
const result = await activateProfile(manifest(), "team", project, {
|
|
408
|
+
onDisplace: () => {},
|
|
409
|
+
installSkill: fakeInstall,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
expect(await fs.readFile(join(skill("shared"), "SKILL.md"), "utf8")).toBe(
|
|
413
|
+
"# mine\n",
|
|
414
|
+
);
|
|
415
|
+
expect(result.conversion.conflicts).toHaveLength(1);
|
|
416
|
+
const conflict = result.conversion.conflicts[0];
|
|
417
|
+
expect(conflict?.kept).toBe(skill("shared"));
|
|
418
|
+
expect(
|
|
419
|
+
await fs.readFile(join(conflict?.other ?? "", "SKILL.md"), "utf8"),
|
|
420
|
+
).toBe("# older copy\n");
|
|
421
|
+
expect(activationNotes(result).join("\n")).toContain(
|
|
422
|
+
'Skill "shared" exists in two versions',
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -20,8 +20,9 @@ import { tmpdir } from "node:os";
|
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import fs from "fs-extra";
|
|
22
22
|
import { validateManifest } from "../services/manifest.js";
|
|
23
|
-
import {
|
|
23
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
24
24
|
import { adoptCurrentProject } from "../services/profile-adopt.js";
|
|
25
|
+
import { writeActiveMarker } from "../services/profile-live.js";
|
|
25
26
|
import {
|
|
26
27
|
ACTIVATE_PREFIX,
|
|
27
28
|
ADOPT_VALUE,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
detectProfileStartupNeed,
|
|
33
34
|
needsNothing,
|
|
34
35
|
parseProfileChoice,
|
|
36
|
+
prepareProfileStartup,
|
|
35
37
|
} from "../services/profile-startup.js";
|
|
36
38
|
import { listProfiles } from "../services/profiles.js";
|
|
37
39
|
import type { ProfileManifest } from "../types/index.js";
|
|
@@ -93,7 +95,7 @@ describe("detectProfileStartupNeed", () => {
|
|
|
93
95
|
test("a profile is active — asks nothing, whatever is enabled", async () => {
|
|
94
96
|
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
95
97
|
await enable(project, ["terminal@magus"]);
|
|
96
|
-
await
|
|
98
|
+
await activateProfile(LEAN, "lean", project, { onDisplace: () => {} });
|
|
97
99
|
|
|
98
100
|
expect(
|
|
99
101
|
needsNothing(await detectProfileStartupNeed(project)).valueOf(),
|
|
@@ -101,19 +103,16 @@ describe("detectProfileStartupNeed", () => {
|
|
|
101
103
|
});
|
|
102
104
|
|
|
103
105
|
test("an active profile whose generated settings were edited still asks nothing", async () => {
|
|
104
|
-
// THE point of dropping the comparison. `settings.json` is
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// is to re-materialize, and the wrong answer is what made magus behave as
|
|
109
|
-
// though the profileless world were still live.
|
|
106
|
+
// THE point of dropping the comparison. `settings.json` is rendered FROM
|
|
107
|
+
// the manifest, so judging the manifest against it reads the manifest's
|
|
108
|
+
// own output. An edit to it is a change to the active profile, absorbed
|
|
109
|
+
// before anything reads it — never a reason to offer a third profile.
|
|
110
110
|
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
111
111
|
await enable(project, ["terminal@magus"]);
|
|
112
|
-
await
|
|
113
|
-
await fs.outputJson(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
);
|
|
112
|
+
await activateProfile(LEAN, "lean", project, { onDisplace: () => {} });
|
|
113
|
+
await fs.outputJson(join(project, ".claude", "settings.json"), {
|
|
114
|
+
enabledPlugins: { "terminal@magus": true, "brand-new@magus": true },
|
|
115
|
+
});
|
|
117
116
|
|
|
118
117
|
const need = await detectProfileStartupNeed(project);
|
|
119
118
|
|
|
@@ -121,6 +120,37 @@ describe("detectProfileStartupNeed", () => {
|
|
|
121
120
|
expect(need.adopt).toBeNull();
|
|
122
121
|
});
|
|
123
122
|
|
|
123
|
+
test("a marker naming a profile the manifest lacks offers only activation", async () => {
|
|
124
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
125
|
+
await writeActiveMarker(
|
|
126
|
+
{ version: 1, profile: "gone", files: {}, skills: {} },
|
|
127
|
+
project,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const need = await detectProfileStartupNeed(project);
|
|
131
|
+
|
|
132
|
+
expect(need.state).toBe("unknown");
|
|
133
|
+
expect(need.unknownProfile).toBe("gone");
|
|
134
|
+
// Saving "what you have" would capture a profile that no longer exists.
|
|
135
|
+
expect(need.adopt).toBeNull();
|
|
136
|
+
expect(need.activate.map((p) => p.id).sort()).toEqual(["full", "lean"]);
|
|
137
|
+
expect(describeNeed(need).message).toContain('"gone"');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("the TUI startup runs the gate first and reports what it absorbed", async () => {
|
|
141
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
142
|
+
await activateProfile(LEAN, "lean", project, { onDisplace: () => {} });
|
|
143
|
+
const live = join(project, ".claude", "settings.json");
|
|
144
|
+
const settings = await fs.readJson(live);
|
|
145
|
+
settings.enabledPlugins["code-search@magus"] = true;
|
|
146
|
+
await fs.writeFile(live, JSON.stringify(settings));
|
|
147
|
+
|
|
148
|
+
const { need, notices } = await prepareProfileStartup(project);
|
|
149
|
+
|
|
150
|
+
expect(need.state).toBe("ok");
|
|
151
|
+
expect(notices).toEqual(['Added code-search@magus to profile "lean"']);
|
|
152
|
+
});
|
|
153
|
+
|
|
124
154
|
test("what is enabled never changes the answer", async () => {
|
|
125
155
|
// Nothing reads enabledPlugins, so two wildly different settings files on
|
|
126
156
|
// the same manifest must produce identical needs.
|
|
@@ -143,7 +173,7 @@ describe("detectProfileStartupNeed", () => {
|
|
|
143
173
|
const before = await listProfiles(project);
|
|
144
174
|
expect(before.every((p) => p.active !== true)).toBe(true);
|
|
145
175
|
|
|
146
|
-
await
|
|
176
|
+
await activateProfile(LEAN, "lean", project, { onDisplace: () => {} });
|
|
147
177
|
const after = await listProfiles(project);
|
|
148
178
|
|
|
149
179
|
expect(after.find((p) => p.id === "lean")?.active).toBe(true);
|