magus-cli 7.1.0 → 7.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.1.0",
3
+ "version": "7.2.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.1.0",
68
- "magus-cli-darwin-x64": "7.1.0",
69
- "magus-cli-linux-x64": "7.1.0"
67
+ "magus-cli-darwin-arm64": "7.2.0",
68
+ "magus-cli-darwin-x64": "7.2.0",
69
+ "magus-cli-linux-x64": "7.2.0"
70
70
  }
71
71
  }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * The CLI gate: no active profile, no command.
3
+ *
4
+ * This replaces `offerAdoptionOnDrift`, which asked only when the manifest had
5
+ * drifted from `enabledPlugins`, let the user decline, and then let `magus
6
+ * update` carry on with a profile "for this run only" — resolving against a
7
+ * manifest the project was not running. Two worlds live at once.
8
+ *
9
+ * The interactive path mounts a real OpenTUI renderer and cannot run here; what
10
+ * IS testable is every path that must refuse, which is the behaviour that
11
+ * changed. `selectDialog` returns null without a TTY, so a non-TTY run takes
12
+ * the refusal branch by construction — which is also what CI does.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
16
+ import { mkdtemp, rm } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import fs from "fs-extra";
20
+ import { requireActiveProfile } from "../cli/bootstrap.js";
21
+ import { runUpdateCommand } from "../cli/update.js";
22
+ import { materializeAndActivate } from "../services/profile-activate.js";
23
+ import { activeProfile } from "../services/symlink-manager.js";
24
+ import type { ProfileManifest } from "../types/index.js";
25
+
26
+ const MANIFEST: ProfileManifest = {
27
+ version: 2,
28
+ profiles: {
29
+ lean: { name: "Lean", plugins: { "terminal@magus": "latest" } },
30
+ full: { name: "Full", plugins: { "dev@magus": "latest" } },
31
+ },
32
+ };
33
+
34
+ describe("requireActiveProfile", () => {
35
+ let project: string;
36
+ let wasTTY: boolean | undefined;
37
+
38
+ beforeEach(async () => {
39
+ project = await mkdtemp(join(tmpdir(), "gate-"));
40
+ await fs.outputJson(join(project, ".claude", "settings.json"), {
41
+ enabledPlugins: { "dev@magus": true },
42
+ });
43
+ wasTTY = process.stdin.isTTY;
44
+ });
45
+ afterEach(async () => {
46
+ Object.defineProperty(process.stdin, "isTTY", {
47
+ value: wasTTY,
48
+ configurable: true,
49
+ });
50
+ await rm(project, { recursive: true, force: true });
51
+ });
52
+
53
+ /** Force the non-interactive path, as CI has. */
54
+ function noTTY(): void {
55
+ Object.defineProperty(process.stdin, "isTTY", {
56
+ value: false,
57
+ configurable: true,
58
+ });
59
+ }
60
+
61
+ test("refuses when profiles exist but none is active", async () => {
62
+ await fs.outputJson(join(project, ".claude", "profiles.json"), MANIFEST);
63
+ noTTY();
64
+
65
+ // null is the caller's signal to exit non-zero. The old code returned the
66
+ // manifest here and the command went on to update a profile the project
67
+ // was not running.
68
+ expect(await requireActiveProfile(project, MANIFEST)).toBeNull();
69
+ expect(await activeProfile(project)).toBeNull();
70
+ });
71
+
72
+ test("refuses when the project has no manifest at all", async () => {
73
+ noTTY();
74
+ expect(
75
+ await requireActiveProfile(project, { version: 2, profiles: {} }),
76
+ ).toBeNull();
77
+ });
78
+
79
+ test("refusing writes nothing", async () => {
80
+ await fs.outputJson(join(project, ".claude", "profiles.json"), MANIFEST);
81
+ noTTY();
82
+
83
+ await requireActiveProfile(project, MANIFEST);
84
+
85
+ // No profile materialized, no backups taken, settings left exactly as it
86
+ // was: a refusal is not allowed to half-migrate anything.
87
+ expect(await fs.pathExists(join(project, ".claude", "_profiles"))).toBe(
88
+ false,
89
+ );
90
+ expect(
91
+ await fs.pathExists(join(project, ".claude", ".magus-backups")),
92
+ ).toBe(false);
93
+ expect(
94
+ (
95
+ await fs.lstat(join(project, ".claude", "settings.json"))
96
+ ).isSymbolicLink(),
97
+ ).toBe(false);
98
+ expect(
99
+ await fs.readJson(join(project, ".claude", "profiles.json")),
100
+ ).toEqual(MANIFEST);
101
+ });
102
+
103
+ test("--check refuses too, and never repairs", async () => {
104
+ await fs.outputJson(join(project, ".claude", "profiles.json"), MANIFEST);
105
+
106
+ // Even at a TTY: `--check` is a read-only CI gate, and "this project is not
107
+ // on a profile" is a legitimate failure for it rather than a prompt.
108
+ Object.defineProperty(process.stdin, "isTTY", {
109
+ value: true,
110
+ configurable: true,
111
+ });
112
+ expect(
113
+ await requireActiveProfile(project, MANIFEST, { check: true }),
114
+ ).toBeNull();
115
+ expect(await fs.pathExists(join(project, ".claude", "_profiles"))).toBe(
116
+ false,
117
+ );
118
+ });
119
+
120
+ test("passes straight through when a profile IS active", async () => {
121
+ await fs.outputJson(join(project, ".claude", "profiles.json"), MANIFEST);
122
+ await materializeAndActivate(MANIFEST, "lean", project, () => {});
123
+ noTTY();
124
+
125
+ // No prompt, no refusal: the one state where there is nothing to ask.
126
+ const carried = await requireActiveProfile(project, MANIFEST);
127
+
128
+ expect(carried).toEqual(MANIFEST);
129
+ expect(await activeProfile(project)).toBe("lean");
130
+ });
131
+
132
+ test("naming a profile skips the gate entirely", async () => {
133
+ // `magus update lean` says which profile to update, and updating it needs
134
+ // the manifest rather than the symlink — installs go to project scope
135
+ // either way. Gating it would refuse a question already answered on the
136
+ // command line, and would break `magus update lean` and `--check lean` in
137
+ // CI, both of which worked before the gate existed. Caught in release
138
+ // preflight, when the version bump had to be justified.
139
+ await fs.outputJson(join(project, ".claude", "profiles.json"), MANIFEST);
140
+ noTTY();
141
+
142
+ // The gate itself still refuses without an argument...
143
+ expect(await requireActiveProfile(project, MANIFEST)).toBeNull();
144
+
145
+ // ...and a named profile must not reach it. Exit code alone cannot show
146
+ // that — an unknown name exits 1 either way — so this asserts on WHICH
147
+ // error was printed. The gate's own wording must be absent, and the
148
+ // unknown-profile error (which lives past the gate) must be present.
149
+ const said: string[] = [];
150
+ const realError = console.error;
151
+ const realLog = console.log;
152
+ console.error = (...a: unknown[]) => said.push(a.join(" "));
153
+ console.log = () => {};
154
+ try {
155
+ expect(await runUpdateCommand(["no-such-profile"], project)).toBe(1);
156
+ } finally {
157
+ console.error = realError;
158
+ console.log = realLog;
159
+ }
160
+ const output = said.join("\n");
161
+
162
+ expect(output).toContain("no-such-profile");
163
+ expect(output).not.toContain("No profile selected");
164
+ });
165
+
166
+ test("--dry-run writes nothing and exits 0", async () => {
167
+ // `--dry-run` promises to write nothing and exit 0. An earlier version
168
+ // passed only `check: flags.check`, so a dry run reached `autoActivate`,
169
+ // wrote symlinks and appended to the TRACKED `.gitignore`. A preview that
170
+ // mutates the repo is not a preview. Refusing is the right outcome, and
171
+ // for a preview it is not a failure — hence 0, where `--check` gives 1.
172
+ await fs.outputJson(join(project, ".claude", "profiles.json"), {
173
+ ...MANIFEST,
174
+ default: "lean",
175
+ });
176
+ // No settings.json at all: the fresh-clone shape, where auto-activation
177
+ // would otherwise fire with nothing to displace.
178
+ await fs.remove(join(project, ".claude", "settings.json"));
179
+
180
+ const realLog = console.log;
181
+ const realError = console.error;
182
+ console.log = () => {};
183
+ console.error = () => {};
184
+ try {
185
+ expect(await runUpdateCommand(["--dry-run"], project)).toBe(0);
186
+ } finally {
187
+ console.log = realLog;
188
+ console.error = realError;
189
+ }
190
+
191
+ expect(await activeProfile(project)).toBeNull();
192
+ expect(await fs.pathExists(join(project, ".claude", "_profiles"))).toBe(
193
+ false,
194
+ );
195
+ expect(await fs.pathExists(join(project, ".gitignore"))).toBe(false);
196
+ });
197
+
198
+ test("an active profile passes even with unrelated plugins enabled", async () => {
199
+ // The gate must not consult enabledPlugins. A project running a profile is
200
+ // usable regardless of what its generated settings currently say.
201
+ await fs.outputJson(join(project, ".claude", "profiles.json"), MANIFEST);
202
+ await materializeAndActivate(MANIFEST, "lean", project, () => {});
203
+ await fs.outputJson(
204
+ join(project, ".claude", "_profiles", "lean", "settings.json"),
205
+ { enabledPlugins: { "something-else@magus": true } },
206
+ );
207
+ noTTY();
208
+
209
+ expect(await requireActiveProfile(project, MANIFEST)).not.toBeNull();
210
+ });
211
+ });
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Migration = adopt AND activate, without destroying what it replaces.
3
+ *
4
+ * REGRESSION: adopting a project wrote `.claude/profiles.json` and stopped, so
5
+ * `activeProfile()` still returned null and every later run asked which profile
6
+ * to use. The manifest changed; nothing the user could feel did. Fixed in
7
+ * /dev:fix session dev-fix-20260918-migration.
8
+ *
9
+ * The second half is the reason activation was left out in the first place:
10
+ * activating replaces four real paths with symlinks, and the replacement used
11
+ * `fs.remove` with no backup. A migration that loses a hand-written
12
+ * `.claude/settings.json` is worse than one that does nothing.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
16
+ import { lstat, mkdtemp, rm } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import fs from "fs-extra";
20
+ import { materializeAndActivate } from "../services/profile-activate.js";
21
+ import { adoptCurrentProject } from "../services/profile-adopt.js";
22
+ import {
23
+ activateProfile,
24
+ activeProfile,
25
+ backupRoot,
26
+ profileDir,
27
+ profilesRoot,
28
+ } from "../services/symlink-manager.js";
29
+ import type { ProfileManifest } from "../types/index.js";
30
+
31
+ /** Materialize a fake profile dir, as `magus install` would have. */
32
+ async function materialize(
33
+ project: string,
34
+ name: string,
35
+ settings: object,
36
+ ): Promise<void> {
37
+ const dir = profileDir(name, project);
38
+ await fs.ensureDir(dir);
39
+ await fs.writeJson(join(dir, "settings.json"), settings);
40
+ await fs.writeJson(join(dir, "mcp.json"), { mcpServers: {} });
41
+ }
42
+
43
+ describe("activation never deletes a real file", () => {
44
+ let project: string;
45
+ beforeEach(async () => {
46
+ project = await mkdtemp(join(tmpdir(), "migrate-"));
47
+ });
48
+ afterEach(async () => {
49
+ await rm(project, { recursive: true, force: true });
50
+ });
51
+
52
+ test("a hand-written settings.json is moved aside, not destroyed", async () => {
53
+ const settingsPath = join(project, ".claude", "settings.json");
54
+ await fs.outputJson(settingsPath, { hand: "written", keep: "me" });
55
+ await materialize(project, "team", { enabledPlugins: {} });
56
+
57
+ const backups = await activateProfile("team", project);
58
+
59
+ // The link is live...
60
+ expect((await lstat(settingsPath)).isSymbolicLink()).toBe(true);
61
+ // ...and the file it displaced is still readable somewhere.
62
+ const saved = backups.find((b) => b.link === settingsPath);
63
+ expect(saved).toBeDefined();
64
+ expect(await fs.readJson(saved?.savedTo ?? "")).toEqual({
65
+ hand: "written",
66
+ keep: "me",
67
+ });
68
+ });
69
+
70
+ test("switching between profiles reports no backups", async () => {
71
+ await materialize(project, "a", { enabledPlugins: {} });
72
+ await materialize(project, "b", { enabledPlugins: {} });
73
+
74
+ await activateProfile("a", project);
75
+ // Everything at the link paths is now a symlink magus itself wrote.
76
+ // Backing those up on every switch would fill the repo with copies of
77
+ // generated state that the manifest can regenerate at any time.
78
+ const second = await activateProfile("b", project);
79
+
80
+ expect(second).toEqual([]);
81
+ });
82
+
83
+ test("a symlink magus does not own is preserved too", async () => {
84
+ // Someone pointed .mcp.json at a shared config outside the project. It is
85
+ // not generated state, so it gets the same protection a real file gets.
86
+ const outside = join(project, "shared-mcp.json");
87
+ await fs.writeJson(outside, { mcpServers: { shared: {} } });
88
+ await fs.symlink(outside, join(project, ".mcp.json"));
89
+ await materialize(project, "team", { enabledPlugins: {} });
90
+
91
+ const backups = await activateProfile("team", project);
92
+
93
+ expect(backups.map((b) => b.link)).toContain(join(project, ".mcp.json"));
94
+ // The target was never touched — only the link moved.
95
+ expect(await fs.readJson(outside)).toEqual({ mcpServers: { shared: {} } });
96
+ });
97
+
98
+ test("a RELATIVE user symlink still resolves from the backup", async () => {
99
+ // A symlink's target resolves against the directory the link sits in, so
100
+ // moving the link into a backup two levels deeper silently repoints it.
101
+ // Preserving a link that no longer resolves preserves nothing.
102
+ await fs.writeJson(join(project, "shared-mcp.json"), { mcpServers: {} });
103
+ await fs.symlink("shared-mcp.json", join(project, ".mcp.json"));
104
+ await materialize(project, "team", { enabledPlugins: {} });
105
+
106
+ const backups = await activateProfile("team", project);
107
+ const saved = backups.find((b) => b.link === join(project, ".mcp.json"));
108
+
109
+ expect(saved).toBeDefined();
110
+ // readJson follows the link: if it were still relative this would throw.
111
+ expect(await fs.readJson(saved?.savedTo ?? "")).toEqual({ mcpServers: {} });
112
+ });
113
+
114
+ test("backups live OUTSIDE the regenerable _profiles/ tree", async () => {
115
+ // `_profiles/` is generated and documented as safe to delete. A backup kept
116
+ // there is the only copy of a hand-written file, stored in the one
117
+ // directory everybody is told they can remove.
118
+ await fs.outputJson(join(project, ".claude", "settings.json"), { a: 1 });
119
+ await materialize(project, "team", { enabledPlugins: {} });
120
+
121
+ const backups = await activateProfile("team", project);
122
+ const saved = backups[0]?.savedTo ?? "";
123
+
124
+ expect(saved.startsWith(profilesRoot(project))).toBe(false);
125
+ expect(saved.startsWith(backupRoot(project))).toBe(true);
126
+
127
+ await fs.remove(profilesRoot(project));
128
+ expect(await fs.readJson(saved)).toEqual({ a: 1 });
129
+ });
130
+
131
+ test("every displaced artifact is announced as it moves", async () => {
132
+ // The returned array is lost if a later link throws, and `magus install`
133
+ // and `profile switch` discard it outright. The callback is what makes the
134
+ // move visible at the moment it happens.
135
+ await fs.outputJson(join(project, ".claude", "settings.json"), { a: 1 });
136
+ await fs.writeJson(join(project, ".mcp.json"), { mcpServers: {} });
137
+ await materialize(project, "team", { enabledPlugins: {} });
138
+
139
+ const announced: string[] = [];
140
+ const returned = await activateProfile("team", project, (d) =>
141
+ announced.push(d.link),
142
+ );
143
+
144
+ expect(announced.sort()).toEqual(returned.map((d) => d.link).sort());
145
+ expect(announced).toHaveLength(2);
146
+ });
147
+ });
148
+
149
+ describe("migration leaves the project ON the profile it created", () => {
150
+ let project: string;
151
+ beforeEach(async () => {
152
+ project = await mkdtemp(join(tmpdir(), "migrate-"));
153
+ });
154
+ afterEach(async () => {
155
+ await rm(project, { recursive: true, force: true });
156
+ });
157
+
158
+ test("after adopting, activeProfile() names the adopted profile", async () => {
159
+ await fs.outputJson(join(project, ".claude", "settings.json"), {
160
+ enabledPlugins: { "dev@magus": true, "code-search@magus": true },
161
+ });
162
+
163
+ const result = await adoptCurrentProject(
164
+ project,
165
+ undefined,
166
+ undefined,
167
+ "default",
168
+ );
169
+ await materializeAndActivate(result.manifest, "default", project);
170
+
171
+ // The whole point: the question "which profile?" now has an answer.
172
+ expect(await activeProfile(project)).toBe("default");
173
+ });
174
+
175
+ test("the adopted profile enables exactly what the project had", async () => {
176
+ await fs.outputJson(join(project, ".claude", "settings.json"), {
177
+ enabledPlugins: { "dev@magus": true, "code-search@magus": true },
178
+ someOtherSetting: 42,
179
+ });
180
+
181
+ const result = await adoptCurrentProject(
182
+ project,
183
+ undefined,
184
+ undefined,
185
+ "default",
186
+ );
187
+ await materializeAndActivate(result.manifest, "default", project);
188
+
189
+ // Read through the live symlink — this is what Claude Code will load.
190
+ const live = await fs.readJson(join(project, ".claude", "settings.json"));
191
+ expect(live.enabledPlugins).toEqual({
192
+ "dev@magus": true,
193
+ "code-search@magus": true,
194
+ });
195
+ expect(live.someOtherSetting).toBe(42);
196
+ });
197
+
198
+ test("adopting into a manifest that already has profiles keeps them excluded", async () => {
199
+ await fs.outputJson(join(project, ".claude", "settings.json"), {
200
+ enabledPlugins: { "dev@magus": true },
201
+ });
202
+ const existing: ProfileManifest = {
203
+ version: 2,
204
+ profiles: {
205
+ lean: { name: "Lean", plugins: { "terminal@magus": "latest" } },
206
+ },
207
+ };
208
+
209
+ const result = await adoptCurrentProject(
210
+ project,
211
+ undefined,
212
+ existing,
213
+ "default",
214
+ );
215
+ await materializeAndActivate(result.manifest, "default", project);
216
+
217
+ const live = await fs.readJson(join(project, ".claude", "settings.json"));
218
+ // `terminal@magus` belongs to another profile, so this one must say so
219
+ // explicitly — an omitted id falls through to user scope and stays on.
220
+ expect(live.enabledPlugins["terminal@magus"]).toBe(false);
221
+ expect(live.enabledPlugins["dev@magus"]).toBe(true);
222
+ });
223
+
224
+ test("the generated dir and the symlinks are gitignored", async () => {
225
+ await fs.outputJson(join(project, ".claude", "settings.json"), {
226
+ enabledPlugins: { "dev@magus": true },
227
+ });
228
+ const result = await adoptCurrentProject(
229
+ project,
230
+ undefined,
231
+ undefined,
232
+ "default",
233
+ );
234
+ await materializeAndActivate(result.manifest, "default", project);
235
+
236
+ const gitignore = await fs.readFile(join(project, ".gitignore"), "utf8");
237
+ // `.claude/` is committed, so a symlink into generated state that lands in
238
+ // git breaks every teammate who does not have that profile materialized.
239
+ expect(gitignore).toContain(".claude/_profiles/");
240
+ expect(gitignore).toContain(".claude/settings.json");
241
+ });
242
+ });