magus-cli 7.5.2 → 7.5.4

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 (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/gitignore-check.test.ts +11 -29
  12. package/src/__tests__/gitignore-profile.test.ts +248 -0
  13. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  14. package/src/__tests__/mate-availability.test.ts +11 -13
  15. package/src/__tests__/models-cli.test.ts +11 -0
  16. package/src/__tests__/plugin-contents.test.ts +0 -1
  17. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  18. package/src/__tests__/plugin-row-text.test.ts +18 -21
  19. package/src/__tests__/profile-command.test.ts +2 -2
  20. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  21. package/src/__tests__/profile-skills.test.ts +17 -22
  22. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  23. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  24. package/src/__tests__/profile-state.test.ts +152 -164
  25. package/src/__tests__/scope-action.test.ts +35 -38
  26. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  27. package/src/__tests__/select-restore.test.ts +209 -0
  28. package/src/__tests__/stale-load-repair.test.ts +1 -2
  29. package/src/__tests__/update-apply.test.ts +18 -2
  30. package/src/__tests__/update-null-readback.test.ts +300 -0
  31. package/src/__tests__/update-plan-parity.test.ts +249 -0
  32. package/src/__tests__/update-plan.test.ts +66 -67
  33. package/src/__tests__/version-unknown-state.test.ts +38 -39
  34. package/src/cli/bootstrap.ts +239 -70
  35. package/src/cli/doctor.ts +7 -20
  36. package/src/cli/install.ts +83 -27
  37. package/src/cli/prompt.ts +7 -3
  38. package/src/cli/router.ts +6 -4
  39. package/src/cli/select.ts +168 -0
  40. package/src/cli/update-view.ts +118 -6
  41. package/src/cli/update.ts +10 -1
  42. package/src/services/claude-cli.ts +0 -24
  43. package/src/services/claude-settings.ts +28 -7
  44. package/src/services/gitignore-check.ts +37 -49
  45. package/src/services/gitignore-resolver.ts +10 -1
  46. package/src/services/gitignore-service.ts +67 -0
  47. package/src/services/manifest.ts +31 -0
  48. package/src/services/mate-availability.ts +23 -29
  49. package/src/services/plugin-manager.ts +247 -105
  50. package/src/services/profile-absorb.ts +41 -24
  51. package/src/services/profile-activate.ts +102 -127
  52. package/src/services/profile-startup.ts +160 -4
  53. package/src/services/profile-state.ts +7 -68
  54. package/src/services/tracked-live-files.ts +109 -74
  55. package/src/services/update-engine.ts +28 -10
  56. package/src/services/update-plan.ts +78 -94
  57. package/src/ui/App.tsx +41 -35
  58. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  59. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  60. package/src/ui/hooks/useGitignoreModal.ts +63 -17
  61. package/src/ui/hooks/useProfileStartupModal.ts +94 -9
  62. package/src/ui/keymap.ts +1 -1
  63. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  64. package/src/ui/renderers/pluginRowText.ts +6 -2
  65. package/src/ui/renderers/pluginStatusText.ts +4 -3
  66. package/src/ui/screens/PluginsScreen.tsx +30 -52
  67. package/src/ui/screens/ProfilesScreen.tsx +3 -10
  68. package/src/cli/tui-prompt.tsx +0 -143
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Spec 7.5.4 re-review — REQ-12: a refused format update is a failure in the TUI, as in
3
+ * the CLI.
4
+ *
5
+ * Written blind: from the spec and the exported signatures of `prepareProfileStartup`,
6
+ * `ProfileStartup`, `ProfileStartupNeed`, `formatUpdateOutcome` and `FormatUpdateOutcome`
7
+ * only. `formatUpdateOutcome` is what the TUI shows after "Update it now"; `problems` is
8
+ * what the gate refused — the lines the CLI stops on.
9
+ *
10
+ * REQ-12a–12d call the pure `formatUpdateOutcome` in-process on hand-built results.
11
+ *
12
+ * REQ-12e–12g run `prepareProfileStartup` on a real git repository holding the old
13
+ * layout, in a CHILD process that starts with HOME, CLAUDE_CONFIG_DIR and XDG_* already
14
+ * pointing at temp dirs (Bun's `os.homedir()` ignores a HOME changed at runtime), with
15
+ * stdin/stdout forced non-TTY and colours off. The child's PATH carries only `bun` and
16
+ * `git`, so nothing here can reach a real `claude` CLI or the real `~/.claude`. The child
17
+ * also computes `formatUpdateOutcome` on the result and writes everything as JSON.
18
+ */
19
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
20
+ import { existsSync } from "node:fs";
21
+ import {
22
+ mkdir,
23
+ mkdtemp,
24
+ readFile,
25
+ realpath,
26
+ rm,
27
+ symlink,
28
+ writeFile,
29
+ } from "node:fs/promises";
30
+ import { tmpdir } from "node:os";
31
+ import { dirname, join } from "node:path";
32
+ import {
33
+ type ProfileStartup,
34
+ type ProfileStartupNeed,
35
+ type ProfileStartupState,
36
+ formatUpdateOutcome,
37
+ } from "../services/profile-startup.js";
38
+
39
+ const SRC = join(import.meta.dir, "..");
40
+ const MODULES = {
41
+ ansi: join(SRC, "cli", "ansi.ts"),
42
+ startup: join(SRC, "services", "profile-startup.ts"),
43
+ };
44
+
45
+ const CHILD_TIMEOUT_MS = 45_000;
46
+ const CHILD_TEST_TIMEOUT_MS = 60_000;
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Pure cases
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function needIn(state: ProfileStartupState): ProfileStartupNeed {
53
+ return {
54
+ state,
55
+ adopt: null,
56
+ activate: [],
57
+ declaredDefault: null,
58
+ autoActivate: false,
59
+ manifest: { version: 2, profiles: {} },
60
+ };
61
+ }
62
+
63
+ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", () => {
64
+ test('REQ-12a a problem makes the outcome "failed" with the fixed title and the problem in the message, even when the state reads ok', () => {
65
+ const result: ProfileStartup = {
66
+ need: needIn("ok"),
67
+ notices: [],
68
+ problems: ["boom"],
69
+ };
70
+
71
+ const outcome = formatUpdateOutcome(result);
72
+
73
+ expect(outcome).toEqual({
74
+ kind: "failed",
75
+ title: "Could not update the profile format",
76
+ message: expect.stringContaining("boom"),
77
+ });
78
+ });
79
+
80
+ test('REQ-12b no problems and a need still to answer gives "ask" carrying the same need object', () => {
81
+ const need = needIn("inactive");
82
+ const result: ProfileStartup = { need, notices: [], problems: [] };
83
+
84
+ const outcome = formatUpdateOutcome(result);
85
+
86
+ expect(outcome.kind).toBe("ask");
87
+ expect((outcome as { need?: unknown }).need).toBe(need);
88
+ });
89
+
90
+ test('REQ-12c no problems, state ok and a notice gives "updated" titled Profile format updated with the notice in the message', () => {
91
+ const result: ProfileStartup = {
92
+ need: needIn("ok"),
93
+ notices: ["absorbed 1 edit"],
94
+ problems: [],
95
+ };
96
+
97
+ const outcome = formatUpdateOutcome(result);
98
+
99
+ expect(outcome).toEqual({
100
+ kind: "updated",
101
+ title: "Profile format updated",
102
+ message: expect.stringContaining("absorbed 1 edit"),
103
+ });
104
+ });
105
+
106
+ test('REQ-12d no problems, state ok and no notices gives "updated" with the fixed current-format message', () => {
107
+ const result: ProfileStartup = {
108
+ need: needIn("ok"),
109
+ notices: [],
110
+ problems: [],
111
+ };
112
+
113
+ const outcome = formatUpdateOutcome(result);
114
+
115
+ expect(outcome).toEqual({
116
+ kind: "updated",
117
+ title: expect.any(String),
118
+ message: "This project now uses the current profile format.",
119
+ });
120
+ });
121
+ });
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Real-tree cases
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /** mode `prepare`: prepareProfileStartup with NO options. mode `convert`: `{ convert: true }`. */
128
+ const STARTUP_CHILD = `
129
+ import { writeFileSync } from "node:fs";
130
+
131
+ const [mode, project, resultPath] = process.argv.slice(2);
132
+ const ansi = await import(${JSON.stringify(MODULES.ansi)});
133
+ const startup = await import(${JSON.stringify(MODULES.startup)});
134
+
135
+ if (typeof ansi.setColorEnabled === "function") ansi.setColorEnabled(false);
136
+ for (const stream of [process.stdin, process.stdout, process.stderr]) {
137
+ Object.defineProperty(stream, "isTTY", { value: false, configurable: true });
138
+ }
139
+
140
+ const out = {
141
+ state: null,
142
+ notices: null,
143
+ problems: null,
144
+ outcome: null,
145
+ error: null,
146
+ };
147
+ try {
148
+ const prepared =
149
+ mode === "convert"
150
+ ? await startup.prepareProfileStartup(project, { convert: true })
151
+ : await startup.prepareProfileStartup(project);
152
+ out.state = prepared.need.state;
153
+ out.notices = prepared.notices;
154
+ out.problems = Array.isArray(prepared.problems) ? prepared.problems : null;
155
+ const outcome = startup.formatUpdateOutcome(prepared);
156
+ out.outcome =
157
+ outcome.kind === "ask"
158
+ ? { kind: "ask", needState: outcome.need.state }
159
+ : { kind: outcome.kind, title: outcome.title, message: outcome.message };
160
+ } catch (e) {
161
+ out.error = e instanceof Error ? (e.stack ?? e.message) : String(e);
162
+ }
163
+ writeFileSync(resultPath, JSON.stringify(out));
164
+ process.exit(0);
165
+ `;
166
+
167
+ interface StartupRun {
168
+ state: ProfileStartupState | null;
169
+ notices: string[] | null;
170
+ /** Null when the result carried no `problems` array at all. */
171
+ problems: string[] | null;
172
+ outcome:
173
+ | { kind: "ask"; needState: ProfileStartupState }
174
+ | { kind: "updated" | "failed"; title: string; message: string }
175
+ | null;
176
+ error: string | null;
177
+ }
178
+
179
+ /** The target file the settings symlink names, when it does NOT parse (REQ-12e). */
180
+ const UNPARSEABLE_TARGET = '{"enabledPlugins": {"dev@magus": true,},}\n';
181
+ /** The same file when it parses (REQ-12f). */
182
+ const PARSEABLE_TARGET = '{"enabledPlugins":{"dev@magus":true}}\n';
183
+ const PROFILES_JSON =
184
+ '{"version":2,"profiles":{"team":{"name":"team","plugins":{"dev@magus":true}}}}\n';
185
+
186
+ const scratch: string[] = [];
187
+ let helperDir = "";
188
+ let binDir = "";
189
+
190
+ async function tempRoot(prefix: string): Promise<string> {
191
+ const dir = await realpath(await mkdtemp(join(tmpdir(), prefix)));
192
+ scratch.push(dir);
193
+ return dir;
194
+ }
195
+
196
+ beforeAll(async () => {
197
+ helperDir = await tempRoot("fuo-helpers-");
198
+ binDir = join(helperDir, "bin");
199
+ await mkdir(binDir);
200
+ await symlink(process.execPath, join(binDir, "bun"));
201
+ const git = Bun.which("git");
202
+ if (git) await symlink(git, join(binDir, "git"));
203
+ await writeFile(join(helperDir, "startup-child.ts"), STARTUP_CHILD);
204
+ });
205
+
206
+ afterAll(async () => {
207
+ for (const dir of scratch) await rm(dir, { recursive: true, force: true });
208
+ });
209
+
210
+ interface Project {
211
+ base: string;
212
+ project: string;
213
+ env: Record<string, string>;
214
+ }
215
+
216
+ /**
217
+ * A real git repository holding the old layout, everything committed:
218
+ * `.claude/profiles.json` defining `team`, `.claude/_profiles/team/settings.json` with
219
+ * `targetContent`, and `.claude/settings.json` a SYMLINK to the RELATIVE path
220
+ * `_profiles/team/settings.json`. HOME, CLAUDE_CONFIG_DIR (`<HOME>/.claude`) and XDG_*
221
+ * are temp dirs beside it.
222
+ */
223
+ async function oldLayoutProject(targetContent: string): Promise<Project> {
224
+ const base = await tempRoot("fuo-");
225
+ const home = join(base, "home");
226
+ const xdg = join(base, "xdg");
227
+ await mkdir(join(home, ".claude"), { recursive: true });
228
+ for (const kind of ["config", "cache", "data", "state"]) {
229
+ await mkdir(join(xdg, kind), { recursive: true });
230
+ }
231
+ const env = {
232
+ PATH: `${binDir}:/usr/bin:/bin`,
233
+ HOME: home,
234
+ CLAUDE_CONFIG_DIR: join(home, ".claude"),
235
+ XDG_CONFIG_HOME: join(xdg, "config"),
236
+ XDG_CACHE_HOME: join(xdg, "cache"),
237
+ XDG_DATA_HOME: join(xdg, "data"),
238
+ XDG_STATE_HOME: join(xdg, "state"),
239
+ TMPDIR: tmpdir(),
240
+ NO_COLOR: "1",
241
+ TERM: "dumb",
242
+ GIT_CONFIG_NOSYSTEM: "1",
243
+ GIT_CONFIG_GLOBAL: "/dev/null",
244
+ };
245
+ const project = join(base, "proj");
246
+ await mkdir(project, { recursive: true });
247
+ const git = (...args: string[]): void => {
248
+ const r = Bun.spawnSync({
249
+ cmd: [
250
+ "git",
251
+ "-c",
252
+ "user.email=t@t",
253
+ "-c",
254
+ "user.name=t",
255
+ "-c",
256
+ "commit.gpgsign=false",
257
+ ...args,
258
+ ],
259
+ cwd: project,
260
+ env,
261
+ });
262
+ if (r.exitCode !== 0) {
263
+ throw new Error(`git ${args.join(" ")} failed: ${r.stderr.toString()}`);
264
+ }
265
+ };
266
+ git("init", "-q");
267
+ const target = join(project, ".claude", "_profiles", "team", "settings.json");
268
+ await mkdir(dirname(target), { recursive: true });
269
+ await writeFile(target, targetContent);
270
+ await symlink(
271
+ "_profiles/team/settings.json",
272
+ join(project, ".claude", "settings.json"),
273
+ );
274
+ await writeFile(join(project, ".claude", "profiles.json"), PROFILES_JSON);
275
+ git("add", "-A");
276
+ git("commit", "-q", "-m", "init");
277
+ return { base, project, env };
278
+ }
279
+
280
+ async function runStartup(
281
+ proj: Project,
282
+ mode: "prepare" | "convert",
283
+ ): Promise<StartupRun> {
284
+ const resultPath = join(proj.base, `result-${mode}.json`);
285
+ const proc = Bun.spawn({
286
+ cmd: [
287
+ process.execPath,
288
+ "--no-env-file",
289
+ join(helperDir, "startup-child.ts"),
290
+ mode,
291
+ proj.project,
292
+ resultPath,
293
+ ],
294
+ cwd: proj.project,
295
+ env: proj.env,
296
+ stdin: "ignore",
297
+ stdout: "pipe",
298
+ stderr: "pipe",
299
+ });
300
+ const timer = setTimeout(() => proc.kill(), CHILD_TIMEOUT_MS);
301
+ const [stdout, stderr, code] = await Promise.all([
302
+ new Response(proc.stdout).text(),
303
+ new Response(proc.stderr).text(),
304
+ proc.exited,
305
+ ]);
306
+ clearTimeout(timer);
307
+ let raw: string;
308
+ try {
309
+ raw = await readFile(resultPath, "utf8");
310
+ } catch {
311
+ throw new Error(
312
+ `startup child wrote no result (exit ${code}).\nstdout:\n${stdout}\nstderr:\n${stderr}`,
313
+ );
314
+ }
315
+ return JSON.parse(raw) as StartupRun;
316
+ }
317
+
318
+ describe("REQ-12 a refused format update is a failure in the TUI, as in the CLI", () => {
319
+ test(
320
+ 'REQ-12e converting an old layout whose settings target does not parse returns a problem, a "failed" outcome, and leaves .claude/_profiles/ in place',
321
+ async () => {
322
+ const proj = await oldLayoutProject(UNPARSEABLE_TARGET);
323
+ const oldDir = join(proj.project, ".claude", "_profiles");
324
+ expect(existsSync(oldDir)).toBe(true);
325
+
326
+ const run = await runStartup(proj, "convert");
327
+
328
+ expect(run.error).toBeNull();
329
+ expect(run.problems).not.toBeNull();
330
+ expect(run.problems?.length ?? 0).toBeGreaterThan(0);
331
+ expect(run.outcome?.kind).toBe("failed");
332
+ expect(existsSync(oldDir)).toBe(true);
333
+ },
334
+ CHILD_TEST_TIMEOUT_MS,
335
+ );
336
+
337
+ test(
338
+ 'REQ-12f negative control: converting an old layout whose settings target parses returns no problems and an outcome that is not "failed"',
339
+ async () => {
340
+ const proj = await oldLayoutProject(PARSEABLE_TARGET);
341
+
342
+ const run = await runStartup(proj, "convert");
343
+
344
+ expect(run.error).toBeNull();
345
+ expect(run.problems).toEqual([]);
346
+ expect(run.outcome).not.toBeNull();
347
+ expect(run.outcome?.kind).not.toBe("failed");
348
+ },
349
+ CHILD_TEST_TIMEOUT_MS,
350
+ );
351
+
352
+ test(
353
+ 'REQ-12g without options, the unparseable old layout is asked about as "old-format", refuses nothing and converts nothing',
354
+ async () => {
355
+ const proj = await oldLayoutProject(UNPARSEABLE_TARGET);
356
+ const oldDir = join(proj.project, ".claude", "_profiles");
357
+
358
+ const run = await runStartup(proj, "prepare");
359
+
360
+ expect(run.error).toBeNull();
361
+ expect(run.state).toBe("old-format");
362
+ expect(run.problems).toEqual([]);
363
+ expect(existsSync(oldDir)).toBe(true);
364
+ },
365
+ CHILD_TEST_TIMEOUT_MS,
366
+ );
367
+ });
@@ -1,19 +1,16 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
2
  import { spawnSync } from "node:child_process";
3
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { BUILTIN_DEFAULTS } from "../data/gitignore-defaults.js";
7
- import {
8
- checkGitignore,
9
- formatGitignoreWarning,
10
- } from "../services/gitignore-check";
7
+ import { startupGitignoreNeed } from "../services/gitignore-check";
11
8
 
12
9
  function git(cwd: string, ...args: string[]): void {
13
10
  spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], { cwd });
14
11
  }
15
12
 
16
- describe("checkGitignore", () => {
13
+ describe("startupGitignoreNeed", () => {
17
14
  let projectDir: string;
18
15
 
19
16
  beforeEach(async () => {
@@ -24,20 +21,21 @@ describe("checkGitignore", () => {
24
21
  await rm(projectDir, { recursive: true, force: true });
25
22
  });
26
23
 
27
- it("returns 0 violations for non-git directory", async () => {
28
- const r = await checkGitignore(projectDir);
29
- expect(r.violationCount).toBe(0);
24
+ it("asks nothing in a non-git directory", async () => {
25
+ expect(await startupGitignoreNeed(projectDir)).toBeNull();
30
26
  });
31
27
 
32
28
  it("flags missing built-in patterns inside a fresh git repo", async () => {
33
29
  git(projectDir, "init", "-q");
34
- const r = await checkGitignore(projectDir);
30
+ const need = await startupGitignoreNeed(projectDir);
35
31
  // BUILTIN_DEFAULTS includes ".mnemex/" etc. — none are tracked, none in
36
32
  // .gitignore, so each one becomes "missing-from-gitignore".
37
- expect(r.violationCount).toBeGreaterThan(0);
33
+ expect(need?.violationCount).toBeGreaterThan(0);
34
+ expect(need?.safeCount).toBeGreaterThan(0);
35
+ expect(need?.generated).toEqual([]);
38
36
  });
39
37
 
40
- it("returns 0 when .gitignore covers all built-in patterns and tracked files match track rules", async () => {
38
+ it("asks nothing when .gitignore covers all built-in patterns and tracked files match track rules", async () => {
41
39
  git(projectDir, "init", "-q");
42
40
  git(projectDir, "config", "user.email", "t@t");
43
41
  git(projectDir, "config", "user.name", "t");
@@ -50,22 +48,6 @@ describe("checkGitignore", () => {
50
48
  `${BUILTIN_DEFAULTS.ignore.join("\n")}\n`,
51
49
  );
52
50
  // Tracked files don't have to exist for the absent-track-path case to be ignored
53
- const r = await checkGitignore(projectDir);
54
- expect(r.violationCount).toBe(0);
55
- });
56
- });
57
-
58
- describe("formatGitignoreWarning", () => {
59
- it("returns null when no violations", () => {
60
- expect(
61
- formatGitignoreWarning({ violationCount: 0, warnings: [] }),
62
- ).toBeNull();
63
- });
64
-
65
- it("returns a warning string when violations exist", () => {
66
- const w = formatGitignoreWarning({ violationCount: 3, warnings: [] });
67
- expect(w).not.toBeNull();
68
- expect(w).toContain("3 git state");
69
- expect(w).toContain("Git State tab");
51
+ expect(await startupGitignoreNeed(projectDir)).toBeNull();
70
52
  });
71
53
  });
@@ -0,0 +1,248 @@
1
+ /**
2
+ * B7 — in a project with profiles, a generated file git tracks is a question
3
+ * asked on every TUI open, fixed in place when the user says so.
4
+ *
5
+ * The files magus generates from `.claude/profiles.json` are expected to be
6
+ * gitignored. The gitignore manager's `profile` tier says so, so a committed
7
+ * copy is reported as "tracked but should be ignored" — and the startup fix
8
+ * untracks exactly those files, leaving every other violation for the tab.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
12
+ import { spawnSync } from "node:child_process";
13
+ import { mkdtemp, rm } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import fs from "fs-extra";
17
+ import { startupGitignoreNeed } from "../services/gitignore-check.js";
18
+ import {
19
+ applyStartupFixes,
20
+ describeStartupFix,
21
+ loadGitignoreState,
22
+ } from "../services/gitignore-service.js";
23
+ import { writeManifest } from "../services/manifest.js";
24
+ import { activateProfile } from "../services/profile-activate.js";
25
+ import {
26
+ folderHash,
27
+ readActiveMarker,
28
+ writeActiveMarker,
29
+ } from "../services/profile-live.js";
30
+ import {
31
+ applyProfileChoice,
32
+ prepareProfileState,
33
+ } from "../services/profile-state.js";
34
+ import type { ProfileManifest } from "../types/index.js";
35
+ import { buildGitignoreModal } from "../ui/hooks/useGitignoreModal.js";
36
+
37
+ const MANIFEST: ProfileManifest = {
38
+ version: 2,
39
+ profiles: { team: { name: "Team", plugins: { "dev@magus": "latest" } } },
40
+ };
41
+
42
+ let project: string;
43
+ let log: ReturnType<typeof spyOn>;
44
+
45
+ function git(...args: string[]) {
46
+ return spawnSync(
47
+ "git",
48
+ [
49
+ "-c",
50
+ "user.email=test@example.com",
51
+ "-c",
52
+ "user.name=test",
53
+ "-c",
54
+ "commit.gpgsign=false",
55
+ "-c",
56
+ "core.excludesFile=/dev/null",
57
+ ...args,
58
+ ],
59
+ { cwd: project, encoding: "utf8" },
60
+ );
61
+ }
62
+
63
+ /** A repo that commits `.claude/settings.json` (and, optionally, profiles.json). */
64
+ async function commitSettings(withProfiles: boolean): Promise<void> {
65
+ expect(git("init", "-q").status).toBe(0);
66
+ await fs.outputJson(join(project, ".claude", "settings.json"), {
67
+ enabledPlugins: { "dev@magus": true },
68
+ });
69
+ if (withProfiles) await writeManifest(MANIFEST, project);
70
+ expect(git("add", "-A").status).toBe(0);
71
+ expect(git("commit", "-q", "-m", "init").status).toBe(0);
72
+ }
73
+
74
+ beforeEach(async () => {
75
+ project = await fs.realpath(await mkdtemp(join(tmpdir(), "gitig-prof-")));
76
+ log = spyOn(console, "log").mockImplementation(() => {});
77
+ });
78
+ afterEach(async () => {
79
+ log.mockRestore();
80
+ await rm(project, { recursive: true, force: true });
81
+ });
82
+
83
+ describe("the startup question names tracked generated files", () => {
84
+ test("B7: in a project with profiles, a committed settings.json is tracked-but-should-ignore", async () => {
85
+ await commitSettings(true);
86
+
87
+ const need = await startupGitignoreNeed(project);
88
+
89
+ expect(need?.generated).toEqual([".claude/settings.json"]);
90
+ const { violations } = await loadGitignoreState(project);
91
+ expect(violations).toContainEqual({
92
+ kind: "tracked-but-should-ignore",
93
+ path: ".claude/settings.json",
94
+ source: "profile",
95
+ severity: "destructive",
96
+ });
97
+ });
98
+
99
+ test("B7: without profiles, a committed settings.json is the team's file — nothing generated to ask about", async () => {
100
+ await commitSettings(false);
101
+ expect((await startupGitignoreNeed(project))?.generated ?? []).toEqual([]);
102
+ });
103
+
104
+ test("B7: nothing is remembered — dismissing leaves the question for the next open", async () => {
105
+ await commitSettings(true);
106
+ // Two opens, the first dismissed (dismiss changes nothing).
107
+ const first = await startupGitignoreNeed(project);
108
+ const second = await startupGitignoreNeed(project);
109
+ expect(first?.generated).toEqual([".claude/settings.json"]);
110
+ expect(second).toEqual(first);
111
+ });
112
+
113
+ test("B7: after a first adoption in the same session, the re-check asks about the file adoption made generated", async () => {
114
+ await commitSettings(false);
115
+ expect((await startupGitignoreNeed(project))?.generated ?? []).toEqual([]);
116
+
117
+ // The profile modal's "Save what you have".
118
+ await applyProfileChoice(
119
+ { kind: "adopt", id: "default" },
120
+ { version: 2, profiles: {} },
121
+ project,
122
+ { onDisplace: () => {} },
123
+ );
124
+
125
+ expect((await startupGitignoreNeed(project))?.generated).toEqual([
126
+ ".claude/settings.json",
127
+ ]);
128
+ });
129
+ });
130
+
131
+ describe("the startup fix untracks generated files in place", () => {
132
+ test("B7: only generated files are untracked; other violations stay; the removal is staged", async () => {
133
+ await commitSettings(true);
134
+ // Another violation magus does not own: a committed personal settings file.
135
+ await fs.outputJson(join(project, ".claude", "settings.local.json"), {});
136
+ expect(git("add", "-f", ".claude/settings.local.json").status).toBe(0);
137
+ expect(git("commit", "-q", "-m", "local").status).toBe(0);
138
+
139
+ const result = await applyStartupFixes(project);
140
+
141
+ expect(result.untracked).toEqual([".claude/settings.json"]);
142
+ expect(result.failed).toEqual([]);
143
+ const status = git("status", "--porcelain").stdout;
144
+ expect(status).toContain("D .claude/settings.json");
145
+ expect(status).not.toContain("settings.local.json");
146
+ expect(git("ls-files", ".claude/settings.local.json").stdout.trim()).toBe(
147
+ ".claude/settings.local.json",
148
+ );
149
+ // The file itself is kept, and now ignored.
150
+ expect(await fs.pathExists(join(project, ".claude", "settings.json"))).toBe(
151
+ true,
152
+ );
153
+ expect(await fs.readFile(join(project, ".gitignore"), "utf8")).toContain(
154
+ ".claude/settings.json",
155
+ );
156
+ expect(describeStartupFix(result)).toContain(
157
+ "Stopped tracking (the removal is staged — commit it):",
158
+ );
159
+ // Asked again on the next open: nothing generated is tracked any more.
160
+ expect((await startupGitignoreNeed(project))?.generated ?? []).toEqual([]);
161
+ });
162
+
163
+ test("B7: the modal offers the in-place fix and names the files", async () => {
164
+ const modal = buildGitignoreModal({
165
+ violationCount: 3,
166
+ safeCount: 2,
167
+ generated: [".claude/settings.json"],
168
+ onFixSafe: () => {},
169
+ onOpenTab: () => {},
170
+ onDismiss: () => {},
171
+ });
172
+ if (modal.type !== "select") throw new Error("type guard");
173
+ expect(modal.options.map((o) => o.value)).toEqual([
174
+ "fix-safe",
175
+ "open-tab",
176
+ "dismiss",
177
+ ]);
178
+ expect(modal.options[0]?.label).toBe(
179
+ "Stop tracking 1 generated file and add 2 missing .gitignore entries",
180
+ );
181
+ expect(modal.message).toContain(" .claude/settings.json");
182
+ expect(modal.message).toContain("expected to be gitignored");
183
+ });
184
+ });
185
+
186
+ describe("review: what the profile tier must never claim", () => {
187
+ test("B7 review M: a committed skill an old marker records as magus's is never flagged, never untracked, and handed back", async () => {
188
+ expect(git("init", "-q").status).toBe(0);
189
+ await fs.outputFile(
190
+ join(project, ".claude", "skills", "review", "SKILL.md"),
191
+ "---\nname: review\n---\nteam skill\n",
192
+ );
193
+ expect(git("add", "-A").status).toBe(0);
194
+ expect(git("commit", "-q", "-m", "team skill").status).toBe(0);
195
+ await writeManifest(MANIFEST, project);
196
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
197
+ // What 7.5.0's conversion wrote: the committed folder recorded as installed.
198
+ const marker = await readActiveMarker(project);
199
+ if (!marker) throw new Error("no marker");
200
+ await writeActiveMarker(
201
+ {
202
+ ...marker,
203
+ skills: {
204
+ review: (await folderHash(
205
+ join(project, ".claude", "skills", "review"),
206
+ )) as string,
207
+ },
208
+ },
209
+ project,
210
+ );
211
+
212
+ expect(
213
+ (await startupGitignoreNeed(project))?.generated ?? [],
214
+ ).not.toContain(".claude/skills/review/SKILL.md");
215
+ const fixed = await applyStartupFixes(project);
216
+ expect(fixed.untracked).not.toContain(".claude/skills/review/SKILL.md");
217
+ expect(git("diff", "--cached", "--name-only").stdout).toBe("");
218
+
219
+ // The next marker write drops it, and says so once.
220
+ const prep = await prepareProfileState(project);
221
+ expect(prep.lines).toContain(
222
+ "Skill review is committed by the team — magus no longer manages it",
223
+ );
224
+ expect((await readActiveMarker(project))?.skills).toEqual({});
225
+ expect(
226
+ await fs.pathExists(
227
+ join(project, ".claude", "skills", "review", "SKILL.md"),
228
+ ),
229
+ ).toBe(true);
230
+ expect((await prepareProfileState(project)).lines.join("\n")).not.toContain(
231
+ "committed by the team",
232
+ );
233
+ });
234
+
235
+ test("B7 review L2: a left-over marker in a repo that dropped profiles.json is not profiled", async () => {
236
+ expect(git("init", "-q").status).toBe(0);
237
+ await writeManifest(MANIFEST, project);
238
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
239
+ await fs.remove(join(project, ".claude", "profiles.json"));
240
+ expect(git("add", "-f", ".claude/settings.json").status).toBe(0);
241
+ expect(
242
+ git("commit", "-q", "-m", "team commits settings again").status,
243
+ ).toBe(0);
244
+
245
+ expect((await startupGitignoreNeed(project))?.generated ?? []).toEqual([]);
246
+ expect((await applyStartupFixes(project)).untracked).toEqual([]);
247
+ });
248
+ });