magus-cli 7.0.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.
Files changed (48) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +58 -17
  3. package/src/__tests__/blackbox-plugin-state.test.ts +41 -19
  4. package/src/__tests__/cli-update-view.test.ts +100 -21
  5. package/src/__tests__/local-marketplace-scan.test.ts +74 -0
  6. package/src/__tests__/marketplace-refresh.test.ts +12 -7
  7. package/src/__tests__/plugin-row-text.test.ts +33 -24
  8. package/src/__tests__/profile-migration-offer.test.ts +211 -0
  9. package/src/__tests__/profile-migration.test.ts +242 -0
  10. package/src/__tests__/profile-startup.test.ts +458 -0
  11. package/src/__tests__/theme-env.test.ts +29 -0
  12. package/src/__tests__/update-plan.test.ts +57 -34
  13. package/src/__tests__/version-unknown-state.test.ts +29 -14
  14. package/src/cli/ansi.ts +58 -11
  15. package/src/cli/bootstrap.ts +169 -3
  16. package/src/cli/install.ts +5 -1
  17. package/src/cli/profile.ts +5 -1
  18. package/src/cli/prompt.ts +44 -12
  19. package/src/cli/router.ts +59 -2
  20. package/src/cli/tui-prompt.tsx +143 -0
  21. package/src/cli/update-view.ts +237 -45
  22. package/src/cli/update.ts +59 -29
  23. package/src/main.tsx +11 -2
  24. package/src/services/claude-settings.ts +15 -40
  25. package/src/services/local-marketplace.ts +76 -6
  26. package/src/services/manifest.ts +21 -0
  27. package/src/services/marketplace-refresh.ts +7 -15
  28. package/src/services/plugin-manager.ts +0 -31
  29. package/src/services/profile-activate.ts +147 -0
  30. package/src/services/profile-adopt.ts +50 -4
  31. package/src/services/profile-startup.ts +311 -0
  32. package/src/services/profiles.ts +49 -11
  33. package/src/services/resolver.ts +33 -0
  34. package/src/services/symlink-manager.ts +184 -30
  35. package/src/services/update-plan.ts +23 -66
  36. package/src/types/index.ts +44 -23
  37. package/src/ui/App.tsx +60 -3
  38. package/src/ui/components/layout/FooterHints.tsx +21 -9
  39. package/src/ui/components/modals/ModalContainer.tsx +1 -0
  40. package/src/ui/components/modals/SelectModal.tsx +142 -21
  41. package/src/ui/hooks/useProfileStartupModal.ts +260 -0
  42. package/src/ui/renderers/profileRenderers.tsx +27 -4
  43. package/src/ui/screens/PluginsScreen.tsx +9 -50
  44. package/src/ui/screens/ProfilesScreen.tsx +1 -1
  45. package/src/ui/state/types.ts +18 -0
  46. package/src/ui/theme-mode.ts +142 -0
  47. package/src/utils/command-utils.ts +19 -14
  48. package/src/__tests__/moved-marketplace.test.ts +0 -86
@@ -108,48 +108,58 @@ function cliNote(
108
108
  }
109
109
 
110
110
  describe("pluginRowText — a status never depends on a displayable version", () => {
111
- test("a sha-versioned unknown carries the CLI's reason verbatim", () => {
112
- // The old row said "? not comparable" here; the CLI said the sentence
113
- // below. Same state, different reason — the R2 violation.
114
- const row = rows([CODE_REVIEW]).get(CODE_REVIEW.id);
115
- const note = cliNote([CODE_REVIEW], CODE_REVIEW.id);
116
- expect(note).toBe(
117
- "its marketplace publishes no version for this plugin, so there is nothing to compare",
118
- );
119
- const { text } = pluginRowText(row as PluginPluginItem);
120
- expect(text).toBe(` v3ea32df27be7 ? ${note}`);
111
+ // A sha install against a catalog that publishes no version is `current`
112
+ // now — we assume what is installed is the latest rather than warning about
113
+ // a comparison that can never be made. R2 still holds and is still the point
114
+ // of this file: whatever the CLI says, the row says, word for word.
115
+ test("a sha-versioned install says exactly what the CLI says", () => {
116
+ const row = rows([CODE_REVIEW]).get(CODE_REVIEW.id) as PluginPluginItem;
117
+ expect(row.status?.action).toBe("current");
118
+ // No note means no suffix. The row is the version and nothing else.
119
+ expect(cliNote([CODE_REVIEW], CODE_REVIEW.id)).toBeUndefined();
120
+ const { text } = pluginRowText(row);
121
+ expect(text).toBe(" v3ea32df27be7");
122
+ // The row never invents a vocabulary the CLI does not use.
121
123
  expect(text).not.toContain("not comparable");
124
+ expect(text).not.toContain("?");
122
125
  });
123
126
 
124
- test('REGRESSION: a "0.0.0" unknown is no longer blank', () => {
125
- // Every old status clause was gated on a displayable version, so this row
126
- // printed nothing after its name and read as healthy.
127
+ test('a "0.0.0" install is never printed as a version', () => {
128
+ // "0.0.0" means installed with an unknown version — Anthropic's official
129
+ // plugins record exactly that. It is not a version anyone can act on, so
130
+ // it is never shown, and no status clause may be gated on showing it.
127
131
  const row = rows([ZERO]).get(ZERO.id) as PluginPluginItem;
128
- expect(row.status?.action).toBe("unknown");
129
- const { text, segments } = pluginRowText(row);
130
- expect(text).toBe(` ? ${cliNote([ZERO], ZERO.id)}`);
131
- // The version itself is still never printed.
132
+ expect(row.status?.action).toBe("current");
133
+ const { text } = pluginRowText(row);
132
134
  expect(text).not.toContain("0.0.0");
133
- expect(segments.at(-1)?.tone).toBe("warning");
135
+ expect(text).toBe(cliNote([ZERO], ZERO.id) ?? "");
134
136
  });
135
137
  });
136
138
 
137
139
  describe("pluginRowText — orphans say what the CLI says (F9)", () => {
138
- test("an orphan that moved names where it went", () => {
140
+ // The row NEVER guesses where an orphan went. That guess was a bare name
141
+ // match, and it cannot tell a move from a dual publication: claudish ships
142
+ // to two marketplaces with both copies installed, and the row announced
143
+ // "moved → magus" about a plugin that had not moved anywhere.
144
+ test("an orphan is 'not offered', and never names a destination", () => {
139
145
  const seo = info({
140
146
  id: "seo@magus",
141
147
  version: "1.8.0",
142
148
  installedVersion: "1.8.0",
143
149
  isOrphaned: true,
144
- movedTo: "magus-marketing",
145
150
  projectScope: { enabled: true, version: "1.8.0" },
146
151
  });
147
152
  const row = rows([seo]).get(seo.id) as PluginPluginItem;
148
- expect(cliNote([seo], seo.id)).toBe("moved to magus-marketing");
149
- expect(pluginRowText(row).text).toBe(" v1.8.0 ? moved to magus-marketing");
153
+ expect(cliNote([seo], seo.id)).toBe(
154
+ "not offered by any configured marketplace",
155
+ );
156
+ expect(pluginRowText(row).text).toBe(
157
+ " v1.8.0 ? not offered by any configured marketplace",
158
+ );
159
+ expect(pluginRowText(row).text).not.toMatch(/moved/i);
150
160
  });
151
161
 
152
- test("an orphan with no movedTo, from a marketplace we read, is 'not offered'", () => {
162
+ test("an orphan from a marketplace we DID read is 'not offered'", () => {
153
163
  const gone = info({
154
164
  id: "gone@magus",
155
165
  version: "2.0.0",
@@ -171,7 +181,6 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
171
181
  id: "seo@magus",
172
182
  installedVersion: "1.8.0",
173
183
  isOrphaned: true,
174
- movedTo: "magus-marketing",
175
184
  projectScope: { enabled: true, version: "1.8.0" },
176
185
  });
177
186
  const { text } = pluginRowText(rows([seo]).get(seo.id) as PluginPluginItem);
@@ -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
+ });