magus-cli 7.4.1 → 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 (53) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -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 +1138 -0
  7. package/src/__tests__/profile-command.test.ts +90 -32
  8. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  9. package/src/__tests__/profile-migration.test.ts +246 -117
  10. package/src/__tests__/profile-render.test.ts +141 -0
  11. package/src/__tests__/profile-skills.test.ts +425 -0
  12. package/src/__tests__/profile-startup.test.ts +44 -14
  13. package/src/__tests__/profile-state.test.ts +795 -0
  14. package/src/__tests__/settings-file.test.ts +5 -7
  15. package/src/__tests__/styles-manager.test.ts +47 -74
  16. package/src/__tests__/styles-screen-state.test.ts +11 -10
  17. package/src/cli/bootstrap.ts +87 -27
  18. package/src/cli/doctor.ts +132 -38
  19. package/src/cli/install.ts +58 -118
  20. package/src/cli/models.ts +21 -1
  21. package/src/cli/profile.ts +48 -77
  22. package/src/cli/router.ts +32 -6
  23. package/src/cli/update.ts +43 -6
  24. package/src/data/gitignore-defaults.ts +3 -3
  25. package/src/services/active-closure.ts +3 -3
  26. package/src/services/conventions-manager.ts +88 -0
  27. package/src/services/git-worktree.ts +21 -0
  28. package/src/services/manifest.ts +4 -5
  29. package/src/services/models-manager.ts +36 -73
  30. package/src/services/profile-absorb.ts +1532 -0
  31. package/src/services/profile-activate.ts +348 -96
  32. package/src/services/profile-live.ts +825 -0
  33. package/src/services/profile-render.ts +115 -0
  34. package/src/services/profile-skills.ts +462 -0
  35. package/src/services/profile-startup.ts +88 -16
  36. package/src/services/profile-state.ts +622 -0
  37. package/src/services/profiles.ts +20 -32
  38. package/src/services/resolver.ts +6 -1
  39. package/src/services/settings-file.ts +5 -5
  40. package/src/services/skills-manager.ts +14 -8
  41. package/src/services/styles-manager.ts +45 -28
  42. package/src/types/index.ts +15 -9
  43. package/src/ui/App.tsx +25 -2
  44. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  45. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  46. package/src/ui/screens/StylesScreen.tsx +2 -1
  47. package/src/ui/state/types.ts +1 -1
  48. package/src/__tests__/profile-materializer.test.ts +0 -238
  49. package/src/__tests__/profile-sync.test.ts +0 -154
  50. package/src/__tests__/symlink-manager.test.ts +0 -180
  51. package/src/services/profile-materializer.ts +0 -133
  52. package/src/services/profile-sync.ts +0 -158
  53. package/src/services/symlink-manager.ts +0 -343
package/README.md CHANGED
@@ -43,7 +43,6 @@ install [profile] Install a profile's plugins, binaries, skills and
43
43
  it. No argument = every profile.
44
44
  --check Report drift only, write nothing (CI gate)
45
45
  --yes, -y Skip the confirmation prompt
46
- --force Discard unsynced local edits instead of refusing
47
46
  update [profile] Bring the ACTIVE profile up to date: install what it
48
47
  declares but this machine lacks, and advance every
49
48
  "latest" pin to the newest published version.
@@ -52,10 +51,10 @@ update [profile] Bring the ACTIVE profile up to date: install what it
52
51
  --yes, -y Skip the confirmation prompt
53
52
  profile list Show every profile; ● marks the active one
54
53
  profile show <name> Print a profile's fully resolved closure
55
- profile switch <name> Repoint the active profile — offline, no reinstall
54
+ profile switch <name> Make <name> active: render its settings.json,
55
+ .mcp.json and models.json, install its skills
56
56
  profile init Write .claude/profiles.json from this project's setup
57
- profile sync Promote local edits back into .claude/profiles.json
58
- doctor [--fix] Check binary deps, profile symlinks, conventions
57
+ doctor [--fix] Check binary deps, the active profile, conventions
59
58
 
60
59
  claude [args...] Check for plugin updates (1h cache), then run claude
61
60
  --force, -f Force the update check
@@ -248,13 +247,15 @@ written. `force-for-plugin` is never set either — that would override your own
248
247
 
249
248
  | Path | Owner | What magus does |
250
249
  |---|---|---|
251
- | `.claude/profiles.json` | you, committed | reads it; `profile sync` writes to it |
252
- | `.claude/_profiles/<name>/` | magus, gitignored | generated build output |
253
- | `.claude/settings.json` | Claude Code | in profile mode, a symlink into `_profiles/` |
250
+ | `.claude/profiles.json` | you, committed | the one source of truth; records every change to the live config |
251
+ | `.claude/profiles/active.json` | magus, gitignored | which profile is active, and what magus last wrote |
252
+ | `.claude/settings.json` | magus + Claude Code, gitignored | in profile mode, rendered from the active profile; Claude Code's edits are recorded back into it |
253
+ | `.claude/models.json` | magus, gitignored | in profile mode, rendered from the active profile's routing |
254
+ | `.claude/skills/<name>/` | magus or you | a skill the active profile lists is installed and gitignored by magus; any other folder is yours and is never touched |
254
255
  | `.claude/settings.local.json` | you, gitignored | env values collected during install |
255
256
  | `.claude/style.json` | you, committed | the project's declared style — written on apply |
256
257
  | `.claude/output-styles/composed*.md` | magus, committed | the generated style Claude Code reads |
257
- | `.mcp.json` | Claude Code | in profile mode, a symlink into `_profiles/` |
258
+ | `.mcp.json` | magus + Claude Code, gitignored | in profile mode, rendered from the active profile; Claude Code's edits are recorded back into it |
258
259
  | `~/.claude/plugins/*` | Claude Code | never written directly — always via the `claude` CLI |
259
260
 
260
261
  magus never hand-edits Claude Code's own registries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.4.1",
3
+ "version": "7.5.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "magus-cli-darwin-arm64": "7.4.1",
68
- "magus-cli-darwin-x64": "7.4.1",
69
- "magus-cli-linux-x64": "7.4.1"
67
+ "magus-cli-darwin-arm64": "7.5.0",
68
+ "magus-cli-darwin-x64": "7.5.0",
69
+ "magus-cli-linux-x64": "7.5.0"
70
70
  }
71
71
  }
@@ -7,11 +7,12 @@ import { runInstallCommand } from "../cli/install.js";
7
7
 
8
8
  // Integration coverage for runInstallCommand's VALIDATION wiring — the paths
9
9
  // that return before the closure is resolved. We deliberately don't exercise
10
- // --check / the drift guard here: those resolve the closure, which reaches
10
+ // --check or the install itself here: those resolve the closure, which reaches
11
11
  // claude-settings/plugin-manager — modules other test files replace globally
12
12
  // via bun's mock.module (not restorable across files in bun 1.3.10). The
13
- // --check and drift LOGIC is covered by pure-function tests instead
14
- // (install-plan.test.ts computeVersionDrift, profile-sync.test.ts computeSync).
13
+ // --check logic is covered by install-plan.test.ts (computeVersionDrift); the
14
+ // re-render over the Claude CLI's own settings.json writes by
15
+ // profile-absorb.test.ts (regenerateLiveFiles).
15
16
  //
16
17
  // runInstallCommand takes an explicit projectPath so no cwd juggling is needed.
17
18
  // Console is captured via explicit save/restore.
@@ -14,6 +14,7 @@ import fs from "fs-extra";
14
14
  import { runModelsCommand } from "../cli/models.js";
15
15
  import { presetNames } from "../data/models-presets.js";
16
16
  import { writeManifest } from "../services/manifest.js";
17
+ import { activateProfile } from "../services/profile-activate.js";
17
18
 
18
19
  let logs: string[];
19
20
  let errs: string[];
@@ -56,13 +57,9 @@ afterEach(async () => {
56
57
  * under test rather than on how the runner was launched.
57
58
  */
58
59
  async function seedProfile(name = "team"): Promise<void> {
59
- await writeManifest({ version: 2, profiles: { [name]: { name } } }, project);
60
- const dir = join(project, ".claude", "_profiles", name);
61
- await fs.outputJson(join(dir, "settings.json"), {});
62
- await fs.symlink(
63
- join("_profiles", name, "settings.json"),
64
- join(project, ".claude", "settings.json"),
65
- );
60
+ const manifest = { version: 2 as const, profiles: { [name]: { name } } };
61
+ await writeManifest(manifest, project);
62
+ await activateProfile(manifest, name, project, { onDisplace: () => {} });
66
63
  }
67
64
 
68
65
  describe("models use", () => {
@@ -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();