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.
Files changed (67) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -4
  3. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  4. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  5. package/src/__tests__/install-command.test.ts +4 -3
  6. package/src/__tests__/models-cli.test.ts +4 -7
  7. package/src/__tests__/models-manager.test.ts +52 -40
  8. package/src/__tests__/profile-absorb.test.ts +1138 -0
  9. package/src/__tests__/profile-command.test.ts +90 -32
  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 +425 -0
  14. package/src/__tests__/profile-startup.test.ts +44 -14
  15. package/src/__tests__/profile-state.test.ts +795 -0
  16. package/src/__tests__/settings-file.test.ts +5 -7
  17. package/src/__tests__/stale-load-repair.test.ts +5 -4
  18. package/src/__tests__/styles-manager.test.ts +47 -74
  19. package/src/__tests__/styles-screen-state.test.ts +11 -10
  20. package/src/cli/bootstrap.ts +87 -27
  21. package/src/cli/doctor.ts +152 -44
  22. package/src/cli/install.ts +92 -127
  23. package/src/cli/models.ts +21 -1
  24. package/src/cli/profile.ts +48 -77
  25. package/src/cli/router.ts +32 -6
  26. package/src/cli/update.ts +43 -6
  27. package/src/data/gitignore-defaults.ts +3 -3
  28. package/src/services/active-closure.ts +3 -3
  29. package/src/services/claude-settings.ts +283 -125
  30. package/src/services/cli-tool-commands.ts +30 -0
  31. package/src/services/conventions-manager.ts +88 -0
  32. package/src/services/git-worktree.ts +21 -0
  33. package/src/services/gitignore-service.ts +39 -22
  34. package/src/services/manifest.ts +4 -5
  35. package/src/services/models-manager.ts +36 -73
  36. package/src/services/profile-absorb.ts +1532 -0
  37. package/src/services/profile-activate.ts +348 -96
  38. package/src/services/profile-live.ts +825 -0
  39. package/src/services/profile-render.ts +115 -0
  40. package/src/services/profile-skills.ts +462 -0
  41. package/src/services/profile-startup.ts +88 -16
  42. package/src/services/profile-state.ts +622 -0
  43. package/src/services/profiles.ts +20 -32
  44. package/src/services/resolver.ts +6 -1
  45. package/src/services/settings-file.ts +14 -8
  46. package/src/services/skills-manager.ts +14 -8
  47. package/src/services/styles-manager.ts +45 -28
  48. package/src/services/tool-probe.ts +48 -3
  49. package/src/types/index.ts +15 -9
  50. package/src/ui/App.tsx +25 -2
  51. package/src/ui/components/modals/ModalContainer.tsx +9 -1
  52. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  53. package/src/ui/input/KeyRouter.tsx +147 -23
  54. package/src/ui/input/key-dispatch.ts +48 -0
  55. package/src/ui/keymap.ts +8 -1
  56. package/src/ui/renderers/cliToolRenderers.tsx +65 -27
  57. package/src/ui/screens/CliToolsScreen.tsx +74 -13
  58. package/src/ui/screens/PluginsScreen.tsx +6 -2
  59. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  60. package/src/ui/screens/StylesScreen.tsx +2 -1
  61. package/src/ui/state/types.ts +1 -1
  62. package/src/__tests__/profile-materializer.test.ts +0 -238
  63. package/src/__tests__/profile-sync.test.ts +0 -154
  64. package/src/__tests__/symlink-manager.test.ts +0 -180
  65. package/src/services/profile-materializer.ts +0 -133
  66. package/src/services/profile-sync.ts +0 -158
  67. package/src/services/symlink-manager.ts +0 -343
@@ -3,8 +3,14 @@ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import fs from "fs-extra";
6
- import { listProfiles, showProfile, switchProfile } from "../cli/profile.js";
7
- import { activeProfile } from "../services/symlink-manager.js";
6
+ import {
7
+ listProfiles,
8
+ runProfileCommand,
9
+ showProfile,
10
+ switchProfile,
11
+ } from "../cli/profile.js";
12
+ import { readManifest } from "../services/manifest.js";
13
+ import { activeProfile, writeActiveMarker } from "../services/profile-live.js";
8
14
  import type { ProfileManifest } from "../types/index.js";
9
15
 
10
16
  let logs: string[];
@@ -18,19 +24,10 @@ beforeEach(() => {
18
24
  });
19
25
  afterEach(() => mock.restore());
20
26
 
21
- async function setupProject(
22
- manifest: ProfileManifest,
23
- materialize: string[] = [],
24
- ): Promise<string> {
27
+ async function setupProject(manifest: ProfileManifest): Promise<string> {
25
28
  const dir = await mkdtemp(join(tmpdir(), "profcmd-"));
26
29
  await fs.ensureDir(join(dir, ".claude"));
27
30
  await fs.writeJson(join(dir, ".claude", "profiles.json"), manifest);
28
- for (const name of materialize) {
29
- const pd = join(dir, ".claude", "_profiles", name);
30
- await fs.ensureDir(pd);
31
- await fs.writeJson(join(pd, "settings.json"), { which: name });
32
- await fs.writeJson(join(pd, "mcp.json"), { mcpServers: {} });
33
- }
34
31
  return dir;
35
32
  }
36
33
 
@@ -38,7 +35,7 @@ const twoProfiles: ProfileManifest = {
38
35
  version: 2,
39
36
  profiles: {
40
37
  frontend: { name: "Frontend", plugins: { "dev@magus": "latest" } },
41
- backend: { name: "Backend", plugins: { "dev@magus": "2.9.0" } },
38
+ backend: { name: "Backend", plugins: { "terminal@magus": "2.9.0" } },
42
39
  },
43
40
  };
44
41
 
@@ -48,18 +45,41 @@ describe("profile list", () => {
48
45
  if (dir) await rm(dir, { recursive: true, force: true });
49
46
  });
50
47
 
51
- test("lists profiles, marks (not installed) and the active one", async () => {
52
- dir = await setupProject(twoProfiles, ["frontend"]);
53
- await switchProfile("frontend", dir); // activate
48
+ test("lists profiles and marks the active one", async () => {
49
+ dir = await setupProject(twoProfiles);
50
+ await switchProfile("frontend", dir);
54
51
  logs = [];
55
- const code = await listProfiles(dir);
56
- expect(code).toBe(0);
52
+ expect(await listProfiles(dir)).toBe(0);
57
53
  const out = logs.join("\n");
58
- expect(out).toContain("frontend");
54
+ expect(out).toContain("● frontend");
59
55
  expect(out).toContain("Backend");
60
- expect(out).toContain("(not installed)"); // backend isn't materialized
61
56
  expect(out).toContain("Active: frontend");
62
57
  });
58
+
59
+ test("a recorded profile the manifest no longer defines is named, with the fix", async () => {
60
+ dir = await setupProject(twoProfiles);
61
+ await writeActiveMarker(
62
+ { version: 1, profile: "gone", files: {}, skills: {} },
63
+ dir,
64
+ );
65
+ expect(await listProfiles(dir)).toBe(0);
66
+ // No default and two profiles: `<name>`, with the choices spelled out.
67
+ expect(logs.join("\n")).toContain(
68
+ 'recorded as "gone", which is not defined above. Run: `magus profile switch <name>` (profiles: frontend, backend)',
69
+ );
70
+ });
71
+
72
+ test("the fix names the declared default, never a real name in angle brackets", async () => {
73
+ dir = await setupProject({ ...twoProfiles, default: "backend" });
74
+ await writeActiveMarker(
75
+ { version: 1, profile: "gone", files: {}, skills: {} },
76
+ dir,
77
+ );
78
+ expect(await listProfiles(dir)).toBe(0);
79
+ const out = logs.join("\n");
80
+ expect(out).toContain("Run: `magus profile switch backend`");
81
+ expect(out).not.toContain("<backend>");
82
+ });
63
83
  });
64
84
 
65
85
  describe("profile show", () => {
@@ -70,9 +90,8 @@ describe("profile show", () => {
70
90
 
71
91
  test("prints a resolved closure summary", async () => {
72
92
  dir = await setupProject(twoProfiles);
73
- const code = await showProfile("backend", dir);
74
- expect(code).toBe(0);
75
- expect(logs.join("\n")).toContain("dev@magus@2.9.0");
93
+ expect(await showProfile("backend", dir)).toBe(0);
94
+ expect(logs.join("\n")).toContain("terminal@magus@2.9.0");
76
95
  });
77
96
 
78
97
  test("unknown profile → exit 1", async () => {
@@ -87,20 +106,51 @@ describe("profile switch", () => {
87
106
  if (dir) await rm(dir, { recursive: true, force: true });
88
107
  });
89
108
 
90
- test("switches when materialized and records the active profile", async () => {
91
- dir = await setupProject(twoProfiles, ["frontend", "backend"]);
109
+ test("switches and records the active profile", async () => {
110
+ dir = await setupProject(twoProfiles);
92
111
  expect(await switchProfile("backend", dir)).toBe(0);
93
112
  expect(await activeProfile(dir)).toBe("backend");
94
- // gitignore got the _profiles entry
95
- expect(await fs.readFile(join(dir, ".gitignore"), "utf8")).toContain(
96
- ".claude/_profiles/",
113
+ const gitignore = await fs.readFile(join(dir, ".gitignore"), "utf8");
114
+ expect(gitignore).toContain(".claude/profiles/");
115
+ expect(gitignore).not.toContain(".claude/_profiles/");
116
+ });
117
+
118
+ test("Claude Code's edits go into the profile being left; nothing is backed up", async () => {
119
+ dir = await setupProject(twoProfiles);
120
+ expect(await switchProfile("frontend", dir)).toBe(0);
121
+ const live = join(dir, ".claude", "settings.json");
122
+ const settings = await fs.readJson(live);
123
+ settings.enabledPlugins["code-search@magus"] = true;
124
+ await fs.writeFile(live, JSON.stringify(settings));
125
+
126
+ logs = [];
127
+ expect(await switchProfile("backend", dir)).toBe(0);
128
+
129
+ expect(logs.join("\n")).toContain(
130
+ 'Added code-search@magus to profile "frontend"',
131
+ );
132
+ const manifest = await readManifest(dir);
133
+ expect(manifest.profiles.frontend?.plugins?.["code-search@magus"]).toBe(
134
+ "latest",
135
+ );
136
+ expect((await fs.readJson(live)).enabledPlugins["terminal@magus"]).toBe(
137
+ true,
138
+ );
139
+ expect(logs.some((l) => l.includes("kept at"))).toBe(false);
140
+ expect(await fs.pathExists(join(dir, ".claude", ".magus-backups"))).toBe(
141
+ false,
97
142
  );
98
143
  });
99
144
 
100
- test("refuses to switch to a profile that isn't installed", async () => {
101
- dir = await setupProject(twoProfiles, ["frontend"]);
102
- expect(await switchProfile("backend", dir)).toBe(1);
103
- expect(errs.join("\n")).toContain("magus install backend");
145
+ test("switching back and forth backs nothing up", async () => {
146
+ dir = await setupProject(twoProfiles);
147
+ await switchProfile("frontend", dir);
148
+ await switchProfile("backend", dir);
149
+ await switchProfile("frontend", dir);
150
+ expect(logs.some((l) => l.includes("kept at"))).toBe(false);
151
+ expect(await fs.pathExists(join(dir, ".claude", ".magus-backups"))).toBe(
152
+ false,
153
+ );
104
154
  });
105
155
 
106
156
  test("unknown profile → exit 1", async () => {
@@ -108,3 +158,11 @@ describe("profile switch", () => {
108
158
  expect(await switchProfile("ghost", dir)).toBe(1);
109
159
  });
110
160
  });
161
+
162
+ describe("profile sync is gone", () => {
163
+ test("is an unknown subcommand", async () => {
164
+ expect(await runProfileCommand(["sync"])).toBe(1);
165
+ expect(errs.join("\n")).toContain('Unknown profile command "sync"');
166
+ expect(errs.join("\n")).not.toContain("| sync");
167
+ });
168
+ });
@@ -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();