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.
- package/README.md +9 -8
- package/package.json +5 -4
- package/src/__tests__/install-command.test.ts +4 -3
- package/src/__tests__/models-cli.test.ts +4 -7
- package/src/__tests__/models-manager.test.ts +52 -40
- package/src/__tests__/profile-absorb.test.ts +1210 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- package/src/__tests__/profile-dependencies.test.ts +442 -0
- package/src/__tests__/profile-manifest-write.test.ts +231 -0
- package/src/__tests__/profile-migration-offer.test.ts +18 -15
- package/src/__tests__/profile-migration.test.ts +246 -117
- package/src/__tests__/profile-render.test.ts +141 -0
- package/src/__tests__/profile-skills.test.ts +524 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +1110 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/stale-load-repair.test.ts +105 -0
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/__tests__/support/preload.ts +14 -0
- package/src/__tests__/update-apply.test.ts +145 -0
- package/src/cli/bootstrap.ts +91 -27
- package/src/cli/doctor.ts +165 -38
- package/src/cli/install.ts +126 -122
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +39 -6
- package/src/cli/update.ts +46 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-cli.ts +24 -0
- package/src/services/claude-settings.ts +27 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +113 -9
- package/src/services/manifest.ts +224 -8
- package/src/services/models-manager.ts +36 -73
- package/src/services/plugin-dependencies.ts +48 -0
- package/src/services/plugin-requires.ts +137 -14
- package/src/services/profile-absorb.ts +1586 -0
- package/src/services/profile-activate.ts +492 -94
- package/src/services/profile-live.ts +884 -0
- package/src/services/profile-render.ts +182 -0
- package/src/services/profile-skills.ts +487 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +706 -0
- package/src/services/profiles.ts +26 -37
- package/src/services/resolver.ts +100 -2
- package/src/services/settings-file.ts +5 -5
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tracked-live-files.ts +125 -0
- package/src/services/update-engine.ts +87 -2
- package/src/types/index.ts +34 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/screens/PluginsScreen.tsx +11 -2
- package/src/ui/screens/ProfilesScreen.tsx +84 -29
- package/src/ui/screens/StylesScreen.tsx +2 -1
- package/src/ui/state/types.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +0 -238
- package/src/__tests__/profile-sync.test.ts +0 -154
- package/src/__tests__/symlink-manager.test.ts +0 -180
- package/src/services/profile-materializer.ts +0 -133
- package/src/services/profile-sync.ts +0 -158
- 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
|
|
5
|
-
*
|
|
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 {
|
|
23
|
-
import { activeProfile } from "../services/
|
|
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
|
|
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", "
|
|
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", "
|
|
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
|
|
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
|
|
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", "
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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();
|