magus-cli 7.5.0 → 7.5.2

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.
Files changed (33) hide show
  1. package/package.json +5 -4
  2. package/src/__tests__/profile-absorb.test.ts +72 -0
  3. package/src/__tests__/profile-dependencies.test.ts +442 -0
  4. package/src/__tests__/profile-manifest-write.test.ts +231 -0
  5. package/src/__tests__/profile-skills.test.ts +99 -0
  6. package/src/__tests__/profile-state.test.ts +328 -13
  7. package/src/__tests__/stale-load-repair.test.ts +265 -0
  8. package/src/__tests__/support/preload.ts +14 -0
  9. package/src/__tests__/update-apply.test.ts +342 -1
  10. package/src/cli/bootstrap.ts +4 -0
  11. package/src/cli/doctor.ts +39 -6
  12. package/src/cli/install.ts +68 -4
  13. package/src/cli/router.ts +7 -0
  14. package/src/cli/update.ts +3 -0
  15. package/src/services/claude-cli.ts +24 -0
  16. package/src/services/claude-settings.ts +45 -0
  17. package/src/services/git-worktree.ts +92 -9
  18. package/src/services/manifest.ts +220 -3
  19. package/src/services/plugin-dependencies.ts +48 -0
  20. package/src/services/plugin-requires.ts +137 -14
  21. package/src/services/profile-absorb.ts +69 -15
  22. package/src/services/profile-activate.ts +179 -33
  23. package/src/services/profile-live.ts +66 -7
  24. package/src/services/profile-render.ts +73 -6
  25. package/src/services/profile-skills.ts +32 -7
  26. package/src/services/profile-state.ts +89 -5
  27. package/src/services/profiles.ts +6 -5
  28. package/src/services/resolver.ts +96 -3
  29. package/src/services/tracked-live-files.ts +125 -0
  30. package/src/services/update-engine.ts +266 -8
  31. package/src/types/index.ts +19 -0
  32. package/src/ui/screens/PluginsScreen.tsx +11 -2
  33. package/src/ui/screens/ProfilesScreen.tsx +10 -3
@@ -0,0 +1,231 @@
1
+ /**
2
+ * B2 — writing `.claude/profiles.json` changes only the values that changed.
3
+ *
4
+ * The file is committed and reviewed. A single absorbed plugin used to
5
+ * re-serialise the whole thing: every inline array expanded, keys reordered,
6
+ * `"strictVersions": false` dropped — a 63-line diff on magus-src.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
10
+ import { mkdtemp, rm } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import fs from "fs-extra";
14
+ import { readManifest, writeManifest } from "../services/manifest.js";
15
+ import { reconcileActiveProfile } from "../services/profile-absorb.js";
16
+ import { activateProfile } from "../services/profile-activate.js";
17
+ import { renameProfile, saveProfile } from "../services/profiles.js";
18
+
19
+ /** A committed-style manifest: inline arrays, a key with a default value. */
20
+ const COMMITTED = `{
21
+ "version": 2,
22
+ "strictVersions": false,
23
+ "profiles": {
24
+ "team": {
25
+ "name": "Team",
26
+ "marketplaces": {
27
+ "magus": { "source": "github", "repo": "MadAppGang/magus" }
28
+ },
29
+ "plugins": {
30
+ "dev@magus": "latest",
31
+ "terminal@magus": "latest"
32
+ },
33
+ "mcpServers": {
34
+ "chrome": { "command": "npx", "args": ["-y", "chrome-devtools-mcp"] }
35
+ },
36
+ "env": {
37
+ "required": ["OPENROUTER_API_KEY"]
38
+ }
39
+ },
40
+ "other": {
41
+ "name": "Other",
42
+ "plugins": { "designer@magus": "latest" }
43
+ }
44
+ }
45
+ }
46
+ `;
47
+
48
+ let project: string;
49
+ let log: ReturnType<typeof spyOn>;
50
+ const manifestPath = () => join(project, ".claude", "profiles.json");
51
+
52
+ /** Lines in `after` not matched by an LCS against `before`, plus the reverse. */
53
+ function changedLines(before: string, after: string): number {
54
+ const a = before.split("\n");
55
+ const b = after.split("\n");
56
+ const dp: number[][] = Array.from({ length: a.length + 1 }, () =>
57
+ new Array<number>(b.length + 1).fill(0),
58
+ );
59
+ for (let i = a.length - 1; i >= 0; i--) {
60
+ for (let j = b.length - 1; j >= 0; j--) {
61
+ dp[i]![j] =
62
+ a[i] === b[j]
63
+ ? (dp[i + 1]![j + 1] as number) + 1
64
+ : Math.max(dp[i + 1]![j] as number, dp[i]![j + 1] as number);
65
+ }
66
+ }
67
+ const common = dp[0]![0] as number;
68
+ return a.length - common + (b.length - common);
69
+ }
70
+
71
+ beforeEach(async () => {
72
+ project = await mkdtemp(join(tmpdir(), "manifest-write-"));
73
+ await fs.outputFile(manifestPath(), COMMITTED);
74
+ log = spyOn(console, "log").mockImplementation(() => {});
75
+ });
76
+ afterEach(async () => {
77
+ log.mockRestore();
78
+ await rm(project, { recursive: true, force: true });
79
+ });
80
+
81
+ describe("writeManifest edits, it does not re-serialise", () => {
82
+ test("adding a plugin changes exactly the plugin lines", async () => {
83
+ const m = await readManifest(project);
84
+ (m.profiles.team?.plugins as Record<string, string>)["code-search@magus"] =
85
+ "latest";
86
+ await writeManifest(m, project);
87
+
88
+ expect(await fs.readFile(manifestPath(), "utf8")).toBe(
89
+ COMMITTED.replace(
90
+ '"terminal@magus": "latest"\n',
91
+ '"terminal@magus": "latest",\n "code-search@magus": "latest"\n',
92
+ ),
93
+ );
94
+ });
95
+
96
+ test("removing a key removes that line only; strictVersions: false stays", async () => {
97
+ const m = await readManifest(project);
98
+ // biome-ignore lint/performance/noDelete: an absent key is the case under test
99
+ delete (m.profiles.team?.plugins as Record<string, string>)[
100
+ "terminal@magus"
101
+ ];
102
+ await writeManifest(m, project);
103
+
104
+ const after = await fs.readFile(manifestPath(), "utf8");
105
+ expect(after).toBe(
106
+ COMMITTED.replace(
107
+ '"dev@magus": "latest",\n "terminal@magus": "latest"\n',
108
+ '"dev@magus": "latest"\n',
109
+ ),
110
+ );
111
+ expect(after).toContain('"strictVersions": false');
112
+ expect(after).toContain('"args": ["-y", "chrome-devtools-mcp"]');
113
+ });
114
+
115
+ test("a write that changes nothing — even reordered keys — leaves every byte", async () => {
116
+ const m = await readManifest(project);
117
+ const team = m.profiles.team as unknown as Record<string, unknown>;
118
+ const reordered: Record<string, unknown> = {};
119
+ for (const key of Object.keys(team).reverse()) reordered[key] = team[key];
120
+ m.profiles.team = reordered as never;
121
+ await writeManifest(m, project);
122
+ expect(await fs.readFile(manifestPath(), "utf8")).toBe(COMMITTED);
123
+ });
124
+
125
+ test("a new file is written as 2-space JSON with a trailing newline", async () => {
126
+ await fs.remove(manifestPath());
127
+ const m = { version: 2 as const, profiles: { a: { name: "A" } } };
128
+ await writeManifest(m, project);
129
+ expect(await fs.readFile(manifestPath(), "utf8")).toBe(
130
+ `${JSON.stringify(m, null, 2)}\n`,
131
+ );
132
+ });
133
+
134
+ test("an inline object gains a key on its own line; a changed inline array stays inline", async () => {
135
+ const m = await readManifest(project);
136
+ (m.profiles.other?.plugins as Record<string, string>)["code-search@magus"] =
137
+ "latest";
138
+ (m.profiles.team?.env as { required: string[] }).required.push(
139
+ "GEMINI_KEY",
140
+ );
141
+ await writeManifest(m, project);
142
+
143
+ expect(await fs.readFile(manifestPath(), "utf8")).toBe(
144
+ COMMITTED.replace(
145
+ '"plugins": { "designer@magus": "latest" }',
146
+ '"plugins": { "designer@magus": "latest", "code-search@magus": "latest" }',
147
+ ).replace(
148
+ '"required": ["OPENROUTER_API_KEY"]',
149
+ '"required": ["OPENROUTER_API_KEY", "GEMINI_KEY"]',
150
+ ),
151
+ );
152
+ });
153
+
154
+ test("removing the first key, or the only one, keeps the rest of the layout", async () => {
155
+ const m = await readManifest(project);
156
+ // biome-ignore lint/performance/noDelete: an absent key is the case under test
157
+ delete (m.profiles.team?.plugins as Record<string, string>)["dev@magus"];
158
+ m.profiles.team!.mcpServers = {};
159
+ await writeManifest(m, project);
160
+
161
+ expect(await fs.readFile(manifestPath(), "utf8")).toBe(
162
+ COMMITTED.replace(
163
+ '"dev@magus": "latest",\n "terminal@magus"',
164
+ '"terminal@magus"',
165
+ ).replace(
166
+ '"mcpServers": {\n "chrome": { "command": "npx", "args": ["-y", "chrome-devtools-mcp"] }\n }',
167
+ '"mcpServers": {}',
168
+ ),
169
+ );
170
+ });
171
+
172
+ test("a v1 file is recorded as v2 and nothing else moves", async () => {
173
+ const v1 = COMMITTED.replace('"version": 2', '"version": 1');
174
+ await fs.outputFile(manifestPath(), v1);
175
+ const m = await readManifest(project);
176
+ await writeManifest(m, project);
177
+ expect(await fs.readFile(manifestPath(), "utf8")).toBe(COMMITTED);
178
+ });
179
+
180
+ test("the TUI's profiles writer (project scope) changes only the renamed profile's values", async () => {
181
+ await renameProfile("other", "Other Renamed", "project", project);
182
+ const after = await fs.readFile(manifestPath(), "utf8");
183
+ const stamp = /"updatedAt": "([^"]+)"/.exec(after)?.[1];
184
+ expect(stamp).toBeString();
185
+ expect(after).toBe(
186
+ COMMITTED.replace(
187
+ '"name": "Other",\n "plugins": { "designer@magus": "latest" }\n',
188
+ `"name": "Other Renamed",\n "plugins": { "designer@magus": "latest" },\n "updatedAt": "${stamp}"\n`,
189
+ ),
190
+ );
191
+ });
192
+
193
+ test("the TUI's profiles writer adds a profile without touching the others", async () => {
194
+ await saveProfile("New", { "dev@magus": true }, "project", project);
195
+ const after = await fs.readFile(manifestPath(), "utf8");
196
+ const [head] = after.split(' "new": {');
197
+ expect(head).toBe(
198
+ COMMITTED.slice(0, COMMITTED.lastIndexOf(" }\n }\n}")).concat(
199
+ " },\n",
200
+ ),
201
+ );
202
+ const saved = JSON.parse(after).profiles.new;
203
+ expect(saved.name).toBe("New");
204
+ expect(saved.plugins).toEqual({ "dev@magus": true });
205
+ expect(after).toContain(
206
+ ' "new": {\n "name": "New",\n "plugins": {\n "dev@magus": true\n },',
207
+ );
208
+ });
209
+ });
210
+
211
+ describe("absorption writes a minimal diff", () => {
212
+ test("a plugin Claude Code installed is a ≤3-line diff, with no updatedAt", async () => {
213
+ await activateProfile(await readManifest(project), "team", project, {
214
+ onDisplace: () => {},
215
+ });
216
+ const committed = await fs.readFile(manifestPath(), "utf8");
217
+ expect(committed).toBe(COMMITTED);
218
+ const live = join(project, ".claude", "settings.json");
219
+ const settings = await fs.readJson(live);
220
+ settings.enabledPlugins["code-search@magus"] = true;
221
+ await fs.writeFile(live, JSON.stringify(settings));
222
+
223
+ const result = await reconcileActiveProfile(project);
224
+
225
+ expect(result.lines).toEqual(['Added code-search@magus to profile "team"']);
226
+ const after = await fs.readFile(manifestPath(), "utf8");
227
+ expect(changedLines(committed, after)).toBeLessThanOrEqual(3);
228
+ expect(after).not.toContain("updatedAt");
229
+ expect(after).toContain('"strictVersions": false');
230
+ });
231
+ });
@@ -398,6 +398,105 @@ describe("older layout: committed custom skills the seeding displaced come back"
398
398
  ).toBe(true);
399
399
  });
400
400
 
401
+ test("review 3b: a committed skill the profile ALSO lists stays the team's copy — never magus-installed", async () => {
402
+ expect(git(project, "init", "-q").status).toBe(0);
403
+ const committed = "---\nname: claudish-usage\n---\n# the team's skill\n";
404
+ await fs.outputFile(join(skill("claudish-usage"), "SKILL.md"), committed);
405
+ expect(git(project, "add", ".claude/skills").status).toBe(0);
406
+ expect(git(project, "commit", "-q", "-m", "skills").status).toBe(0);
407
+ // 7.4.1: the profile lists that same skill, and the layout linked it.
408
+ const listed: ProfileManifest = manifest();
409
+ (listed.profiles.team as { skills: ProfileSkillRef[] }).skills = [
410
+ ALPHA,
411
+ {
412
+ name: "claudish-usage",
413
+ repo: "acme/skills",
414
+ path: "skills/claudish-usage",
415
+ },
416
+ ];
417
+ const old = join(project, ".claude", "_profiles", "team");
418
+ await fs.move(skillsRoot(project), join(old, "skills"));
419
+ await fs.outputFile(join(old, "skills", "alpha", "SKILL.md"), "# alpha\n");
420
+ await fs.symlink("_profiles/team/skills", skillsRoot(project));
421
+ await fs.outputJson(join(old, "settings.json"), { enabledPlugins: {} });
422
+ await fs.symlink(
423
+ "_profiles/team/settings.json",
424
+ join(project, ".claude", "settings.json"),
425
+ );
426
+ await writeManifest(listed, project);
427
+
428
+ const prep = await prepareProfileState(project, {
429
+ installSkill: fakeInstall,
430
+ });
431
+
432
+ expect(prep.problems).toEqual([]);
433
+ expect(prep.lines.join("\n")).not.toContain("git rm");
434
+ expect(prep.lines).toContain(
435
+ "Skill .claude/skills/claudish-usage is committed to git — the team's copy is used; magus does not install or manage it",
436
+ );
437
+ expect(
438
+ Object.keys((await readActiveMarker(project))?.skills ?? {}),
439
+ ).toEqual(["alpha"]);
440
+ expect(
441
+ await fs.readFile(join(skill("claudish-usage"), "SKILL.md"), "utf8"),
442
+ ).toBe(committed);
443
+ expect(await gitignoreLines()).not.toContain(
444
+ ".claude/skills/claudish-usage/",
445
+ );
446
+ expect(
447
+ git(
448
+ project,
449
+ "status",
450
+ "--porcelain",
451
+ "--",
452
+ ".claude/skills/claudish-usage",
453
+ ).stdout,
454
+ ).toBe("");
455
+ // The next command neither refuses nor reconverts.
456
+ const again = await prepareProfileState(project, {
457
+ installSkill: fakeInstall,
458
+ });
459
+ expect(again.problems).toEqual([]);
460
+ });
461
+
462
+ test("review 3b: a magus-installed folder someone committed is refused before the reconcile writes anything", async () => {
463
+ await start();
464
+ expect(git(project, "init", "-q").status).toBe(0);
465
+ expect(git(project, "add", "-f", ".claude/skills/alpha").status).toBe(0);
466
+ expect(git(project, "commit", "-q", "-m", "oops").status).toBe(0);
467
+ // A teammate drops alpha from the profile: the next reconcile would
468
+ // remove the folder, after recording the change in the marker.
469
+ const pulled = manifest();
470
+ (pulled.profiles.team as { skills: ProfileSkillRef[] }).skills = [];
471
+ await writeManifest(pulled, project);
472
+ const marker = await fs.readFile(
473
+ join(project, ".claude", "profiles", "active.json"),
474
+ "utf8",
475
+ );
476
+ const manifestBytes = await fs.readFile(
477
+ join(project, ".claude", "profiles.json"),
478
+ "utf8",
479
+ );
480
+
481
+ const prep = await prepareProfileState(project, {
482
+ installSkill: fakeInstall,
483
+ });
484
+
485
+ expect(prep.problems[0]).toContain(
486
+ "git rm --cached -r .claude/skills/alpha",
487
+ );
488
+ expect(await fs.pathExists(skill("alpha"))).toBe(true);
489
+ expect(
490
+ await fs.readFile(
491
+ join(project, ".claude", "profiles", "active.json"),
492
+ "utf8",
493
+ ),
494
+ ).toBe(marker);
495
+ expect(
496
+ await fs.readFile(join(project, ".claude", "profiles.json"), "utf8"),
497
+ ).toBe(manifestBytes);
498
+ });
499
+
401
500
  test("an existing folder is never overwritten; a differing copy is reported with both paths", async () => {
402
501
  await fs.outputFile(join(skill("shared"), "SKILL.md"), "# mine\n");
403
502
  const old = join(project, ".claude", "_profiles", "other", "skills");