magus-cli 7.5.2 → 7.5.3

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.5.2",
3
+ "version": "7.5.3",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -65,8 +65,8 @@
65
65
  "typescript": "^5.6.3"
66
66
  },
67
67
  "optionalDependencies": {
68
- "magus-cli-darwin-arm64": "7.5.2",
69
- "magus-cli-darwin-x64": "7.5.2",
70
- "magus-cli-linux-x64": "7.5.2"
68
+ "magus-cli-darwin-arm64": "7.5.3",
69
+ "magus-cli-darwin-x64": "7.5.3",
70
+ "magus-cli-linux-x64": "7.5.3"
71
71
  }
72
72
  }
@@ -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
+ });
@@ -14,6 +14,10 @@ import fs from "fs-extra";
14
14
  import { runModelsCommand } from "../cli/models.js";
15
15
  import { presetNames } from "../data/models-presets.js";
16
16
  import { writeManifest } from "../services/manifest.js";
17
+ import {
18
+ clearMateCatalogCache,
19
+ loadMateCatalog,
20
+ } from "../services/mate-catalog.js";
17
21
  import { activateProfile } from "../services/profile-activate.js";
18
22
 
19
23
  let logs: string[];
@@ -31,10 +35,17 @@ beforeEach(async () => {
31
35
  configDir = await mkdtemp(join(tmpdir(), "modelscli-config-"));
32
36
  previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
33
37
  process.env.CLAUDE_CONFIG_DIR = configDir;
38
+ // `models list` with a mate asks the catalogue, whose default fetcher spawns the real
39
+ // `claudish --models` — about 2s on a machine that has it, with an 8s budget, so the first
40
+ // test to reach it timed out under load and passed on CI, which has no claudish. An empty
41
+ // catalogue is the no-claudish case, and every assertion here holds against it.
42
+ clearMateCatalogCache();
43
+ await loadMateCatalog(async () => null);
34
44
  });
35
45
 
36
46
  afterEach(async () => {
37
47
  mock.restore();
48
+ clearMateCatalogCache();
38
49
  // biome-ignore lint/performance/noDelete: absence is the intent, not a shortcut
39
50
  if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
40
51
  else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
@@ -17,6 +17,7 @@ import {
17
17
  activationNotes,
18
18
  recordRefetchedSkills,
19
19
  trackFetchedSkills,
20
+ trackedGeneratedWarnings,
20
21
  updatableSkills,
21
22
  } from "../services/profile-activate.js";
22
23
  import {
@@ -459,42 +460,36 @@ describe("older layout: committed custom skills the seeding displaced come back"
459
460
  expect(again.problems).toEqual([]);
460
461
  });
461
462
 
462
- test("review 3b: a magus-installed folder someone committed is refused before the reconcile writes anything", async () => {
463
+ test("B7 review: a magus-installed folder someone committed is handed back to the team — kept, never warned about", async () => {
463
464
  await start();
464
465
  expect(git(project, "init", "-q").status).toBe(0);
465
466
  expect(git(project, "add", "-f", ".claude/skills/alpha").status).toBe(0);
466
467
  expect(git(project, "commit", "-q", "-m", "oops").status).toBe(0);
467
- // A teammate drops alpha from the profile: the next reconcile would
468
- // remove the folder, after recording the change in the marker.
468
+ expect(await trackedGeneratedWarnings(project)).toEqual([]);
469
+ // A teammate drops alpha from the profile. Were it still magus's, the
470
+ // reconcile would delete the team's committed folder.
469
471
  const pulled = manifest();
470
472
  (pulled.profiles.team as { skills: ProfileSkillRef[] }).skills = [];
471
473
  await writeManifest(pulled, project);
472
- const marker = await fs.readFile(
473
- join(project, ".claude", "profiles", "active.json"),
474
- "utf8",
475
- );
476
- const manifestBytes = await fs.readFile(
477
- join(project, ".claude", "profiles.json"),
478
- "utf8",
479
- );
480
474
 
481
475
  const prep = await prepareProfileState(project, {
482
476
  installSkill: fakeInstall,
483
477
  });
484
478
 
485
- expect(prep.problems[0]).toContain(
486
- "git rm --cached -r .claude/skills/alpha",
479
+ expect(prep.problems).toEqual([]);
480
+ expect(prep.lines).toContain(
481
+ "Skill alpha is committed by the team — magus no longer manages it",
487
482
  );
488
483
  expect(await fs.pathExists(skill("alpha"))).toBe(true);
484
+ expect((await readActiveMarker(project))?.skills).toEqual({});
489
485
  expect(
490
- await fs.readFile(
491
- join(project, ".claude", "profiles", "active.json"),
492
- "utf8",
493
- ),
494
- ).toBe(marker);
495
- expect(
496
- await fs.readFile(join(project, ".claude", "profiles.json"), "utf8"),
497
- ).toBe(manifestBytes);
486
+ git(project, "status", "--porcelain", "--", ".claude/skills").stdout,
487
+ ).toBe("");
488
+ // Said once: the marker no longer records it.
489
+ const again = await prepareProfileState(project, {
490
+ installSkill: fakeInstall,
491
+ });
492
+ expect(again.lines.join("\n")).not.toContain("committed by the team");
498
493
  });
499
494
 
500
495
  test("an existing folder is never overwritten; a differing copy is reported with both paths", async () => {