magus-cli 7.4.1 → 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.
Files changed (53) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -4
  3. package/src/__tests__/install-command.test.ts +4 -3
  4. package/src/__tests__/models-cli.test.ts +4 -7
  5. package/src/__tests__/models-manager.test.ts +52 -40
  6. package/src/__tests__/profile-absorb.test.ts +1138 -0
  7. package/src/__tests__/profile-command.test.ts +90 -32
  8. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  9. package/src/__tests__/profile-migration.test.ts +246 -117
  10. package/src/__tests__/profile-render.test.ts +141 -0
  11. package/src/__tests__/profile-skills.test.ts +425 -0
  12. package/src/__tests__/profile-startup.test.ts +44 -14
  13. package/src/__tests__/profile-state.test.ts +795 -0
  14. package/src/__tests__/settings-file.test.ts +5 -7
  15. package/src/__tests__/styles-manager.test.ts +47 -74
  16. package/src/__tests__/styles-screen-state.test.ts +11 -10
  17. package/src/cli/bootstrap.ts +87 -27
  18. package/src/cli/doctor.ts +132 -38
  19. package/src/cli/install.ts +58 -118
  20. package/src/cli/models.ts +21 -1
  21. package/src/cli/profile.ts +48 -77
  22. package/src/cli/router.ts +32 -6
  23. package/src/cli/update.ts +43 -6
  24. package/src/data/gitignore-defaults.ts +3 -3
  25. package/src/services/active-closure.ts +3 -3
  26. package/src/services/conventions-manager.ts +88 -0
  27. package/src/services/git-worktree.ts +21 -0
  28. package/src/services/manifest.ts +4 -5
  29. package/src/services/models-manager.ts +36 -73
  30. package/src/services/profile-absorb.ts +1532 -0
  31. package/src/services/profile-activate.ts +348 -96
  32. package/src/services/profile-live.ts +825 -0
  33. package/src/services/profile-render.ts +115 -0
  34. package/src/services/profile-skills.ts +462 -0
  35. package/src/services/profile-startup.ts +88 -16
  36. package/src/services/profile-state.ts +622 -0
  37. package/src/services/profiles.ts +20 -32
  38. package/src/services/resolver.ts +6 -1
  39. package/src/services/settings-file.ts +5 -5
  40. package/src/services/skills-manager.ts +14 -8
  41. package/src/services/styles-manager.ts +45 -28
  42. package/src/types/index.ts +15 -9
  43. package/src/ui/App.tsx +25 -2
  44. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  45. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  46. package/src/ui/screens/StylesScreen.tsx +2 -1
  47. package/src/ui/state/types.ts +1 -1
  48. package/src/__tests__/profile-materializer.test.ts +0 -238
  49. package/src/__tests__/profile-sync.test.ts +0 -154
  50. package/src/__tests__/symlink-manager.test.ts +0 -180
  51. package/src/services/profile-materializer.ts +0 -133
  52. package/src/services/profile-sync.ts +0 -158
  53. package/src/services/symlink-manager.ts +0 -343
@@ -1,238 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import fs from "fs-extra";
6
- import type { ModelsConfig } from "../services/models-core.js";
7
- import {
8
- buildProfileMcp,
9
- buildProfileSettings,
10
- materializeProfile,
11
- } from "../services/profile-materializer.js";
12
- import { profileDir } from "../services/symlink-manager.js";
13
- import type { ResolvedClosure } from "../types/index.js";
14
-
15
- const ROUTING: ModelsConfig = {
16
- version: 1,
17
- preset: "test",
18
- main: { model: "opus", effort: "medium" },
19
- grades: {
20
- smart: { model: "fable", effort: "xhigh" },
21
- normal: { model: "opus", effort: "medium" },
22
- cheap: { model: "sonnet", effort: "low" },
23
- },
24
- agents: { "dev:architect": "smart" },
25
- fallback: "normal",
26
- };
27
-
28
- function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
29
- return {
30
- marketplaces: {},
31
- plugins: {},
32
- mcpServers: {},
33
- bins: [],
34
- skills: [],
35
- settings: {},
36
- env: { required: [], optional: [] },
37
- ...overrides,
38
- };
39
- }
40
-
41
- describe("buildProfileSettings", () => {
42
- test("enables every resolved plugin and merges declared settings", () => {
43
- const s = buildProfileSettings(
44
- closure({
45
- plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
46
- settings: { enableAllProjectMcpServers: true },
47
- }),
48
- );
49
- expect(s.enabledPlugins).toEqual({
50
- "dev@magus": true,
51
- "terminal@magus": true,
52
- });
53
- expect(s.enableAllProjectMcpServers).toBe(true);
54
- });
55
-
56
- // Regression: `install` enables the manifest-wide union at USER scope, and
57
- // Claude Code resolves enabledPlugins per plugin id — an id the project
58
- // scope omits falls through to the user scope's `true`. A profile that
59
- // merely omits another profile's plugins therefore never disables them, so
60
- // `profile switch` silently becomes additive instead of exclusive.
61
- test("explicitly disables union plugins the profile excludes", () => {
62
- const s = buildProfileSettings(
63
- closure({ plugins: { "designer@magus": "latest" } }),
64
- ["designer@magus", "terminal@magus", "madbench@magus"],
65
- );
66
- expect(s.enabledPlugins).toEqual({
67
- "designer@magus": true,
68
- "terminal@magus": false,
69
- "madbench@magus": false,
70
- });
71
- });
72
-
73
- test("a profile's own plugin wins over its union entry", () => {
74
- const s = buildProfileSettings(
75
- closure({ plugins: { "terminal@magus": "4.1.4" } }),
76
- ["terminal@magus"],
77
- );
78
- expect(s.enabledPlugins).toEqual({ "terminal@magus": true });
79
- });
80
-
81
- test("no union given -> no false entries (unchanged legacy shape)", () => {
82
- const s = buildProfileSettings(
83
- closure({ plugins: { "dev@magus": "latest" } }),
84
- );
85
- expect(s.enabledPlugins).toEqual({ "dev@magus": true });
86
- });
87
- });
88
-
89
- describe("buildProfileMcp", () => {
90
- test("wraps the closure's mcpServers", () => {
91
- const m = buildProfileMcp(
92
- closure({ mcpServers: { "chrome-devtools": { command: "npx" } } }),
93
- );
94
- expect(m.mcpServers["chrome-devtools"]).toEqual({ command: "npx" });
95
- });
96
- });
97
-
98
- describe("materializeProfile", () => {
99
- let project: string;
100
- beforeEach(async () => {
101
- project = await mkdtemp(join(tmpdir(), "materialize-"));
102
- });
103
- afterEach(async () => {
104
- await rm(project, { recursive: true, force: true });
105
- });
106
-
107
- test("writes settings.json, mcp.json, and an empty skills/ dir", async () => {
108
- const dir = await materializeProfile(
109
- "frontend",
110
- closure({
111
- plugins: { "dev@magus": "latest" },
112
- mcpServers: { x: { command: "y" } },
113
- }),
114
- project,
115
- );
116
- expect(dir).toBe(profileDir("frontend", project));
117
- expect(
118
- (await fs.readJson(join(dir, "settings.json"))).enabledPlugins[
119
- "dev@magus"
120
- ],
121
- ).toBe(true);
122
- expect(
123
- (await fs.readJson(join(dir, "mcp.json"))).mcpServers.x.command,
124
- ).toBe("y");
125
- expect(await fs.pathExists(join(dir, "skills"))).toBe(true);
126
- });
127
-
128
- test("is idempotent (re-materialize overwrites cleanly)", async () => {
129
- await materializeProfile(
130
- "p",
131
- closure({ plugins: { "a@m": "latest" } }),
132
- project,
133
- );
134
- const dir = await materializeProfile(
135
- "p",
136
- closure({ plugins: { "b@m": "latest" } }),
137
- project,
138
- );
139
- const settings = await fs.readJson(join(dir, "settings.json"));
140
- expect(settings.enabledPlugins).toEqual({ "b@m": true });
141
- });
142
-
143
- // Regression: activating a profile replaces .claude/skills with a symlink,
144
- // and replacing means fs.remove first. A repo that committed its own project
145
- // skills before adopting profiles would lose them at that moment.
146
- test("seeds skills/ from the project's pre-existing .claude/skills", async () => {
147
- const src = join(project, ".claude", "skills", "systematic-debugging");
148
- await fs.outputFile(join(src, "SKILL.md"), "# committed project skill\n");
149
-
150
- const dir = await materializeProfile("p", closure(), project);
151
-
152
- expect(
153
- await fs.readFile(
154
- join(dir, "skills", "systematic-debugging", "SKILL.md"),
155
- "utf8",
156
- ),
157
- ).toContain("committed project skill");
158
- });
159
-
160
- test("does not re-seed skills/ once the profile dir exists", async () => {
161
- await fs.outputFile(
162
- join(project, ".claude", "skills", "gone", "SKILL.md"),
163
- "# removed later\n",
164
- );
165
- const dir = await materializeProfile("p", closure(), project);
166
- await fs.remove(join(dir, "skills", "gone"));
167
-
168
- await materializeProfile("p", closure(), project);
169
-
170
- expect(await fs.pathExists(join(dir, "skills", "gone"))).toBe(false);
171
- });
172
-
173
- test("still creates an empty skills/ when the project has none", async () => {
174
- const dir = await materializeProfile("p", closure(), project);
175
- expect(await fs.pathExists(join(dir, "skills"))).toBe(true);
176
- expect(await fs.readdir(join(dir, "skills"))).toEqual([]);
177
- });
178
-
179
- test("writes models.json when the closure carries routing", async () => {
180
- const dir = await materializeProfile(
181
- "p",
182
- closure({ models: ROUTING }),
183
- project,
184
- );
185
- expect(await fs.readJson(join(dir, "models.json"))).toEqual(ROUTING);
186
- });
187
-
188
- // The one artifact materialization DELETES. Every other file here is
189
- // unconditional, so overwriting is enough; routing is optional, and a
190
- // profile that dropped it would otherwise keep routing from a file nothing
191
- // writes any more — `magus models off` could not turn routing off,
192
- // because the hook walks up to the nearest models.json and finds this one.
193
- test("deletes a stale models.json when the closure has no routing", async () => {
194
- const dir = await materializeProfile(
195
- "p",
196
- closure({ models: ROUTING }),
197
- project,
198
- );
199
- expect(await fs.pathExists(join(dir, "models.json"))).toBe(true);
200
-
201
- await materializeProfile("p", closure(), project);
202
-
203
- expect(await fs.pathExists(join(dir, "models.json"))).toBe(false);
204
- });
205
-
206
- test("a profile that never had routing gets no models.json", async () => {
207
- const dir = await materializeProfile("p", closure(), project);
208
- expect(await fs.pathExists(join(dir, "models.json"))).toBe(false);
209
- });
210
-
211
- test("two profiles from one union each disable the other's plugins", async () => {
212
- const union = ["designer@magus", "terminal@magus"];
213
- const fe = await materializeProfile(
214
- "frontend",
215
- closure({ plugins: { "designer@magus": "latest" } }),
216
- project,
217
- union,
218
- );
219
- const be = await materializeProfile(
220
- "backend",
221
- closure({ plugins: { "terminal@magus": "latest" } }),
222
- project,
223
- union,
224
- );
225
- expect(
226
- (await fs.readJson(join(fe, "settings.json"))).enabledPlugins,
227
- ).toEqual({
228
- "designer@magus": true,
229
- "terminal@magus": false,
230
- });
231
- expect(
232
- (await fs.readJson(join(be, "settings.json"))).enabledPlugins,
233
- ).toEqual({
234
- "designer@magus": false,
235
- "terminal@magus": true,
236
- });
237
- });
238
- });
@@ -1,154 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import fs from "fs-extra";
6
- import { computeSync, syncProfile } from "../services/profile-sync.js";
7
- import { profileDir } from "../services/symlink-manager.js";
8
- import type { ProfileManifestEntry } from "../types/index.js";
9
-
10
- describe("computeSync (pure)", () => {
11
- const entry: ProfileManifestEntry = {
12
- name: "Frontend",
13
- plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
14
- settings: { effortLevel: "high" },
15
- };
16
-
17
- test("adds a newly-enabled plugin, preserving existing pins", () => {
18
- const { entry: next, changes } = computeSync(
19
- entry,
20
- {
21
- enabledPlugins: {
22
- "dev@magus": true,
23
- "terminal@magus": true,
24
- "designer@magus": true,
25
- },
26
- effortLevel: "high",
27
- },
28
- { mcpServers: {} },
29
- );
30
- // existing pin preserved (not downgraded to latest)
31
- expect(next.plugins!["dev@magus"]).toBe("2.9.0");
32
- // new plugin added as latest
33
- expect(next.plugins!["designer@magus"]).toBe("latest");
34
- expect(changes).toContainEqual({
35
- kind: "plugin-added",
36
- detail: "designer@magus",
37
- });
38
- });
39
-
40
- test("removes a plugin that was disabled", () => {
41
- const { entry: next, changes } = computeSync(
42
- entry,
43
- { enabledPlugins: { "dev@magus": true }, effortLevel: "high" },
44
- { mcpServers: {} },
45
- );
46
- expect(next.plugins!["terminal@magus"]).toBeUndefined();
47
- expect(changes).toContainEqual({
48
- kind: "plugin-removed",
49
- detail: "terminal@magus",
50
- });
51
- });
52
-
53
- test("captures a settings change and an mcp change", () => {
54
- const { entry: next, changes } = computeSync(
55
- entry,
56
- {
57
- enabledPlugins: { "dev@magus": true, "terminal@magus": true },
58
- effortLevel: "max",
59
- },
60
- { mcpServers: { "chrome-devtools": { command: "npx" } } },
61
- );
62
- expect(next.settings!.effortLevel).toBe("max");
63
- expect(next.mcpServers!["chrome-devtools"]).toEqual({ command: "npx" });
64
- expect(changes.some((c) => c.kind === "settings-changed")).toBe(true);
65
- expect(changes.some((c) => c.kind === "mcp-changed")).toBe(true);
66
- });
67
-
68
- test("no changes when the materialized state matches the manifest", () => {
69
- const { changes } = computeSync(
70
- entry,
71
- {
72
- enabledPlugins: { "dev@magus": true, "terminal@magus": true },
73
- effortLevel: "high",
74
- },
75
- { mcpServers: {} },
76
- );
77
- expect(changes).toEqual([]);
78
- });
79
-
80
- test("refreshes updatedAt to the injected timestamp when changed", () => {
81
- const withStamp: ProfileManifestEntry = { ...entry, updatedAt: "OLD" };
82
- const { entry: changed } = computeSync(
83
- withStamp,
84
- {
85
- enabledPlugins: {
86
- "dev@magus": true,
87
- "terminal@magus": true,
88
- "new@magus": true,
89
- },
90
- effortLevel: "high",
91
- },
92
- { mcpServers: {} },
93
- "2026-07-27T00:00:00.000Z",
94
- );
95
- expect(changed.updatedAt).toBe("2026-07-27T00:00:00.000Z");
96
-
97
- // Unchanged sync leaves updatedAt alone.
98
- const { entry: same } = computeSync(
99
- withStamp,
100
- {
101
- enabledPlugins: { "dev@magus": true, "terminal@magus": true },
102
- effortLevel: "high",
103
- },
104
- { mcpServers: {} },
105
- "2026-07-27T00:00:00.000Z",
106
- );
107
- expect(same.updatedAt).toBe("OLD");
108
- });
109
- });
110
-
111
- describe("syncProfile (io)", () => {
112
- let dir: string;
113
- beforeEach(async () => {
114
- dir = await mkdtemp(join(tmpdir(), "sync-"));
115
- await fs.ensureDir(join(dir, ".claude"));
116
- await fs.writeJson(join(dir, ".claude", "profiles.json"), {
117
- version: 2,
118
- profiles: {
119
- frontend: { name: "Frontend", plugins: { "dev@magus": "2.9.0" } },
120
- },
121
- });
122
- });
123
- afterEach(async () => {
124
- await rm(dir, { recursive: true, force: true });
125
- });
126
-
127
- test("promotes an edit in the materialized dir into the manifest file", async () => {
128
- // A teammate enabled a new plugin directly in the active profile's settings.
129
- const pd = profileDir("frontend", dir);
130
- await fs.ensureDir(pd);
131
- await fs.writeJson(join(pd, "settings.json"), {
132
- enabledPlugins: { "dev@magus": true, "designer@magus": true },
133
- });
134
- await fs.writeJson(join(pd, "mcp.json"), { mcpServers: {} });
135
-
136
- const { changes, wrote } = await syncProfile("frontend", dir);
137
- expect(wrote).toBe(true);
138
- expect(changes.some((c) => c.detail === "designer@magus")).toBe(true);
139
-
140
- const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
141
- expect(manifest.profiles.frontend.plugins["designer@magus"]).toBe("latest");
142
- expect(manifest.profiles.frontend.plugins["dev@magus"]).toBe("2.9.0"); // pin kept
143
- });
144
-
145
- test("no-op when nothing drifted", async () => {
146
- const pd = profileDir("frontend", dir);
147
- await fs.ensureDir(pd);
148
- await fs.writeJson(join(pd, "settings.json"), {
149
- enabledPlugins: { "dev@magus": true },
150
- });
151
- const { wrote } = await syncProfile("frontend", dir);
152
- expect(wrote).toBe(false);
153
- });
154
- });
@@ -1,180 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { lstat, mkdtemp, readlink, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import fs from "fs-extra";
6
- import {
7
- activateProfile,
8
- activeProfile,
9
- profileDir,
10
- } from "../services/symlink-manager.js";
11
-
12
- /** Materialize a fake profile dir with the given files. */
13
- async function materialize(
14
- project: string,
15
- name: string,
16
- files: {
17
- settings?: object;
18
- mcp?: object;
19
- skills?: boolean;
20
- models?: object;
21
- },
22
- ) {
23
- const dir = profileDir(name, project);
24
- await fs.ensureDir(dir);
25
- if (files.settings)
26
- await fs.writeJson(join(dir, "settings.json"), files.settings);
27
- if (files.mcp) await fs.writeJson(join(dir, "mcp.json"), files.mcp);
28
- if (files.skills) await fs.ensureDir(join(dir, "skills"));
29
- if (files.models) await fs.writeJson(join(dir, "models.json"), files.models);
30
- }
31
-
32
- describe("symlink-manager", () => {
33
- let project: string;
34
- beforeEach(async () => {
35
- project = await mkdtemp(join(tmpdir(), "symlink-"));
36
- });
37
- afterEach(async () => {
38
- await rm(project, { recursive: true, force: true });
39
- });
40
-
41
- test("activateProfile links settings.json, .mcp.json, and skills/", async () => {
42
- await materialize(project, "frontend", {
43
- settings: { enabledPlugins: { "dev@magus": true } },
44
- mcp: { mcpServers: {} },
45
- skills: true,
46
- });
47
- await activateProfile("frontend", project);
48
-
49
- // settings.json is a symlink into the profile dir
50
- expect(
51
- (await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
52
- ).toBe(true);
53
- expect((await lstat(join(project, ".mcp.json"))).isSymbolicLink()).toBe(
54
- true,
55
- );
56
- expect(
57
- (await lstat(join(project, ".claude", "skills"))).isSymbolicLink(),
58
- ).toBe(true);
59
-
60
- // reading through the link returns the profile's content
61
- const settings = await fs.readJson(
62
- join(project, ".claude", "settings.json"),
63
- );
64
- expect(settings.enabledPlugins["dev@magus"]).toBe(true);
65
- });
66
-
67
- test("writeJson through the settings symlink lands in the profile dir", async () => {
68
- await materialize(project, "frontend", { settings: { a: 1 } });
69
- await activateProfile("frontend", project);
70
- await fs.writeJson(join(project, ".claude", "settings.json"), { a: 2 });
71
- // The real file inside the profile dir was updated (symlink preserved).
72
- expect(
73
- (await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
74
- ).toBe(true);
75
- expect(
76
- (await fs.readJson(`${profileDir("frontend", project)}/settings.json`)).a,
77
- ).toBe(2);
78
- });
79
-
80
- test("switching repoints links and activeProfile tracks it", async () => {
81
- await materialize(project, "frontend", { settings: { which: "frontend" } });
82
- await materialize(project, "backend", { settings: { which: "backend" } });
83
-
84
- await activateProfile("frontend", project);
85
- expect(await activeProfile(project)).toBe("frontend");
86
- expect(
87
- (await fs.readJson(join(project, ".claude", "settings.json"))).which,
88
- ).toBe("frontend");
89
-
90
- await activateProfile("backend", project);
91
- expect(await activeProfile(project)).toBe("backend");
92
- expect(
93
- (await fs.readJson(join(project, ".claude", "settings.json"))).which,
94
- ).toBe("backend");
95
- });
96
-
97
- test("uses relative (not absolute) symlinks", async () => {
98
- await materialize(project, "frontend", { settings: { a: 1 } });
99
- await activateProfile("frontend", project);
100
- const target = await readlink(join(project, ".claude", "settings.json"));
101
- expect(target.startsWith("/")).toBe(false);
102
- expect(target).toContain("_profiles");
103
- });
104
-
105
- test("a profile without skills/ creates no skills link", async () => {
106
- await materialize(project, "frontend", { settings: { a: 1 } });
107
- await activateProfile("frontend", project);
108
- expect(await fs.pathExists(join(project, ".claude", "skills"))).toBe(false);
109
- });
110
-
111
- test("activateProfile throws if the profile isn't materialized", async () => {
112
- await expect(activateProfile("ghost", project)).rejects.toThrow(
113
- "not materialized",
114
- );
115
- });
116
-
117
- test("links models.json when the profile has routing", async () => {
118
- await materialize(project, "frontend", {
119
- settings: { a: 1 },
120
- models: { version: 1, preset: "fable-advisor" },
121
- });
122
- await activateProfile("frontend", project);
123
-
124
- const link = join(project, ".claude", "models.json");
125
- expect((await lstat(link)).isSymbolicLink()).toBe(true);
126
- expect((await fs.readJson(link)).preset).toBe("fable-advisor");
127
- });
128
-
129
- // A dangling .claude/models.json is a repo-wide hazard: `.claude/` is
130
- // committed, so it would ship to every teammate and point at a directory
131
- // that only exists on the machine that made it.
132
- test("a profile without routing gets no models link at all", async () => {
133
- await materialize(project, "frontend", { settings: { a: 1 } });
134
- await activateProfile("frontend", project);
135
-
136
- const link = join(project, ".claude", "models.json");
137
- expect(await fs.pathExists(link)).toBe(false);
138
- await expect(lstat(link)).rejects.toThrow();
139
- });
140
-
141
- test("switching to a profile without routing removes the previous link", async () => {
142
- await materialize(project, "routed", {
143
- settings: { which: "routed" },
144
- models: { version: 1, preset: "fable-advisor" },
145
- });
146
- await materialize(project, "plain", { settings: { which: "plain" } });
147
-
148
- await activateProfile("routed", project);
149
- expect(
150
- (await lstat(join(project, ".claude", "models.json"))).isSymbolicLink(),
151
- ).toBe(true);
152
-
153
- await activateProfile("plain", project);
154
- await expect(
155
- lstat(join(project, ".claude", "models.json")),
156
- ).rejects.toThrow();
157
- });
158
-
159
- // Removal is scoped to links INTO _profiles/. A real models.json a project
160
- // wrote by hand is not magus's to delete.
161
- test("a real models.json file is never clobbered by activation", async () => {
162
- await materialize(project, "plain", { settings: { a: 1 } });
163
- await fs.outputJson(join(project, ".claude", "models.json"), {
164
- version: 1,
165
- preset: "hand-written",
166
- });
167
-
168
- await activateProfile("plain", project);
169
-
170
- expect(
171
- (await fs.readJson(join(project, ".claude", "models.json"))).preset,
172
- ).toBe("hand-written");
173
- });
174
-
175
- test("activeProfile returns null when settings.json is a real file", async () => {
176
- await fs.ensureDir(join(project, ".claude"));
177
- await fs.writeJson(join(project, ".claude", "settings.json"), { a: 1 });
178
- expect(await activeProfile(project)).toBeNull();
179
- });
180
- });