magus-cli 7.4.1 → 7.5.1

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 (65) hide show
  1. package/README.md +9 -8
  2. package/package.json +5 -4
  3. package/src/__tests__/install-command.test.ts +4 -3
  4. package/src/__tests__/models-cli.test.ts +4 -7
  5. package/src/__tests__/models-manager.test.ts +52 -40
  6. package/src/__tests__/profile-absorb.test.ts +1210 -0
  7. package/src/__tests__/profile-command.test.ts +90 -32
  8. package/src/__tests__/profile-dependencies.test.ts +442 -0
  9. package/src/__tests__/profile-manifest-write.test.ts +231 -0
  10. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  11. package/src/__tests__/profile-migration.test.ts +246 -117
  12. package/src/__tests__/profile-render.test.ts +141 -0
  13. package/src/__tests__/profile-skills.test.ts +524 -0
  14. package/src/__tests__/profile-startup.test.ts +44 -14
  15. package/src/__tests__/profile-state.test.ts +1110 -0
  16. package/src/__tests__/settings-file.test.ts +5 -7
  17. package/src/__tests__/stale-load-repair.test.ts +105 -0
  18. package/src/__tests__/styles-manager.test.ts +47 -74
  19. package/src/__tests__/styles-screen-state.test.ts +11 -10
  20. package/src/__tests__/support/preload.ts +14 -0
  21. package/src/__tests__/update-apply.test.ts +145 -0
  22. package/src/cli/bootstrap.ts +91 -27
  23. package/src/cli/doctor.ts +165 -38
  24. package/src/cli/install.ts +126 -122
  25. package/src/cli/models.ts +21 -1
  26. package/src/cli/profile.ts +48 -77
  27. package/src/cli/router.ts +39 -6
  28. package/src/cli/update.ts +46 -6
  29. package/src/data/gitignore-defaults.ts +3 -3
  30. package/src/services/active-closure.ts +3 -3
  31. package/src/services/claude-cli.ts +24 -0
  32. package/src/services/claude-settings.ts +27 -0
  33. package/src/services/conventions-manager.ts +88 -0
  34. package/src/services/git-worktree.ts +113 -9
  35. package/src/services/manifest.ts +224 -8
  36. package/src/services/models-manager.ts +36 -73
  37. package/src/services/plugin-dependencies.ts +48 -0
  38. package/src/services/plugin-requires.ts +137 -14
  39. package/src/services/profile-absorb.ts +1586 -0
  40. package/src/services/profile-activate.ts +492 -94
  41. package/src/services/profile-live.ts +884 -0
  42. package/src/services/profile-render.ts +182 -0
  43. package/src/services/profile-skills.ts +487 -0
  44. package/src/services/profile-startup.ts +88 -16
  45. package/src/services/profile-state.ts +706 -0
  46. package/src/services/profiles.ts +26 -37
  47. package/src/services/resolver.ts +100 -2
  48. package/src/services/settings-file.ts +5 -5
  49. package/src/services/skills-manager.ts +14 -8
  50. package/src/services/styles-manager.ts +45 -28
  51. package/src/services/tracked-live-files.ts +125 -0
  52. package/src/services/update-engine.ts +87 -2
  53. package/src/types/index.ts +34 -9
  54. package/src/ui/App.tsx +25 -2
  55. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  56. package/src/ui/screens/PluginsScreen.tsx +11 -2
  57. package/src/ui/screens/ProfilesScreen.tsx +84 -29
  58. package/src/ui/screens/StylesScreen.tsx +2 -1
  59. package/src/ui/state/types.ts +1 -1
  60. package/src/__tests__/profile-materializer.test.ts +0 -238
  61. package/src/__tests__/profile-sync.test.ts +0 -154
  62. package/src/__tests__/symlink-manager.test.ts +0 -180
  63. package/src/services/profile-materializer.ts +0 -133
  64. package/src/services/profile-sync.ts +0 -158
  65. package/src/services/symlink-manager.ts +0 -343
@@ -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
+ });
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The CLI gate: no active profile, no command.
3
3
  *
4
- * This replaces `offerAdoptionOnDrift`, which asked only when the manifest had
5
- * drifted from `enabledPlugins`, let the user decline, and then let `magus
4
+ * This replaces the old adoption offer, which asked only when the manifest
5
+ * disagreed with `enabledPlugins`, let the user decline, and then let `magus
6
6
  * update` carry on with a profile "for this run only" — resolving against a
7
7
  * manifest the project was not running. Two worlds live at once.
8
8
  *
@@ -19,8 +19,8 @@ import { join } from "node:path";
19
19
  import fs from "fs-extra";
20
20
  import { requireActiveProfile } from "../cli/bootstrap.js";
21
21
  import { runUpdateCommand } from "../cli/update.js";
22
- import { materializeAndActivate } from "../services/profile-activate.js";
23
- import { activeProfile } from "../services/symlink-manager.js";
22
+ import { activateProfile } from "../services/profile-activate.js";
23
+ import { activeProfile } from "../services/profile-live.js";
24
24
  import type { ProfileManifest } from "../types/index.js";
25
25
 
26
26
  const MANIFEST: ProfileManifest = {
@@ -82,9 +82,9 @@ describe("requireActiveProfile", () => {
82
82
 
83
83
  await requireActiveProfile(project, MANIFEST);
84
84
 
85
- // No profile materialized, no backups taken, settings left exactly as it
85
+ // No profile activated, no backups taken, settings left exactly as it
86
86
  // was: a refusal is not allowed to half-migrate anything.
87
- expect(await fs.pathExists(join(project, ".claude", "_profiles"))).toBe(
87
+ expect(await fs.pathExists(join(project, ".claude", "profiles"))).toBe(
88
88
  false,
89
89
  );
90
90
  expect(
@@ -112,14 +112,16 @@ describe("requireActiveProfile", () => {
112
112
  expect(
113
113
  await requireActiveProfile(project, MANIFEST, { check: true }),
114
114
  ).toBeNull();
115
- expect(await fs.pathExists(join(project, ".claude", "_profiles"))).toBe(
115
+ expect(await fs.pathExists(join(project, ".claude", "profiles"))).toBe(
116
116
  false,
117
117
  );
118
118
  });
119
119
 
120
120
  test("passes straight through when a profile IS active", async () => {
121
121
  await fs.outputJson(join(project, ".claude", "profiles.json"), MANIFEST);
122
- await materializeAndActivate(MANIFEST, "lean", project, () => {});
122
+ await activateProfile(MANIFEST, "lean", project, {
123
+ onDisplace: () => {},
124
+ });
123
125
  noTTY();
124
126
 
125
127
  // No prompt, no refusal: the one state where there is nothing to ask.
@@ -166,7 +168,7 @@ describe("requireActiveProfile", () => {
166
168
  test("--dry-run writes nothing and exits 0", async () => {
167
169
  // `--dry-run` promises to write nothing and exit 0. An earlier version
168
170
  // passed only `check: flags.check`, so a dry run reached `autoActivate`,
169
- // wrote symlinks and appended to the TRACKED `.gitignore`. A preview that
171
+ // wrote config files and appended to the TRACKED `.gitignore`. A preview that
170
172
  // mutates the repo is not a preview. Refusing is the right outcome, and
171
173
  // for a preview it is not a failure — hence 0, where `--check` gives 1.
172
174
  await fs.outputJson(join(project, ".claude", "profiles.json"), {
@@ -189,7 +191,7 @@ describe("requireActiveProfile", () => {
189
191
  }
190
192
 
191
193
  expect(await activeProfile(project)).toBeNull();
192
- expect(await fs.pathExists(join(project, ".claude", "_profiles"))).toBe(
194
+ expect(await fs.pathExists(join(project, ".claude", "profiles"))).toBe(
193
195
  false,
194
196
  );
195
197
  expect(await fs.pathExists(join(project, ".gitignore"))).toBe(false);
@@ -199,11 +201,12 @@ describe("requireActiveProfile", () => {
199
201
  // The gate must not consult enabledPlugins. A project running a profile is
200
202
  // usable regardless of what its generated settings currently say.
201
203
  await fs.outputJson(join(project, ".claude", "profiles.json"), MANIFEST);
202
- await materializeAndActivate(MANIFEST, "lean", project, () => {});
203
- await fs.outputJson(
204
- join(project, ".claude", "_profiles", "lean", "settings.json"),
205
- { enabledPlugins: { "something-else@magus": true } },
206
- );
204
+ await activateProfile(MANIFEST, "lean", project, {
205
+ onDisplace: () => {},
206
+ });
207
+ await fs.outputJson(join(project, ".claude", "settings.json"), {
208
+ enabledPlugins: { "something-else@magus": true },
209
+ });
207
210
  noTTY();
208
211
 
209
212
  expect(await requireActiveProfile(project, MANIFEST)).not.toBeNull();