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
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>
|
|
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
|
-
|
|
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 |
|
|
252
|
-
| `.claude/
|
|
253
|
-
| `.claude/settings.json` | Claude Code | in profile mode,
|
|
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,
|
|
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.
|
|
3
|
+
"version": "7.5.1",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@opentui/react": "^0.1.75",
|
|
53
53
|
"chalk": "^5.3.0",
|
|
54
54
|
"fs-extra": "^11.2.0",
|
|
55
|
+
"jsonc-parser": "3.3.1",
|
|
55
56
|
"react": "^19.0.0",
|
|
56
57
|
"semver": "^7.6.3"
|
|
57
58
|
},
|
|
@@ -64,8 +65,8 @@
|
|
|
64
65
|
"typescript": "^5.6.3"
|
|
65
66
|
},
|
|
66
67
|
"optionalDependencies": {
|
|
67
|
-
"magus-cli-darwin-arm64": "7.
|
|
68
|
-
"magus-cli-darwin-x64": "7.
|
|
69
|
-
"magus-cli-linux-x64": "7.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.5.1",
|
|
69
|
+
"magus-cli-darwin-x64": "7.5.1",
|
|
70
|
+
"magus-cli-linux-x64": "7.5.1"
|
|
70
71
|
}
|
|
71
72
|
}
|
|
@@ -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
|
|
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
|
|
14
|
-
//
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
await
|
|
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:
|
|
59
|
-
* Built
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
await
|
|
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
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
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
|
|
143
|
-
expect((await fs.lstat(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
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(
|
|
154
|
-
|
|
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
|
|
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();
|