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.
- package/README.md +9 -8
- package/package.json +4 -4
- package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- 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 +1138 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- 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 +425 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +795 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/cli/bootstrap.ts +87 -27
- package/src/cli/doctor.ts +152 -44
- package/src/cli/install.ts +92 -127
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +32 -6
- package/src/cli/update.ts +43 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +283 -125
- package/src/services/cli-tool-commands.ts +30 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +21 -0
- package/src/services/gitignore-service.ts +39 -22
- package/src/services/manifest.ts +4 -5
- package/src/services/models-manager.ts +36 -73
- package/src/services/profile-absorb.ts +1532 -0
- package/src/services/profile-activate.ts +348 -96
- package/src/services/profile-live.ts +825 -0
- package/src/services/profile-render.ts +115 -0
- package/src/services/profile-skills.ts +462 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +622 -0
- package/src/services/profiles.ts +20 -32
- package/src/services/resolver.ts +6 -1
- package/src/services/settings-file.ts +14 -8
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tool-probe.ts +48 -3
- package/src/types/index.ts +15 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/components/modals/ModalContainer.tsx +9 -1
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/input/KeyRouter.tsx +147 -23
- package/src/ui/input/key-dispatch.ts +48 -0
- package/src/ui/keymap.ts +8 -1
- package/src/ui/renderers/cliToolRenderers.tsx +65 -27
- package/src/ui/screens/CliToolsScreen.tsx +74 -13
- package/src/ui/screens/PluginsScreen.tsx +6 -2
- package/src/ui/screens/ProfilesScreen.tsx +75 -27
- 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
|
@@ -3,8 +3,14 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import fs from "fs-extra";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
listProfiles,
|
|
8
|
+
runProfileCommand,
|
|
9
|
+
showProfile,
|
|
10
|
+
switchProfile,
|
|
11
|
+
} from "../cli/profile.js";
|
|
12
|
+
import { readManifest } from "../services/manifest.js";
|
|
13
|
+
import { activeProfile, writeActiveMarker } from "../services/profile-live.js";
|
|
8
14
|
import type { ProfileManifest } from "../types/index.js";
|
|
9
15
|
|
|
10
16
|
let logs: string[];
|
|
@@ -18,19 +24,10 @@ beforeEach(() => {
|
|
|
18
24
|
});
|
|
19
25
|
afterEach(() => mock.restore());
|
|
20
26
|
|
|
21
|
-
async function setupProject(
|
|
22
|
-
manifest: ProfileManifest,
|
|
23
|
-
materialize: string[] = [],
|
|
24
|
-
): Promise<string> {
|
|
27
|
+
async function setupProject(manifest: ProfileManifest): Promise<string> {
|
|
25
28
|
const dir = await mkdtemp(join(tmpdir(), "profcmd-"));
|
|
26
29
|
await fs.ensureDir(join(dir, ".claude"));
|
|
27
30
|
await fs.writeJson(join(dir, ".claude", "profiles.json"), manifest);
|
|
28
|
-
for (const name of materialize) {
|
|
29
|
-
const pd = join(dir, ".claude", "_profiles", name);
|
|
30
|
-
await fs.ensureDir(pd);
|
|
31
|
-
await fs.writeJson(join(pd, "settings.json"), { which: name });
|
|
32
|
-
await fs.writeJson(join(pd, "mcp.json"), { mcpServers: {} });
|
|
33
|
-
}
|
|
34
31
|
return dir;
|
|
35
32
|
}
|
|
36
33
|
|
|
@@ -38,7 +35,7 @@ const twoProfiles: ProfileManifest = {
|
|
|
38
35
|
version: 2,
|
|
39
36
|
profiles: {
|
|
40
37
|
frontend: { name: "Frontend", plugins: { "dev@magus": "latest" } },
|
|
41
|
-
backend: { name: "Backend", plugins: { "
|
|
38
|
+
backend: { name: "Backend", plugins: { "terminal@magus": "2.9.0" } },
|
|
42
39
|
},
|
|
43
40
|
};
|
|
44
41
|
|
|
@@ -48,18 +45,41 @@ describe("profile list", () => {
|
|
|
48
45
|
if (dir) await rm(dir, { recursive: true, force: true });
|
|
49
46
|
});
|
|
50
47
|
|
|
51
|
-
test("lists profiles
|
|
52
|
-
dir = await setupProject(twoProfiles
|
|
53
|
-
await switchProfile("frontend", dir);
|
|
48
|
+
test("lists profiles and marks the active one", async () => {
|
|
49
|
+
dir = await setupProject(twoProfiles);
|
|
50
|
+
await switchProfile("frontend", dir);
|
|
54
51
|
logs = [];
|
|
55
|
-
|
|
56
|
-
expect(code).toBe(0);
|
|
52
|
+
expect(await listProfiles(dir)).toBe(0);
|
|
57
53
|
const out = logs.join("\n");
|
|
58
|
-
expect(out).toContain("frontend");
|
|
54
|
+
expect(out).toContain("● frontend");
|
|
59
55
|
expect(out).toContain("Backend");
|
|
60
|
-
expect(out).toContain("(not installed)"); // backend isn't materialized
|
|
61
56
|
expect(out).toContain("Active: frontend");
|
|
62
57
|
});
|
|
58
|
+
|
|
59
|
+
test("a recorded profile the manifest no longer defines is named, with the fix", async () => {
|
|
60
|
+
dir = await setupProject(twoProfiles);
|
|
61
|
+
await writeActiveMarker(
|
|
62
|
+
{ version: 1, profile: "gone", files: {}, skills: {} },
|
|
63
|
+
dir,
|
|
64
|
+
);
|
|
65
|
+
expect(await listProfiles(dir)).toBe(0);
|
|
66
|
+
// No default and two profiles: `<name>`, with the choices spelled out.
|
|
67
|
+
expect(logs.join("\n")).toContain(
|
|
68
|
+
'recorded as "gone", which is not defined above. Run: `magus profile switch <name>` (profiles: frontend, backend)',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the fix names the declared default, never a real name in angle brackets", async () => {
|
|
73
|
+
dir = await setupProject({ ...twoProfiles, default: "backend" });
|
|
74
|
+
await writeActiveMarker(
|
|
75
|
+
{ version: 1, profile: "gone", files: {}, skills: {} },
|
|
76
|
+
dir,
|
|
77
|
+
);
|
|
78
|
+
expect(await listProfiles(dir)).toBe(0);
|
|
79
|
+
const out = logs.join("\n");
|
|
80
|
+
expect(out).toContain("Run: `magus profile switch backend`");
|
|
81
|
+
expect(out).not.toContain("<backend>");
|
|
82
|
+
});
|
|
63
83
|
});
|
|
64
84
|
|
|
65
85
|
describe("profile show", () => {
|
|
@@ -70,9 +90,8 @@ describe("profile show", () => {
|
|
|
70
90
|
|
|
71
91
|
test("prints a resolved closure summary", async () => {
|
|
72
92
|
dir = await setupProject(twoProfiles);
|
|
73
|
-
|
|
74
|
-
expect(
|
|
75
|
-
expect(logs.join("\n")).toContain("dev@magus@2.9.0");
|
|
93
|
+
expect(await showProfile("backend", dir)).toBe(0);
|
|
94
|
+
expect(logs.join("\n")).toContain("terminal@magus@2.9.0");
|
|
76
95
|
});
|
|
77
96
|
|
|
78
97
|
test("unknown profile → exit 1", async () => {
|
|
@@ -87,20 +106,51 @@ describe("profile switch", () => {
|
|
|
87
106
|
if (dir) await rm(dir, { recursive: true, force: true });
|
|
88
107
|
});
|
|
89
108
|
|
|
90
|
-
test("switches
|
|
91
|
-
dir = await setupProject(twoProfiles
|
|
109
|
+
test("switches and records the active profile", async () => {
|
|
110
|
+
dir = await setupProject(twoProfiles);
|
|
92
111
|
expect(await switchProfile("backend", dir)).toBe(0);
|
|
93
112
|
expect(await activeProfile(dir)).toBe("backend");
|
|
94
|
-
|
|
95
|
-
expect(
|
|
96
|
-
|
|
113
|
+
const gitignore = await fs.readFile(join(dir, ".gitignore"), "utf8");
|
|
114
|
+
expect(gitignore).toContain(".claude/profiles/");
|
|
115
|
+
expect(gitignore).not.toContain(".claude/_profiles/");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("Claude Code's edits go into the profile being left; nothing is backed up", async () => {
|
|
119
|
+
dir = await setupProject(twoProfiles);
|
|
120
|
+
expect(await switchProfile("frontend", dir)).toBe(0);
|
|
121
|
+
const live = join(dir, ".claude", "settings.json");
|
|
122
|
+
const settings = await fs.readJson(live);
|
|
123
|
+
settings.enabledPlugins["code-search@magus"] = true;
|
|
124
|
+
await fs.writeFile(live, JSON.stringify(settings));
|
|
125
|
+
|
|
126
|
+
logs = [];
|
|
127
|
+
expect(await switchProfile("backend", dir)).toBe(0);
|
|
128
|
+
|
|
129
|
+
expect(logs.join("\n")).toContain(
|
|
130
|
+
'Added code-search@magus to profile "frontend"',
|
|
131
|
+
);
|
|
132
|
+
const manifest = await readManifest(dir);
|
|
133
|
+
expect(manifest.profiles.frontend?.plugins?.["code-search@magus"]).toBe(
|
|
134
|
+
"latest",
|
|
135
|
+
);
|
|
136
|
+
expect((await fs.readJson(live)).enabledPlugins["terminal@magus"]).toBe(
|
|
137
|
+
true,
|
|
138
|
+
);
|
|
139
|
+
expect(logs.some((l) => l.includes("kept at"))).toBe(false);
|
|
140
|
+
expect(await fs.pathExists(join(dir, ".claude", ".magus-backups"))).toBe(
|
|
141
|
+
false,
|
|
97
142
|
);
|
|
98
143
|
});
|
|
99
144
|
|
|
100
|
-
test("
|
|
101
|
-
dir = await setupProject(twoProfiles
|
|
102
|
-
|
|
103
|
-
|
|
145
|
+
test("switching back and forth backs nothing up", async () => {
|
|
146
|
+
dir = await setupProject(twoProfiles);
|
|
147
|
+
await switchProfile("frontend", dir);
|
|
148
|
+
await switchProfile("backend", dir);
|
|
149
|
+
await switchProfile("frontend", dir);
|
|
150
|
+
expect(logs.some((l) => l.includes("kept at"))).toBe(false);
|
|
151
|
+
expect(await fs.pathExists(join(dir, ".claude", ".magus-backups"))).toBe(
|
|
152
|
+
false,
|
|
153
|
+
);
|
|
104
154
|
});
|
|
105
155
|
|
|
106
156
|
test("unknown profile → exit 1", async () => {
|
|
@@ -108,3 +158,11 @@ describe("profile switch", () => {
|
|
|
108
158
|
expect(await switchProfile("ghost", dir)).toBe(1);
|
|
109
159
|
});
|
|
110
160
|
});
|
|
161
|
+
|
|
162
|
+
describe("profile sync is gone", () => {
|
|
163
|
+
test("is an unknown subcommand", async () => {
|
|
164
|
+
expect(await runProfileCommand(["sync"])).toBe(1);
|
|
165
|
+
expect(errs.join("\n")).toContain('Unknown profile command "sync"');
|
|
166
|
+
expect(errs.join("\n")).not.toContain("| sync");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -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();
|