magus-cli 7.10.4 → 7.11.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 (38) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/plugin-deps/doctor.test.ts +2 -2
  3. package/src/__tests__/blind/plugin-deps/hook.test.ts +2 -2
  4. package/src/__tests__/blind/plugin-deps/read-only.test.ts +1 -1
  5. package/src/__tests__/blind/plugin-deps/schema.test.ts +6 -6
  6. package/src/__tests__/blind/requires-min-version/TEST-PLAN.md +54 -0
  7. package/src/__tests__/blind/requires-min-version/manifests.test.ts +68 -0
  8. package/src/__tests__/blind/requires-min-version/parse.test.ts +266 -0
  9. package/src/__tests__/blind/requires-min-version/probe.test.ts +359 -0
  10. package/src/__tests__/blind/requires-min-version/render.test.ts +252 -0
  11. package/src/__tests__/blind/requires-min-version/support.ts +83 -0
  12. package/src/__tests__/blind/requires-min-version/surfaces.test.ts +231 -0
  13. package/src/__tests__/blind/review-followups/bun-install-path.test.ts +1 -1
  14. package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +1 -1
  15. package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +1 -1
  16. package/src/__tests__/blind/review-followups/path-advice.test.ts +1 -1
  17. package/src/__tests__/deps-check-hook.test.ts +9 -9
  18. package/src/__tests__/deps-plan.test.ts +1 -1
  19. package/src/__tests__/deps-report.test.ts +5 -5
  20. package/src/__tests__/doctor-json.test.ts +2 -2
  21. package/src/__tests__/marketplace-refresh.test.ts +1 -1
  22. package/src/__tests__/plugin-requires.test.ts +2 -2
  23. package/src/__tests__/profile-worktree-include.test.ts +212 -0
  24. package/src/__tests__/requires-probe.test.ts +2 -2
  25. package/src/cli/deps-view.ts +5 -3
  26. package/src/services/deps-plan.ts +38 -14
  27. package/src/services/deps-report.ts +24 -7
  28. package/src/services/doctor-bins.ts +40 -12
  29. package/src/services/profile-activate.ts +9 -0
  30. package/src/services/profile-live.ts +26 -0
  31. package/src/services/profile-skills.ts +14 -4
  32. package/src/services/profile-state.ts +18 -0
  33. package/src/services/resolver.ts +1 -0
  34. package/src/shared/plugin/hooks/deps-check.ts +7 -3
  35. package/src/shared/plugin/hooks/lib/requires-probe.ts +257 -33
  36. package/src/ui/adapters/cliToolsAdapter.ts +6 -1
  37. package/src/ui/renderers/cliToolRenderers.tsx +7 -1
  38. package/src/ui/screens/CliToolsScreen.tsx +8 -1
@@ -0,0 +1,231 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ type Raw,
7
+ type World,
8
+ configureClaude,
9
+ linkExecutable,
10
+ makeHookPlugin,
11
+ makePlugin,
12
+ makeWorld,
13
+ removeWorld,
14
+ requirement,
15
+ requires,
16
+ runDoctor,
17
+ runHook,
18
+ sessionInput,
19
+ writeExecutable,
20
+ } from "./support";
21
+
22
+ // Fake bun: answers --version, does nothing for `add` (so an "upgrade" leaves
23
+ // the old binary in place). Never the real bun.
24
+ async function fakeBun(world: World): Promise<void> {
25
+ await writeExecutable(
26
+ join(world.bin, "bun"),
27
+ 'if [ "$1" = "--version" ]; then echo 1.4.0; exit 0; fi\nexit 0',
28
+ );
29
+ }
30
+
31
+ function bunAndWidget(minVersion: string): Raw {
32
+ return requires([
33
+ requirement("bun", [{ tool: "bun", via: "bootstrap" }]),
34
+ requirement("widget", [{ package: "widget", via: "bun" }], { minVersion }),
35
+ ]);
36
+ }
37
+
38
+ async function installPlugin(
39
+ world: World,
40
+ name: string,
41
+ value: Raw,
42
+ ): Promise<string> {
43
+ const root = await makePlugin(world, name, value);
44
+ await configureClaude(world, [{ key: `${name}@test`, root, scope: "user" }]);
45
+ return root;
46
+ }
47
+
48
+ function parseReport<T>(result: {
49
+ stdout: string;
50
+ stderr: string;
51
+ exitCode: number | null;
52
+ }): T {
53
+ try {
54
+ return JSON.parse(result.stdout) as T;
55
+ } catch {
56
+ throw new Error(
57
+ `doctor printed no JSON (exit ${result.exitCode}); stderr: ${result.stderr.slice(0, 400)}`,
58
+ );
59
+ }
60
+ }
61
+
62
+ interface Dep {
63
+ name: string;
64
+ status: string;
65
+ found?: string;
66
+ minVersion?: string;
67
+ fix?: { text: string };
68
+ }
69
+
70
+ describe("magus doctor with an outdated dependency", () => {
71
+ // REQ-27: DEPS_REPORT_SCHEMA 2; status "outdated"; found/minVersion only then.
72
+ test("REQ-27 --json reports schema 2 and an outdated dep with found and floor", async () => {
73
+ const world = await makeWorld();
74
+ try {
75
+ await fakeBun(world);
76
+ await writeExecutable(join(world.bin, "widget"), "echo widget 1.2.0");
77
+ await installPlugin(world, "testdata", bunAndWidget("2.0.0"));
78
+ const result = await runDoctor(world, ["--json"]);
79
+ expect(result.timedOut).toBe(false);
80
+ const report = parseReport<{ schema: number; deps: Dep[] }>(result);
81
+ expect(report.schema).toBe(2);
82
+ const widget = report.deps.find((d) => d.name === "widget");
83
+ expect(widget).toMatchObject({
84
+ found: "1.2.0",
85
+ minVersion: "2.0.0",
86
+ status: "outdated",
87
+ });
88
+ expect(widget?.fix?.text).toBe("bun add -g widget@latest");
89
+ const bun = report.deps.find((d) => d.name === "bun");
90
+ expect(bun?.status).toBe("present");
91
+ expect(bun).not.toHaveProperty("found");
92
+ expect(bun).not.toHaveProperty("minVersion");
93
+ } finally {
94
+ await removeWorld(world);
95
+ }
96
+ });
97
+
98
+ // REQ-28: "magus doctor text: Outdated rows are failures and count toward the exit code."
99
+ test("REQ-28 text doctor exits non-zero for an outdated dep, zero once satisfied", async () => {
100
+ const world = await makeWorld();
101
+ try {
102
+ await fakeBun(world);
103
+ await writeExecutable(join(world.bin, "widget"), "echo widget 1.2.0");
104
+ await installPlugin(world, "testdata", bunAndWidget("2.0.0"));
105
+ const outdated = await runDoctor(world, []);
106
+ expect(outdated.timedOut).toBe(false);
107
+ expect(outdated.exitCode).not.toBe(0);
108
+ expect(outdated.stdout + outdated.stderr).toContain("1.2.0");
109
+
110
+ await writeExecutable(join(world.bin, "widget"), "echo widget 2.0.0");
111
+ const healthy = await runDoctor(world, []);
112
+ expect(healthy.exitCode).toBe(0);
113
+ } finally {
114
+ await removeWorld(world);
115
+ }
116
+ });
117
+
118
+ // REQ-29: after install, still outdated → failed; remedy names found, floor, onPath.
119
+ test("REQ-29 --fix that leaves the old binary first on PATH reports failed with a remedy", async () => {
120
+ const world = await makeWorld();
121
+ try {
122
+ await fakeBun(world);
123
+ await writeExecutable(join(world.bin, "widget"), "echo widget 1.2.0");
124
+ await installPlugin(world, "testdata", bunAndWidget("2.0.0"));
125
+ const result = await runDoctor(world, ["--fix", "--yes", "--json"]);
126
+ expect(result.timedOut).toBe(false);
127
+ expect(result.exitCode).not.toBe(0);
128
+ const report = parseReport<{
129
+ applied?: Array<{
130
+ name: string;
131
+ status: string;
132
+ remedy?: string;
133
+ argv?: string[];
134
+ }>;
135
+ }>(result);
136
+ expect(report.applied).toBeDefined();
137
+ const applied = (report.applied ?? []).find((a) => a.name === "widget");
138
+ expect(applied?.status).toBe("failed");
139
+ // The executor may resolve argv[0] to an absolute path ("exactly what
140
+ // ran"); the spec fixes the rendered step, not how bun is located.
141
+ expect(applied?.argv?.[0]?.split("/").pop()).toBe("bun");
142
+ expect(applied?.argv?.slice(1)).toEqual(["add", "-g", "widget@latest"]);
143
+ expect(applied?.remedy).toContain("1.2.0");
144
+ expect(applied?.remedy).toContain("2.0.0");
145
+ expect(applied?.remedy).toContain(join(world.bin, "widget"));
146
+ } finally {
147
+ await removeWorld(world);
148
+ }
149
+ });
150
+ });
151
+
152
+ describe("SessionStart hook with an outdated dependency", () => {
153
+ // REQ-30: "An outdated dep is claimed and reported once per session, like a missing one."
154
+ test("REQ-30 reports an outdated dep once per session", async () => {
155
+ const world = await makeWorld();
156
+ try {
157
+ const plugin = await makeHookPlugin(
158
+ world,
159
+ "outdated",
160
+ requires([
161
+ requirement("widget", [{ formula: "widget", via: "brew" }], {
162
+ minVersion: "2.0.0",
163
+ }),
164
+ ]),
165
+ );
166
+ // The hook itself needs a bun to execute TypeScript, as in the schema-1 suite.
167
+ await linkExecutable(world.bin, "bun", process.execPath);
168
+ await writeExecutable(join(world.bin, "brew"), "exit 0");
169
+ await writeExecutable(join(world.bin, "widget"), "echo widget 1.5.0");
170
+ const input = sessionInput("outdated-session");
171
+ const first = await runHook(
172
+ world,
173
+ plugin,
174
+ "outdated",
175
+ [world.bin],
176
+ input,
177
+ );
178
+ expect(first.timedOut).toBe(false);
179
+ expect(first.exitCode).toBe(0);
180
+ const banner = JSON.parse(first.stdout) as { systemMessage: string };
181
+ expect(banner.systemMessage).toContain("magus-deps");
182
+ expect(banner.systemMessage).toContain(
183
+ "outdated widget 1.5.0 (needs 2.0.0)",
184
+ );
185
+ const second = await runHook(
186
+ world,
187
+ plugin,
188
+ "outdated",
189
+ [world.bin],
190
+ input,
191
+ );
192
+ expect(second.exitCode).toBe(0);
193
+ expect(second.stdout).toBe("");
194
+ expect(
195
+ await readdir(
196
+ join(world.tmp, "magus-deps-outdated-session", "claimed"),
197
+ ),
198
+ ).toHaveLength(1);
199
+ } finally {
200
+ await removeWorld(world);
201
+ }
202
+ });
203
+
204
+ test("REQ-30 a satisfied floor keeps the hook silent", async () => {
205
+ const world = await makeWorld();
206
+ try {
207
+ const plugin = await makeHookPlugin(
208
+ world,
209
+ "fresh",
210
+ requires([
211
+ requirement("widget", [{ formula: "widget", via: "brew" }], {
212
+ minVersion: "2.0.0",
213
+ }),
214
+ ]),
215
+ );
216
+ await linkExecutable(world.bin, "bun", process.execPath);
217
+ await writeExecutable(join(world.bin, "widget"), "echo widget 2.3.0");
218
+ const result = await runHook(
219
+ world,
220
+ plugin,
221
+ "fresh",
222
+ [world.bin],
223
+ sessionInput("fresh-session"),
224
+ );
225
+ expect(result.exitCode).toBe(0);
226
+ expect(result.stdout).toBe("");
227
+ } finally {
228
+ await removeWorld(world);
229
+ }
230
+ });
231
+ });
@@ -18,7 +18,7 @@ test("a Bun under BUN_INSTALL is reported as installed but absent from Claude Co
18
18
  try {
19
19
  const plugin = await makeHookPlugin(world, "bun-install", {
20
20
  bin: [],
21
- schema: 1,
21
+ schema: 2,
22
22
  });
23
23
  const shellPath = await makeShellPath(world);
24
24
  const bunInstall = join(world.root, "bun-install");
@@ -43,7 +43,7 @@ test("the TypeScript hook and shell fallback create ledger directories private t
43
43
  const world = await makeWorld();
44
44
  try {
45
45
  const plugin = await makeHookPlugin(world, "private-ledger", {
46
- schema: 1,
46
+ schema: 2,
47
47
  bin: [],
48
48
  });
49
49
  const shellPath = await makeShellPath(world);
@@ -26,7 +26,7 @@ test("each stale ledger is pruned even when another stale ledger cannot be remov
26
26
  let blocked = "";
27
27
  try {
28
28
  const plugin = await makeHookPlugin(world, "ledger-pruning", {
29
- schema: 1,
29
+ schema: 2,
30
30
  bin: [],
31
31
  });
32
32
  const shellPath = await makeShellPath(world);
@@ -17,7 +17,7 @@ test("doctor does not advise adding a PATH line already present in the shell rc
17
17
  const rcLine = 'export PATH="$HOME/.bun/bin:$PATH"';
18
18
  await writeFile(join(world.home, ".zshrc"), `${rcLine}\n`);
19
19
  const plugin = await makePlugin(world, "path-advice", {
20
- schema: 1,
20
+ schema: 2,
21
21
  bin: [
22
22
  {
23
23
  name: "bun",
@@ -105,14 +105,14 @@ const ledger = (sessionId = "s-1") => join(tmp, `magus-deps-${sessionId}`);
105
105
  describe("deps-check hook", () => {
106
106
  test("healthy: prints nothing, and the ledger proves it ran", () => {
107
107
  tool("jq");
108
- const dir = plugin("dev", { schema: 1, bin: [JQ] });
108
+ const dir = plugin("dev", { schema: 2, bin: [JQ] });
109
109
  const r = runHook("dev", dir);
110
110
  expect(r).toEqual({ stdout: "", exitCode: 0 });
111
111
  expect(readFileSync(join(ledger(), "seen", "dev"), "utf8")).toBe("ts");
112
112
  });
113
113
 
114
114
  test("missing: one systemMessage with the marker, the dep and its fix", () => {
115
- const dir = plugin("dev", { schema: 1, bin: [JQ] });
115
+ const dir = plugin("dev", { schema: 2, bin: [JQ] });
116
116
  const r = runHook("dev", dir);
117
117
  expect(r.exitCode).toBe(0);
118
118
  const out = JSON.parse(r.stdout) as {
@@ -134,7 +134,7 @@ describe("deps-check hook", () => {
134
134
 
135
135
  test("magus on PATH: the fix names magus alone", () => {
136
136
  tool("magus");
137
- const dir = plugin("dev", { schema: 1, bin: [JQ] });
137
+ const dir = plugin("dev", { schema: 2, bin: [JQ] });
138
138
  const out = JSON.parse(runHook("dev", dir).stdout) as {
139
139
  systemMessage: string;
140
140
  };
@@ -149,7 +149,7 @@ describe("deps-check hook", () => {
149
149
  JSON.parse(
150
150
  runHook(
151
151
  "dev",
152
- plugin("dev", { schema: 1, bin: [JQ] }),
152
+ plugin("dev", { schema: 2, bin: [JQ] }),
153
153
  session,
154
154
  input,
155
155
  ).stdout,
@@ -174,10 +174,10 @@ describe("deps-check hook", () => {
174
174
  });
175
175
 
176
176
  test("one identical declaration in two plugins is reported once per session", () => {
177
- const first = runHook("dev", plugin("dev", { schema: 1, bin: [JQ] }));
177
+ const first = runHook("dev", plugin("dev", { schema: 2, bin: [JQ] }));
178
178
  const second = runHook(
179
179
  "code-search",
180
- plugin("code-search", { schema: 1, bin: [JQ] }),
180
+ plugin("code-search", { schema: 2, bin: [JQ] }),
181
181
  );
182
182
  expect(first.stdout).toContain("magus-deps · dev: missing jq");
183
183
  expect(second.stdout).toBe("");
@@ -188,14 +188,14 @@ describe("deps-check hook", () => {
188
188
  });
189
189
 
190
190
  test("no readable session id: no ledger, and the banner still prints", () => {
191
- const dir = plugin("dev", { schema: 1, bin: [JQ] });
191
+ const dir = plugin("dev", { schema: 2, bin: [JQ] });
192
192
  const r = runHook("dev", dir, null);
193
193
  expect(r.stdout).toContain("magus-deps · dev: missing jq");
194
194
  expect(existsSync(tmp) && readdirSync(tmp)).toEqual([]);
195
195
  });
196
196
 
197
197
  test("an invalid declaration is reported, not swallowed", () => {
198
- const dir = plugin("dev", { schema: 1, bin: [{ name: "jq" }] });
198
+ const dir = plugin("dev", { schema: 2, bin: [{ name: "jq" }] });
199
199
  const out = JSON.parse(runHook("dev", dir).stdout) as {
200
200
  systemMessage: string;
201
201
  };
@@ -213,7 +213,7 @@ describe("deps-check hook", () => {
213
213
  const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
214
214
  require("node:fs").utimesSync(old, twoDaysAgo, twoDaysAgo);
215
215
  tool("jq");
216
- runHook("dev", plugin("dev", { schema: 1, bin: [JQ] }));
216
+ runHook("dev", plugin("dev", { schema: 2, bin: [JQ] }));
217
217
  expect(existsSync(old)).toBe(false);
218
218
  expect(existsSync(fresh)).toBe(true);
219
219
  });
@@ -262,7 +262,7 @@ describe("planDeps", () => {
262
262
  for (const via of ["npm", "pip", "go"]) {
263
263
  const parsed = parseRequires({
264
264
  requires: {
265
- schema: 1,
265
+ schema: 2,
266
266
  bin: [{ name: "x", check: ["x"], install: [{ via, package: "x" }] }],
267
267
  },
268
268
  });
@@ -66,7 +66,7 @@ const TMUX_MCP = {
66
66
  ],
67
67
  };
68
68
 
69
- const manifest = (bin: unknown[], schema = 1) => ({
69
+ const manifest = (bin: unknown[], schema = 2) => ({
70
70
  requires: { schema, bin },
71
71
  });
72
72
 
@@ -117,9 +117,9 @@ describe("collectDeps", () => {
117
117
  const collected = await collectDeps(
118
118
  root,
119
119
  sources({
120
- "new@x": manifest([BUN], 2),
121
- "old@x": manifest([BUN], 0),
122
- "bad@x": { requires: { schema: 1, bin: [{ name: "x" }] } },
120
+ "new@x": manifest([BUN], 3),
121
+ "old@x": manifest([BUN], 1),
122
+ "bad@x": { requires: { schema: 2, bin: [{ name: "x" }] } },
123
123
  }),
124
124
  );
125
125
  expect(collected.problems.map((p) => [p.plugin, p.kind])).toEqual([
@@ -132,7 +132,7 @@ describe("collectDeps", () => {
132
132
  });
133
133
 
134
134
  describe("depsReportJson", () => {
135
- test("names tmux-mcp missing with its release install, in the schema-1 shape", async () => {
135
+ test("names tmux-mcp missing with its release install, in the schema-2 shape", async () => {
136
136
  const state = await assessDeps(
137
137
  root,
138
138
  env,
@@ -36,7 +36,7 @@ beforeEach(() => {
36
36
  JSON.stringify({
37
37
  name: "fake",
38
38
  requires: {
39
- schema: 1,
39
+ schema: 2,
40
40
  bin: [
41
41
  {
42
42
  name: "faketool",
@@ -117,7 +117,7 @@ describe("magus doctor --json", () => {
117
117
  const r = doctor("--json");
118
118
  expect(r.code).toBe(1);
119
119
  const doc = JSON.parse(r.out);
120
- expect(doc.schema).toBe(1);
120
+ expect(doc.schema).toBe(2);
121
121
  expect(doc.plugins).toEqual(["fake@magus"]);
122
122
  expect(doc.deps).toEqual([
123
123
  {
@@ -60,7 +60,7 @@ function writePluginJson(dir: string, version: string, pin: string) {
60
60
  JSON.stringify({
61
61
  version,
62
62
  requires: {
63
- schema: 1,
63
+ schema: 2,
64
64
  bin: [{ name: "x", install: [{ via: "release", tag: pin }] }],
65
65
  },
66
66
  }),
@@ -23,7 +23,7 @@ const BUN: Requirement = {
23
23
  };
24
24
  const declares = (bin: Requirement[]) => ({
25
25
  name: "p",
26
- requires: { schema: 1, bin },
26
+ requires: { schema: 2, bin },
27
27
  });
28
28
 
29
29
  let dir: string;
@@ -60,7 +60,7 @@ describe("readPluginRequires", () => {
60
60
  test("an invalid declaration is reported, never dropped to empty", async () => {
61
61
  await fs.outputJson(join(dir, ".claude-plugin", "plugin.json"), {
62
62
  name: "p",
63
- requires: { schema: 1, bin: [{ name: "x", via: "brew" }] },
63
+ requires: { schema: 2, bin: [{ name: "x", via: "brew" }] },
64
64
  });
65
65
  const r = await readPluginRequires(dir);
66
66
  expect(r?.ok).toBe(false);
@@ -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
+ });
@@ -156,10 +156,10 @@ describe("parseRequires", () => {
156
156
  });
157
157
 
158
158
  test("schema: newer asks for a newer magus-cli, older for a newer plugin", () => {
159
- const newer = parseRequires({ requires: { schema: 2, bin: [] } });
159
+ const newer = parseRequires({ requires: { schema: 3, bin: [] } });
160
160
  expect(newer.ok ? null : newer.kind).toBe("newer-schema");
161
161
  expect(newer.ok ? "" : newer.errors[0]).toContain("update magus-cli");
162
- const older = parseRequires({ requires: { schema: 0, bin: [] } });
162
+ const older = parseRequires({ requires: { schema: 1, bin: [] } });
163
163
  expect(older.ok ? null : older.kind).toBe("older-schema");
164
164
  expect(older.ok ? "" : older.errors[0]).toContain("update the plugin");
165
165
  const none = parseRequires({ requires: { bin: [] } });