magus-cli 7.10.4 → 7.10.5

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.10.4",
3
+ "version": "7.10.5",
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.10.4",
69
- "magus-cli-darwin-x64": "7.10.4",
70
- "magus-cli-linux-x64": "7.10.4"
68
+ "magus-cli-darwin-arm64": "7.10.5",
69
+ "magus-cli-darwin-x64": "7.10.5",
70
+ "magus-cli-linux-x64": "7.10.5"
71
71
  }
72
72
  }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Claude Code copies git-ignored files into a NEW worktree (`claude -w`,
3
+ * EnterWorktree, agent isolation) only when the launch root's
4
+ * `.worktreeinclude` lists them. magus git-ignores the set-up state it
5
+ * generates, so it must also list that state there — or every new worktree of
6
+ * a profiled repo starts without the project's plugins.
7
+ *
8
+ * Exercised against a real git repository, with HOME and git config pointed at
9
+ * a scratch folder, because "listed AND git-ignored" is a statement about git.
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
13
+ import { execFileSync, spawnSync } from "node:child_process";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import fs from "fs-extra";
17
+ import { writeManifest } from "../services/manifest.js";
18
+ import { activateProfile } from "../services/profile-activate.js";
19
+ import { updateSkillIgnoreLines } from "../services/profile-skills.js";
20
+ import {
21
+ inspectProfileState,
22
+ prepareProfileState,
23
+ } from "../services/profile-state.js";
24
+ import type { ProfileManifest } from "../types/index.js";
25
+
26
+ // A profile with no plugins: activation renders the project's files with no
27
+ // install, CLI call or network.
28
+ const MANIFEST: ProfileManifest = {
29
+ version: 2,
30
+ profiles: { team: { name: "Team", plugins: {} } },
31
+ default: "team",
32
+ };
33
+
34
+ /** The set-up state a new worktree needs to start with the project's plugins. */
35
+ const REQUIRED = [
36
+ ".claude/settings.json",
37
+ ".mcp.json",
38
+ ".claude/models.json",
39
+ ".claude/profiles/active.json",
40
+ ];
41
+
42
+ const GIT_CFG = [
43
+ "-c",
44
+ "user.name=test",
45
+ "-c",
46
+ "user.email=test@example.com",
47
+ "-c",
48
+ "commit.gpgsign=false",
49
+ "-c",
50
+ "init.defaultBranch=main",
51
+ ];
52
+
53
+ const git = (cwd: string, ...args: string[]): string =>
54
+ execFileSync("git", [...GIT_CFG, ...args], {
55
+ cwd,
56
+ encoding: "utf8",
57
+ stdio: ["ignore", "pipe", "pipe"],
58
+ }).trim();
59
+
60
+ /** Whether git treats `rel` as ignored in `repo` (exit 0 = ignored). */
61
+ const ignoredByGit = (repo: string, rel: string): boolean =>
62
+ spawnSync("git", [...GIT_CFG, "check-ignore", "-q", "--", rel], {
63
+ cwd: repo,
64
+ }).status === 0;
65
+
66
+ const ENV_KEYS = [
67
+ "HOME",
68
+ "CLAUDE_CONFIG_DIR",
69
+ "XDG_CONFIG_HOME",
70
+ "XDG_CACHE_HOME",
71
+ "GIT_CONFIG_NOSYSTEM",
72
+ "GIT_CEILING_DIRECTORIES",
73
+ "GIT_DIR",
74
+ "GIT_WORK_TREE",
75
+ "GIT_INDEX_FILE",
76
+ "GIT_COMMON_DIR",
77
+ "GIT_OBJECT_DIRECTORY",
78
+ "GIT_PREFIX",
79
+ ] as const;
80
+
81
+ let savedEnv: Record<string, string | undefined> = {};
82
+ let base: string;
83
+ let project: string;
84
+ let log: ReturnType<typeof spyOn>;
85
+ let err: ReturnType<typeof spyOn>;
86
+
87
+ const includePath = () => path.join(project, ".worktreeinclude");
88
+
89
+ /** The non-blank, non-comment lines of `.worktreeinclude`. */
90
+ async function includeLines(): Promise<string[]> {
91
+ const text = await fs.readFile(includePath(), "utf8");
92
+ return text
93
+ .split("\n")
94
+ .map((l) => l.trim())
95
+ .filter((l) => l !== "" && !l.startsWith("#"));
96
+ }
97
+
98
+ async function activate(): Promise<void> {
99
+ await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
100
+ }
101
+
102
+ beforeEach(async () => {
103
+ savedEnv = {};
104
+ for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
105
+ for (const key of ENV_KEYS) delete process.env[key];
106
+
107
+ base = await fs.realpath(
108
+ await fs.mkdtemp(path.join(os.tmpdir(), "magus-wt-include-")),
109
+ );
110
+ const home = path.join(base, "home");
111
+ await fs.ensureDir(path.join(home, ".claude"));
112
+ process.env.HOME = home;
113
+ process.env.XDG_CONFIG_HOME = path.join(home, ".config");
114
+ process.env.XDG_CACHE_HOME = path.join(home, ".cache");
115
+ process.env.CLAUDE_CONFIG_DIR = path.join(home, ".claude");
116
+ process.env.GIT_CONFIG_NOSYSTEM = "1";
117
+ process.env.GIT_CEILING_DIRECTORIES = base;
118
+
119
+ project = path.join(base, "repo");
120
+ await fs.ensureDir(project);
121
+ git(project, "init", "-q");
122
+ await writeManifest(MANIFEST, project);
123
+ git(project, "add", "-A");
124
+ git(project, "commit", "-qm", "manifest");
125
+
126
+ log = spyOn(console, "log").mockImplementation(() => {});
127
+ err = spyOn(console, "error").mockImplementation(() => {});
128
+ });
129
+
130
+ afterEach(async () => {
131
+ log.mockRestore();
132
+ err.mockRestore();
133
+ for (const key of ENV_KEYS) {
134
+ if (savedEnv[key] === undefined) delete process.env[key];
135
+ else process.env[key] = savedEnv[key];
136
+ }
137
+ await fs.remove(base);
138
+ });
139
+
140
+ describe("activation lists the set-up state in .worktreeinclude", () => {
141
+ test("lists every generated file and never the backups", async () => {
142
+ // REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
143
+ await activate();
144
+
145
+ expect(await fs.pathExists(includePath())).toBe(true);
146
+ const lines = await includeLines();
147
+ for (const entry of REQUIRED) expect(lines).toContain(entry);
148
+ expect(lines.some((l) => l.includes(".magus-backups"))).toBe(false);
149
+ });
150
+
151
+ test("every listed path is git-ignored, so Claude Code copies it", async () => {
152
+ // REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
153
+ await activate();
154
+
155
+ const lines = await includeLines();
156
+ expect(lines.length).toBeGreaterThan(0);
157
+ const notIgnored = lines.filter((l) => !ignoredByGit(project, l));
158
+ expect(notIgnored).toEqual([]);
159
+ });
160
+
161
+ test("a person's own .worktreeinclude lines survive activation untouched", async () => {
162
+ // REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
163
+ const own =
164
+ "# mine: local secrets for every worktree\n.env\nconfig/local.yaml\n";
165
+ await fs.writeFile(includePath(), own);
166
+
167
+ await activate();
168
+
169
+ const text = await fs.readFile(includePath(), "utf8");
170
+ expect(text.startsWith(own)).toBe(true);
171
+ const lines = await includeLines();
172
+ expect(lines).toContain(".env");
173
+ expect(lines).toContain("config/local.yaml");
174
+ for (const entry of REQUIRED) expect(lines).toContain(entry);
175
+ });
176
+ });
177
+
178
+ describe("an already set-up project gets the include on the next gate pass", () => {
179
+ test("inspectProfileState writes nothing; prepareProfileState adds the block", async () => {
180
+ // REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
181
+ await activate();
182
+ // A project set up by a release that never wrote the include.
183
+ await fs.remove(includePath());
184
+ expect(
185
+ await fs.pathExists(
186
+ path.join(project, ".claude", "profiles", "active.json"),
187
+ ),
188
+ ).toBe(true);
189
+
190
+ await inspectProfileState(project);
191
+ expect(await fs.pathExists(includePath())).toBe(false);
192
+
193
+ const prep = await prepareProfileState(project);
194
+ expect(prep.problems).toEqual([]);
195
+ expect(await fs.pathExists(includePath())).toBe(true);
196
+ const lines = await includeLines();
197
+ for (const entry of REQUIRED) expect(lines).toContain(entry);
198
+ expect(lines.some((l) => l.includes(".magus-backups"))).toBe(false);
199
+ });
200
+ });
201
+
202
+ describe("magus-installed skill folders follow the gitignore into the include", () => {
203
+ test("a recorded skill folder is listed, and unlisted when removed", async () => {
204
+ // REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
205
+ await updateSkillIgnoreLines(project, [], ["team-skill"]);
206
+ expect(await fs.pathExists(includePath())).toBe(true);
207
+ expect(await includeLines()).toContain(".claude/skills/team-skill/");
208
+
209
+ await updateSkillIgnoreLines(project, ["team-skill"], []);
210
+ expect(await includeLines()).not.toContain(".claude/skills/team-skill/");
211
+ });
212
+ });
@@ -42,6 +42,8 @@ import {
42
42
  OBSOLETE_GITIGNORE_ENTRIES,
43
43
  PROFILE_GITIGNORE_COMMENT,
44
44
  PROFILE_GITIGNORE_ENTRIES,
45
+ PROFILE_WORKTREE_INCLUDE_COMMENT,
46
+ PROFILE_WORKTREE_INCLUDE_ENTRIES,
45
47
  assertLiveFilesParse,
46
48
  backupRoot,
47
49
  backupStamp,
@@ -324,6 +326,13 @@ export async function activateProfile(
324
326
  remove: conversion.converted ? OBSOLETE_GITIGNORE_ENTRIES : [],
325
327
  },
326
328
  );
329
+ // Claude Code copies git-ignored files into a worktree it creates only when
330
+ // `.worktreeinclude` lists them — see PROFILE_WORKTREE_INCLUDE_ENTRIES.
331
+ await updateManagedGitignoreBlock(
332
+ path.join(projectPath, ".worktreeinclude"),
333
+ PROFILE_WORKTREE_INCLUDE_COMMENT,
334
+ { add: PROFILE_WORKTREE_INCLUDE_ENTRIES },
335
+ );
327
336
 
328
337
  const backupDir = path.join(backupRoot(projectPath), backupStamp());
329
338
  const displaced: DisplacedArtifact[] = [];
@@ -72,6 +72,32 @@ export const PROFILE_GITIGNORE_ENTRIES = [
72
72
  export const PROFILE_GITIGNORE_COMMENT =
73
73
  "# magus: generated profile config (source of truth: .claude/profiles.json)";
74
74
 
75
+ /**
76
+ * Paths magus lists in a project's `.worktreeinclude`. Claude Code copies a
77
+ * file into a worktree it creates (`claude -w`, EnterWorktree, agent
78
+ * isolation) only when that file is both git-ignored and listed there, at
79
+ * creation time. magus git-ignores the set-up state above, so without this
80
+ * list every new worktree gets `.claude/profiles.json` from the commit and none
81
+ * of the project's plugins.
82
+ *
83
+ * The marker FILE is listed, not `.claude/profiles/`: the directory would also
84
+ * copy a crash-orphaned temp file. It holds only hashes and ids, so a
85
+ * byte-for-byte copy reconciles as unchanged in the worktree. Backups are never
86
+ * listed — they are this checkout's hand-written files, possibly credentials,
87
+ * and nothing in a worktree uses them. Each magus-installed skill folder gets
88
+ * its own line (see `profile-skills.ts`).
89
+ */
90
+ export const PROFILE_WORKTREE_INCLUDE_ENTRIES = [
91
+ ".claude/settings.json",
92
+ ".mcp.json",
93
+ ".claude/models.json",
94
+ ".claude/profiles/active.json",
95
+ ];
96
+
97
+ /** The comment heading magus's lines in a project's `.worktreeinclude`. */
98
+ export const PROFILE_WORKTREE_INCLUDE_COMMENT =
99
+ "# magus: copied into new worktrees so they start with this project's plugins (source of truth: .claude/profiles.json)";
100
+
75
101
  /**
76
102
  * Lines older releases wrote into magus's managed `.gitignore` block that must
77
103
  * go when an older layout is converted: the blanket skills ignore hid committed
@@ -28,6 +28,7 @@ import type {
28
28
  import { updateManagedGitignoreBlock } from "./conventions-manager.js";
29
29
  import {
30
30
  PROFILE_GITIGNORE_COMMENT,
31
+ PROFILE_WORKTREE_INCLUDE_COMMENT,
31
32
  backupRoot,
32
33
  backupStamp,
33
34
  folderHash,
@@ -251,6 +252,9 @@ export async function inspectManagedSkills(
251
252
  * `.gitignore` block: `before` are the folders the marker listed, `after` the
252
253
  * ones it lists now. Lines are taken out of magus's block only — the same
253
254
  * pattern a person wrote elsewhere in the file stays.
255
+ *
256
+ * The same lines go into magus's `.worktreeinclude` block, so a worktree
257
+ * Claude Code creates gets the folders the copied marker records.
254
258
  */
255
259
  export async function updateSkillIgnoreLines(
256
260
  projectPath: string,
@@ -258,13 +262,19 @@ export async function updateSkillIgnoreLines(
258
262
  after: readonly string[],
259
263
  ): Promise<void> {
260
264
  const keep = new Set(after);
265
+ const edits = {
266
+ add: [...keep].map(skillIgnoreLine),
267
+ remove: before.filter((f) => !keep.has(f)).map(skillIgnoreLine),
268
+ };
261
269
  await updateManagedGitignoreBlock(
262
270
  path.join(projectPath, ".gitignore"),
263
271
  PROFILE_GITIGNORE_COMMENT,
264
- {
265
- add: [...keep].map(skillIgnoreLine),
266
- remove: before.filter((f) => !keep.has(f)).map(skillIgnoreLine),
267
- },
272
+ edits,
273
+ );
274
+ await updateManagedGitignoreBlock(
275
+ path.join(projectPath, ".worktreeinclude"),
276
+ PROFILE_WORKTREE_INCLUDE_COMMENT,
277
+ edits,
268
278
  );
269
279
  }
270
280
 
@@ -28,6 +28,7 @@
28
28
  import path from "node:path";
29
29
  import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
30
30
  import { selectProfileId } from "./active-closure.js";
31
+ import { updateManagedGitignoreBlock } from "./conventions-manager.js";
31
32
  import { readManifest, updateProfileEntry, writeManifest } from "./manifest.js";
32
33
  import {
33
34
  type ReconcileResult,
@@ -48,6 +49,8 @@ import {
48
49
  type ActiveMarker,
49
50
  LIVE_FILE_KEYS,
50
51
  MACHINE_LOCAL_SETTINGS_KEYS,
52
+ PROFILE_WORKTREE_INCLUDE_COMMENT,
53
+ PROFILE_WORKTREE_INCLUDE_ENTRIES,
51
54
  activeProfile,
52
55
  folderHash,
53
56
  hasCurrentMarker,
@@ -69,6 +72,7 @@ import {
69
72
  SkillClashError,
70
73
  inspectManagedSkills,
71
74
  skillFolder,
75
+ skillIgnoreLine,
72
76
  } from "./profile-skills.js";
73
77
  import { resolveExtends } from "./resolver.js";
74
78
  import type { SettingsParseError } from "./settings-file.js";
@@ -371,6 +375,20 @@ export async function prepareProfileState(
371
375
  out.lines.push(...activationNotes(result, { tracked: false }));
372
376
  out.activations.push(result);
373
377
  }
378
+ // A project set up by a release that never wrote `.worktreeinclude`
379
+ // re-activates rarely, so the gate lists the set-up state here. A no-op
380
+ // when every line is already there.
381
+ const marker = await readActiveMarker(projectPath);
382
+ await updateManagedGitignoreBlock(
383
+ path.join(projectPath, ".worktreeinclude"),
384
+ PROFILE_WORKTREE_INCLUDE_COMMENT,
385
+ {
386
+ add: [
387
+ ...PROFILE_WORKTREE_INCLUDE_ENTRIES,
388
+ ...Object.keys(marker?.skills ?? {}).map(skillIgnoreLine),
389
+ ],
390
+ },
391
+ );
374
392
  out.check = { state: "active", profile, restored: false };
375
393
  }
376
394
  return out;