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
@@ -1,180 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { lstat, mkdtemp, readlink, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import fs from "fs-extra";
6
- import {
7
- activateProfile,
8
- activeProfile,
9
- profileDir,
10
- } from "../services/symlink-manager.js";
11
-
12
- /** Materialize a fake profile dir with the given files. */
13
- async function materialize(
14
- project: string,
15
- name: string,
16
- files: {
17
- settings?: object;
18
- mcp?: object;
19
- skills?: boolean;
20
- models?: object;
21
- },
22
- ) {
23
- const dir = profileDir(name, project);
24
- await fs.ensureDir(dir);
25
- if (files.settings)
26
- await fs.writeJson(join(dir, "settings.json"), files.settings);
27
- if (files.mcp) await fs.writeJson(join(dir, "mcp.json"), files.mcp);
28
- if (files.skills) await fs.ensureDir(join(dir, "skills"));
29
- if (files.models) await fs.writeJson(join(dir, "models.json"), files.models);
30
- }
31
-
32
- describe("symlink-manager", () => {
33
- let project: string;
34
- beforeEach(async () => {
35
- project = await mkdtemp(join(tmpdir(), "symlink-"));
36
- });
37
- afterEach(async () => {
38
- await rm(project, { recursive: true, force: true });
39
- });
40
-
41
- test("activateProfile links settings.json, .mcp.json, and skills/", async () => {
42
- await materialize(project, "frontend", {
43
- settings: { enabledPlugins: { "dev@magus": true } },
44
- mcp: { mcpServers: {} },
45
- skills: true,
46
- });
47
- await activateProfile("frontend", project);
48
-
49
- // settings.json is a symlink into the profile dir
50
- expect(
51
- (await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
52
- ).toBe(true);
53
- expect((await lstat(join(project, ".mcp.json"))).isSymbolicLink()).toBe(
54
- true,
55
- );
56
- expect(
57
- (await lstat(join(project, ".claude", "skills"))).isSymbolicLink(),
58
- ).toBe(true);
59
-
60
- // reading through the link returns the profile's content
61
- const settings = await fs.readJson(
62
- join(project, ".claude", "settings.json"),
63
- );
64
- expect(settings.enabledPlugins["dev@magus"]).toBe(true);
65
- });
66
-
67
- test("writeJson through the settings symlink lands in the profile dir", async () => {
68
- await materialize(project, "frontend", { settings: { a: 1 } });
69
- await activateProfile("frontend", project);
70
- await fs.writeJson(join(project, ".claude", "settings.json"), { a: 2 });
71
- // The real file inside the profile dir was updated (symlink preserved).
72
- expect(
73
- (await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
74
- ).toBe(true);
75
- expect(
76
- (await fs.readJson(`${profileDir("frontend", project)}/settings.json`)).a,
77
- ).toBe(2);
78
- });
79
-
80
- test("switching repoints links and activeProfile tracks it", async () => {
81
- await materialize(project, "frontend", { settings: { which: "frontend" } });
82
- await materialize(project, "backend", { settings: { which: "backend" } });
83
-
84
- await activateProfile("frontend", project);
85
- expect(await activeProfile(project)).toBe("frontend");
86
- expect(
87
- (await fs.readJson(join(project, ".claude", "settings.json"))).which,
88
- ).toBe("frontend");
89
-
90
- await activateProfile("backend", project);
91
- expect(await activeProfile(project)).toBe("backend");
92
- expect(
93
- (await fs.readJson(join(project, ".claude", "settings.json"))).which,
94
- ).toBe("backend");
95
- });
96
-
97
- test("uses relative (not absolute) symlinks", async () => {
98
- await materialize(project, "frontend", { settings: { a: 1 } });
99
- await activateProfile("frontend", project);
100
- const target = await readlink(join(project, ".claude", "settings.json"));
101
- expect(target.startsWith("/")).toBe(false);
102
- expect(target).toContain("_profiles");
103
- });
104
-
105
- test("a profile without skills/ creates no skills link", async () => {
106
- await materialize(project, "frontend", { settings: { a: 1 } });
107
- await activateProfile("frontend", project);
108
- expect(await fs.pathExists(join(project, ".claude", "skills"))).toBe(false);
109
- });
110
-
111
- test("activateProfile throws if the profile isn't materialized", async () => {
112
- await expect(activateProfile("ghost", project)).rejects.toThrow(
113
- "not materialized",
114
- );
115
- });
116
-
117
- test("links models.json when the profile has routing", async () => {
118
- await materialize(project, "frontend", {
119
- settings: { a: 1 },
120
- models: { version: 1, preset: "fable-advisor" },
121
- });
122
- await activateProfile("frontend", project);
123
-
124
- const link = join(project, ".claude", "models.json");
125
- expect((await lstat(link)).isSymbolicLink()).toBe(true);
126
- expect((await fs.readJson(link)).preset).toBe("fable-advisor");
127
- });
128
-
129
- // A dangling .claude/models.json is a repo-wide hazard: `.claude/` is
130
- // committed, so it would ship to every teammate and point at a directory
131
- // that only exists on the machine that made it.
132
- test("a profile without routing gets no models link at all", async () => {
133
- await materialize(project, "frontend", { settings: { a: 1 } });
134
- await activateProfile("frontend", project);
135
-
136
- const link = join(project, ".claude", "models.json");
137
- expect(await fs.pathExists(link)).toBe(false);
138
- await expect(lstat(link)).rejects.toThrow();
139
- });
140
-
141
- test("switching to a profile without routing removes the previous link", async () => {
142
- await materialize(project, "routed", {
143
- settings: { which: "routed" },
144
- models: { version: 1, preset: "fable-advisor" },
145
- });
146
- await materialize(project, "plain", { settings: { which: "plain" } });
147
-
148
- await activateProfile("routed", project);
149
- expect(
150
- (await lstat(join(project, ".claude", "models.json"))).isSymbolicLink(),
151
- ).toBe(true);
152
-
153
- await activateProfile("plain", project);
154
- await expect(
155
- lstat(join(project, ".claude", "models.json")),
156
- ).rejects.toThrow();
157
- });
158
-
159
- // Removal is scoped to links INTO _profiles/. A real models.json a project
160
- // wrote by hand is not magus's to delete.
161
- test("a real models.json file is never clobbered by activation", async () => {
162
- await materialize(project, "plain", { settings: { a: 1 } });
163
- await fs.outputJson(join(project, ".claude", "models.json"), {
164
- version: 1,
165
- preset: "hand-written",
166
- });
167
-
168
- await activateProfile("plain", project);
169
-
170
- expect(
171
- (await fs.readJson(join(project, ".claude", "models.json"))).preset,
172
- ).toBe("hand-written");
173
- });
174
-
175
- test("activeProfile returns null when settings.json is a real file", async () => {
176
- await fs.ensureDir(join(project, ".claude"));
177
- await fs.writeJson(join(project, ".claude", "settings.json"), { a: 1 });
178
- expect(await activeProfile(project)).toBeNull();
179
- });
180
- });
@@ -1,133 +0,0 @@
1
- /**
2
- * Materialize a resolved profile into `.claude/_profiles/<name>/`.
3
- *
4
- * Writes the shared config the symlink manager later points at:
5
- * _profiles/<name>/settings.json — enabledPlugins + the profile's settings
6
- * _profiles/<name>/mcp.json — the profile's MCP servers
7
- * _profiles/<name>/models.json — per-subagent model routing, when the
8
- * profile declares any; REMOVED when it
9
- * does not
10
- * _profiles/<name>/skills/ — created empty; skills install into it
11
- * (through the activated symlink)
12
- *
13
- * This is pure output derived from a ResolvedClosure — it performs no installs
14
- * and touches no global state, so it is fully testable with a temp dir.
15
- */
16
-
17
- import path from "node:path";
18
- import fs from "fs-extra";
19
- import type { ResolvedClosure } from "../types/index.js";
20
- import { profileDir } from "./symlink-manager.js";
21
-
22
- /**
23
- * Build the settings.json contents for a profile: every resolved plugin
24
- * enabled, merged with the profile's declared settings. The profile's settings
25
- * win over the derived enabledPlugins only if it explicitly sets that key
26
- * (it normally won't).
27
- *
28
- * `allPluginIds` is the manifest-wide union of plugins across every profile.
29
- * Plugins in the union that this profile does NOT want are written as an
30
- * explicit `false`, which is what makes `profile switch` exclusive rather than
31
- * additive. Claude Code resolves enabledPlugins **per plugin id**, walking from
32
- * the highest-precedence scope down and taking the first scope that mentions
33
- * the id — an id a scope omits falls through to the next one. Since
34
- * `magus install` installs the union at user scope, every non-member would
35
- * otherwise inherit that user-scope `true` and stay enabled while a profile
36
- * that excludes it is active. Omitting the union (the default) preserves the
37
- * old additive behavior for callers that have no cross-profile view.
38
- */
39
- export function buildProfileSettings(
40
- closure: ResolvedClosure,
41
- allPluginIds: readonly string[] = [],
42
- ): Record<string, unknown> {
43
- const enabledPlugins: Record<string, boolean> = {};
44
- for (const pluginId of allPluginIds) {
45
- enabledPlugins[pluginId] = false;
46
- }
47
- for (const pluginId of Object.keys(closure.plugins)) {
48
- enabledPlugins[pluginId] = true;
49
- }
50
- return { enabledPlugins, ...closure.settings };
51
- }
52
-
53
- /** Build the .mcp.json contents for a profile. */
54
- export function buildProfileMcp(closure: ResolvedClosure): {
55
- mcpServers: Record<string, unknown>;
56
- } {
57
- return { mcpServers: { ...closure.mcpServers } };
58
- }
59
-
60
- /**
61
- * Seed a profile's skills/ dir from the project's pre-existing `.claude/skills/`.
62
- *
63
- * Activating a profile replaces `.claude/skills` with a symlink into the profile
64
- * dir, and replacing means `fs.remove` first. A project that committed its own
65
- * skills before adopting profiles would lose them at that moment. So the first
66
- * time a profile's skills/ dir is created, any real (non-symlink) project skills
67
- * are copied in — every profile inherits what the project already had, and the
68
- * link swap destroys nothing.
69
- *
70
- * Only runs when the profile's skills/ dir does not exist yet, so it never
71
- * fights a later `skills-manager` install or re-adds a skill the user removed.
72
- */
73
- async function seedSkillsFromProject(
74
- skillsDir: string,
75
- projectPath?: string,
76
- ): Promise<void> {
77
- if (await fs.pathExists(skillsDir)) return;
78
-
79
- const projectSkills = path.join(
80
- projectPath ?? process.cwd(),
81
- ".claude",
82
- "skills",
83
- );
84
- try {
85
- // lstat, not pathExists: an existing profile symlink is not a source.
86
- const stat = await fs.lstat(projectSkills);
87
- if (!stat.isDirectory() || stat.isSymbolicLink()) return;
88
- if ((await fs.readdir(projectSkills)).length === 0) return;
89
- await fs.copy(projectSkills, skillsDir, { dereference: true });
90
- } catch {
91
- // No project skills dir (or unreadable) — nothing to carry over.
92
- }
93
- }
94
-
95
- /**
96
- * Write `_profiles/<name>/` from a resolved closure. Idempotent: overwrites
97
- * settings.json / mcp.json and ensures skills/ exists. Returns the dir path.
98
- */
99
- export async function materializeProfile(
100
- name: string,
101
- closure: ResolvedClosure,
102
- projectPath?: string,
103
- allPluginIds: readonly string[] = [],
104
- ): Promise<string> {
105
- const dir = profileDir(name, projectPath);
106
- await fs.ensureDir(dir);
107
- await fs.writeJson(
108
- path.join(dir, "settings.json"),
109
- buildProfileSettings(closure, allPluginIds),
110
- { spaces: 2 },
111
- );
112
- await fs.writeJson(path.join(dir, "mcp.json"), buildProfileMcp(closure), {
113
- spaces: 2,
114
- });
115
-
116
- // models.json is the one artifact that is REMOVED when the closure lacks it.
117
- // Every other file here is unconditional, so "overwrite" is enough; routing
118
- // is optional, and a profile that dropped it would otherwise keep routing
119
- // from a file nothing writes any more — `magus models off` could not turn
120
- // routing off, because the hook walks up to the nearest models.json and this
121
- // one would still be there (and still linked).
122
- const modelsPath = path.join(dir, "models.json");
123
- if (closure.models) {
124
- await fs.writeJson(modelsPath, closure.models, { spaces: 2 });
125
- } else {
126
- await fs.remove(modelsPath);
127
- }
128
-
129
- const skillsDir = path.join(dir, "skills");
130
- await seedSkillsFromProject(skillsDir, projectPath);
131
- await fs.ensureDir(skillsDir);
132
- return dir;
133
- }
@@ -1,158 +0,0 @@
1
- /**
2
- * `profile sync` — promote edits made to the active profile dir back into the
3
- * committed manifest.
4
- *
5
- * `.claude/_profiles/<name>/` is generated build output, so an edit to the
6
- * symlinked settings.json / mcp.json would be lost on the next `install`. Sync
7
- * reverse-materializes those files into `.claude/profiles.json` so the change
8
- * becomes a reviewable manifest diff instead of silent, throwaway drift.
9
- */
10
-
11
- import path from "node:path";
12
- import fs from "fs-extra";
13
- import type {
14
- McpServerConfig,
15
- ProfileManifest,
16
- ProfileManifestEntry,
17
- } from "../types/index.js";
18
- import { readManifest, writeManifest } from "./manifest.js";
19
- import { profileDir } from "./symlink-manager.js";
20
-
21
- export interface SyncChange {
22
- kind: "plugin-added" | "plugin-removed" | "settings-changed" | "mcp-changed";
23
- detail: string;
24
- }
25
-
26
- /**
27
- * Merge a materialized profile's settings.json + mcp.json back into a manifest
28
- * entry. Pure: returns the updated entry and the list of changes.
29
- *
30
- * - enabledPlugins present but not in the entry -> added as "latest".
31
- * - entry plugins that are no longer enabled -> removed.
32
- * - an existing version PIN is preserved (enabled != downgrade-to-latest).
33
- * - non-enabledPlugins settings keys replace the entry's settings.
34
- * - mcpServers replace the entry's mcpServers when they differ.
35
- */
36
- export function computeSync(
37
- entry: ProfileManifestEntry,
38
- materializedSettings: Record<string, unknown>,
39
- materializedMcp: { mcpServers?: Record<string, McpServerConfig> },
40
- now: string = new Date().toISOString(),
41
- ): { entry: ProfileManifestEntry; changes: SyncChange[] } {
42
- const changes: SyncChange[] = [];
43
- const next: ProfileManifestEntry = { ...entry };
44
-
45
- const enabled = (materializedSettings.enabledPlugins ?? {}) as Record<
46
- string,
47
- boolean
48
- >;
49
- const currentPlugins = { ...(entry.plugins ?? {}) };
50
-
51
- // Additions: enabled plugins the manifest doesn't list.
52
- for (const [id, on] of Object.entries(enabled)) {
53
- if (on && !(id in currentPlugins)) {
54
- currentPlugins[id] = "latest";
55
- changes.push({ kind: "plugin-added", detail: id });
56
- }
57
- }
58
- // Removals: manifest plugins no longer enabled.
59
- for (const id of Object.keys(entry.plugins ?? {})) {
60
- if (!enabled[id]) {
61
- delete currentPlugins[id];
62
- changes.push({ kind: "plugin-removed", detail: id });
63
- }
64
- }
65
- next.plugins = currentPlugins;
66
-
67
- // Settings = everything in the materialized file except enabledPlugins.
68
- const { enabledPlugins: _drop, ...settings } = materializedSettings;
69
- if (JSON.stringify(settings) !== JSON.stringify(entry.settings ?? {})) {
70
- if (Object.keys(settings).length > 0) next.settings = settings;
71
- else {
72
- // biome-ignore lint/performance/noDelete: the manifest entry is serialised to profiles.json and diffed with JSON.stringify/toStrictEqual; the key must be absent, not undefined
73
- delete next.settings;
74
- }
75
- changes.push({ kind: "settings-changed", detail: "settings updated" });
76
- }
77
-
78
- // MCP servers.
79
- const mcp = materializedMcp.mcpServers ?? {};
80
- if (JSON.stringify(mcp) !== JSON.stringify(entry.mcpServers ?? {})) {
81
- if (Object.keys(mcp).length > 0) next.mcpServers = mcp;
82
- else {
83
- // biome-ignore lint/performance/noDelete: the manifest entry is serialised to profiles.json and diffed with JSON.stringify/toStrictEqual; the key must be absent, not undefined
84
- delete next.mcpServers;
85
- }
86
- changes.push({ kind: "mcp-changed", detail: "mcp servers updated" });
87
- }
88
-
89
- // Refresh the timestamp when anything changed. `now` is injectable so
90
- // callers/tests control it (and computeSync stays pure).
91
- if (changes.length > 0) next.updatedAt = now;
92
-
93
- return { entry: next, changes };
94
- }
95
-
96
- /**
97
- * Detect drift between a materialized profile and the manifest WITHOUT writing.
98
- * Returns the changes `sync` would promote (empty = in sync). Returns [] if the
99
- * profile isn't materialized (nothing to lose).
100
- */
101
- export async function detectProfileDrift(
102
- name: string,
103
- projectPath?: string,
104
- ): Promise<SyncChange[]> {
105
- const manifest = await readManifest(projectPath);
106
- const entry = manifest.profiles[name];
107
- if (!entry) return [];
108
-
109
- const dir = profileDir(name, projectPath);
110
- const settingsPath = path.join(dir, "settings.json");
111
- if (!(await fs.pathExists(settingsPath))) return [];
112
-
113
- const mcpPath = path.join(dir, "mcp.json");
114
- const materializedSettings = await fs.readJson(settingsPath);
115
- const materializedMcp = (await fs.pathExists(mcpPath))
116
- ? await fs.readJson(mcpPath)
117
- : { mcpServers: {} };
118
-
119
- return computeSync(entry, materializedSettings, materializedMcp).changes;
120
- }
121
-
122
- /**
123
- * Read the materialized active profile, merge it into the manifest, and write
124
- * the manifest back if anything changed. Returns the applied changes.
125
- */
126
- export async function syncProfile(
127
- name: string,
128
- projectPath?: string,
129
- ): Promise<{ changes: SyncChange[]; wrote: boolean }> {
130
- const manifest: ProfileManifest = await readManifest(projectPath);
131
- const entry = manifest.profiles[name];
132
- if (!entry) throw new Error(`Profile "${name}" not found in manifest`);
133
-
134
- const dir = profileDir(name, projectPath);
135
- const settingsPath = path.join(dir, "settings.json");
136
- const mcpPath = path.join(dir, "mcp.json");
137
- if (!(await fs.pathExists(settingsPath))) {
138
- throw new Error(
139
- `Profile "${name}" isn't materialized (${settingsPath} missing).`,
140
- );
141
- }
142
-
143
- const materializedSettings = await fs.readJson(settingsPath);
144
- const materializedMcp = (await fs.pathExists(mcpPath))
145
- ? await fs.readJson(mcpPath)
146
- : { mcpServers: {} };
147
-
148
- const { entry: nextEntry, changes } = computeSync(
149
- entry,
150
- materializedSettings,
151
- materializedMcp,
152
- );
153
- if (changes.length === 0) return { changes: [], wrote: false };
154
-
155
- manifest.profiles[name] = nextEntry;
156
- await writeManifest(manifest, projectPath);
157
- return { changes, wrote: true };
158
- }