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
@@ -23,6 +23,8 @@ import {
23
23
  readModelsStatus,
24
24
  resolveFullModelId,
25
25
  } from "../services/models-manager.js";
26
+ import { activateProfile } from "../services/profile-activate.js";
27
+ import { liveHash, readActiveMarker } from "../services/profile-live.js";
26
28
  import type { ProfileManifest } from "../types/index.js";
27
29
 
28
30
  let project: string;
@@ -55,22 +57,16 @@ function manifestWith(profile: string, extra = {}): ProfileManifest {
55
57
 
56
58
  /**
57
59
  * Stage a project that already has a profile ACTIVE, the way `magus install`
58
- * leaves one: a materialized dir and `.claude/settings.json` symlinked into it.
59
- * Built by hand rather than by running install, which needs the claude CLI.
60
+ * leaves one: the live files rendered from the manifest and recorded in the
61
+ * marker. Built without running install, which needs the claude CLI.
60
62
  */
61
63
  async function stageActiveProfile(
62
64
  name: string,
63
65
  entry: Record<string, unknown> = {},
64
- settings: Record<string, unknown> = {},
65
66
  ): Promise<void> {
66
- await writeManifest(manifestWith(name, entry), project);
67
- const dir = join(project, ".claude", "_profiles", name);
68
- await fs.outputJson(join(dir, "settings.json"), settings, { spaces: 2 });
69
- await fs.ensureDir(join(project, ".claude"));
70
- await fs.symlink(
71
- join("_profiles", name, "settings.json"),
72
- join(project, ".claude", "settings.json"),
73
- );
67
+ const manifest = manifestWith(name, entry);
68
+ await writeManifest(manifest, project);
69
+ await activateProfile(manifest, name, project, { onDisplace: () => {} });
74
70
  }
75
71
 
76
72
  async function readSettings(): Promise<Record<string, unknown>> {
@@ -131,34 +127,55 @@ describe("applyModelPreset", () => {
131
127
  expect(again.hook).toBe("already");
132
128
  });
133
129
 
134
- // The symlink is how the active profile is wired. `updateSettingsFile` writes
135
- // through it on purpose; a remove-then-create would silently detach the
136
- // project from its profile and leave the next read looking at a fresh file.
137
- test("keeps the profile symlink and updates profiles.json under it", async () => {
130
+ // settings.json and models.json are real files rendered from the manifest
131
+ // (Claude Code refuses to write through a symlink), recorded in the marker.
132
+ test("renders settings.json and models.json from the profile as real files", async () => {
138
133
  await stageActiveProfile("team", { plugins: { "dev@magus": "latest" } });
139
134
 
140
135
  await applyModelPreset({ projectPath: project, preset: PRESET });
141
136
 
142
- const link = join(project, ".claude", "settings.json");
143
- expect((await fs.lstat(link)).isSymbolicLink()).toBe(true);
144
- expect(await fs.readlink(link)).toBe(
145
- join("_profiles", "team", "settings.json"),
146
- );
147
-
148
- // The write landed in the profile's copy, and re-materializing did not
149
- // cost the profile the plugins its manifest entry declares.
150
- const inProfile = await fs.readJson(
151
- join(project, ".claude", "_profiles", "team", "settings.json"),
137
+ const live = join(project, ".claude", "settings.json");
138
+ expect((await fs.lstat(live)).isFile()).toBe(true);
139
+ const settings = await readSettings();
140
+ expect(settings.model).toBe("opus");
141
+ // Rendering from the manifest kept the plugins the entry declares.
142
+ expect(
143
+ (settings.enabledPlugins as Record<string, boolean>)["dev@magus"],
144
+ ).toBe(true);
145
+ const marker = await readActiveMarker(project);
146
+ expect(marker?.files.settings).toBe(
147
+ (await liveHash("settings", project)) ?? "",
152
148
  );
153
- expect(inProfile.model).toBe("opus");
154
- expect(inProfile.enabledPlugins["dev@magus"]).toBe(true);
155
-
156
- // And .claude/models.json is a link into the same profile dir, not a copy.
157
- const modelsLink = join(project, ".claude", "models.json");
158
- expect((await fs.lstat(modelsLink)).isSymbolicLink()).toBe(true);
159
- expect(await fs.readlink(modelsLink)).toBe(
160
- join("_profiles", "team", "models.json"),
149
+ expect(marker?.files.models).toBe(
150
+ (await liveHash("models", project)) ?? "",
161
151
  );
152
+
153
+ const modelsFile = join(project, ".claude", "models.json");
154
+ expect((await fs.lstat(modelsFile)).isSymbolicLink()).toBe(false);
155
+ expect((await fs.readJson(modelsFile)).preset).toBe(PRESET);
156
+ expect(
157
+ await fs.pathExists(join(project, ".claude", "profiles", "team")),
158
+ ).toBe(false);
159
+ });
160
+
161
+ test("a Claude Code edit to settings.json is absorbed into the profile, then the keys are added", async () => {
162
+ await stageActiveProfile("team");
163
+ // Claude Code wrote a permission; that is a change to the active profile.
164
+ await fs.writeJson(join(project, ".claude", "settings.json"), {
165
+ enabledPlugins: {},
166
+ permissions: { allow: ["Bash(ls)"] },
167
+ });
168
+
169
+ await applyModelPreset({ projectPath: project, preset: PRESET });
170
+
171
+ const settings = await readSettings();
172
+ expect(settings.model).toBe("opus");
173
+ expect(settings.permissions).toEqual({ allow: ["Bash(ls)"] });
174
+ const manifest = await fs.readJson(getManifestPath(project));
175
+ expect(manifest.profiles.team.settings.permissions).toEqual({
176
+ allow: ["Bash(ls)"],
177
+ });
178
+ expect(manifest.profiles.team.settings.model).toBe("opus");
162
179
  });
163
180
 
164
181
  test("without a profile it writes a plain models.json and records nothing", async () => {
@@ -212,20 +229,15 @@ describe("applyModelPreset", () => {
212
229
  });
213
230
 
214
231
  describe("clearModels", () => {
215
- test("removes the config, the link, and the manifest entry", async () => {
232
+ test("removes the config file and the manifest entry", async () => {
216
233
  await stageActiveProfile("team");
217
234
  await applyModelPreset({ projectPath: project, preset: PRESET });
218
235
 
219
236
  await clearModels(project);
220
237
 
221
238
  expect(await fs.pathExists(configPath(project))).toBe(false);
222
- // lstat, not pathExists: a DANGLING symlink is the failure this guards.
223
239
  await expect(fs.lstat(configPath(project))).rejects.toThrow();
224
- expect(
225
- await fs.pathExists(
226
- join(project, ".claude", "_profiles", "team", "models.json"),
227
- ),
228
- ).toBe(false);
240
+ expect((await readActiveMarker(project))?.files.models).toBeUndefined();
229
241
 
230
242
  const manifest = await fs.readJson(getManifestPath(project));
231
243
  expect(manifest.profiles.team.models).toBeUndefined();