magus-cli 7.9.0 → 7.10.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 (29) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/plugin-deps/doctor.test.ts +1 -0
  3. package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
  4. package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
  5. package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
  6. package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
  7. package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
  8. package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
  9. package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
  10. package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
  11. package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
  12. package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
  13. package/src/__tests__/deps-plan.test.ts +3 -2
  14. package/src/__tests__/plugin-mcp-source-shapes.test.ts +250 -0
  15. package/src/__tests__/worktree-profiles.test.ts +583 -0
  16. package/src/cli/doctor.ts +24 -2
  17. package/src/cli/update-view.ts +73 -0
  18. package/src/cli/update.ts +21 -0
  19. package/src/cli/worktrees.ts +32 -0
  20. package/src/services/deps-plan.ts +273 -81
  21. package/src/services/git-worktree.ts +41 -0
  22. package/src/services/plugin-mcp-config.ts +3 -1
  23. package/src/services/worktree-profiles.ts +181 -0
  24. package/src/shared/plugin/hooks/bun-missing.sh +35 -10
  25. package/src/shared/plugin/hooks/deps-check.ts +25 -9
  26. package/src/shared/plugin/hooks/lib/requires-probe.ts +101 -5
  27. package/src/ui/App.tsx +32 -1
  28. package/src/ui/adapters/startupReports.ts +22 -2
  29. package/src/utils/run.ts +21 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.9.0",
3
+ "version": "7.10.0",
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.9.0",
69
- "magus-cli-darwin-x64": "7.9.0",
70
- "magus-cli-linux-x64": "7.9.0"
68
+ "magus-cli-darwin-arm64": "7.10.0",
69
+ "magus-cli-darwin-x64": "7.10.0",
70
+ "magus-cli-linux-x64": "7.10.0"
71
71
  }
72
72
  }
@@ -174,6 +174,7 @@ describe("magus doctor black-box contract", () => {
174
174
  "plugins_needing_update",
175
175
  "schema",
176
176
  "unfixable",
177
+ "worktreesWithoutProfile",
177
178
  ]);
178
179
  expect(new Set(report.plugins)).toEqual(
179
180
  new Set(["user-tool@test", "project-tool@test", "local-tool@test"]),
@@ -0,0 +1,57 @@
1
+ import { expect, test } from "bun:test";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ hookCommand,
6
+ makeHookPlugin,
7
+ makeShellPath,
8
+ makeWorld,
9
+ pinnedEnv,
10
+ removeWorld,
11
+ runChild,
12
+ sessionInput,
13
+ writeExecutable,
14
+ } from "../plugin-deps/helpers";
15
+
16
+ test("a Bun under BUN_INSTALL is reported as installed but absent from Claude Code's PATH", async () => {
17
+ const world = await makeWorld();
18
+ try {
19
+ const plugin = await makeHookPlugin(world, "bun-install", {
20
+ bin: [],
21
+ schema: 1,
22
+ });
23
+ const shellPath = await makeShellPath(world);
24
+ const bunInstall = join(world.root, "bun-install");
25
+ const bunBin = join(bunInstall, "bin");
26
+ await writeExecutable(join(bunBin, "bun"), "exit 0");
27
+
28
+ const result = await runChild(
29
+ join(shellPath, "sh"),
30
+ ["-c", hookCommand("bun-install")],
31
+ {
32
+ cwd: world.project,
33
+ env: pinnedEnv(world, [shellPath], {
34
+ BUN_INSTALL: bunInstall,
35
+ CLAUDE_PLUGIN_ROOT: plugin,
36
+ SHELL: join(shellPath, "sh"),
37
+ }),
38
+ input: sessionInput("bun-install-session"),
39
+ timeoutMs: 9_500,
40
+ },
41
+ );
42
+ const message = JSON.parse(result.stdout).systemMessage as string;
43
+
44
+ expect({
45
+ describesInstalledBun:
46
+ message.includes("Bun is installed") && message.includes(bunBin),
47
+ explainsOffPath: message.includes(
48
+ "not on the PATH Claude Code started with",
49
+ ),
50
+ }).toEqual({
51
+ describesInstalledBun: true,
52
+ explainsOffPath: true,
53
+ });
54
+ } finally {
55
+ await removeWorld(world);
56
+ }
57
+ });
@@ -0,0 +1,94 @@
1
+ import { expect, test } from "bun:test";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ type ApplyIO,
7
+ type ApplyOptions,
8
+ DepsLockBusy,
9
+ type DepsPlan,
10
+ applyDepsPlan,
11
+ } from "../../../services/deps-plan.js";
12
+ import type { RunResult } from "../../../utils/run.js";
13
+ import { makeWorld, removeWorld } from "../plugin-deps/helpers.js";
14
+
15
+ function successfulRun(): Promise<RunResult> {
16
+ return Promise.resolve({
17
+ durationMs: 0,
18
+ exitCode: 0,
19
+ outputTail: "",
20
+ signal: null,
21
+ timedOut: false,
22
+ });
23
+ }
24
+
25
+ function plan(): DepsPlan {
26
+ return {
27
+ pathAdvice: [],
28
+ steps: [
29
+ {
30
+ installs: [
31
+ {
32
+ pluginRoot: "",
33
+ requirement: {
34
+ check: ["widget", "--version"],
35
+ install: [],
36
+ name: "widget",
37
+ },
38
+ },
39
+ ],
40
+ needs: [],
41
+ requiredBy: ["testdata@test"],
42
+ step: {
43
+ argv: ["installer", "widget"],
44
+ escalation: "none",
45
+ kind: "argv",
46
+ name: "widget",
47
+ },
48
+ },
49
+ ],
50
+ unfixable: [],
51
+ };
52
+ }
53
+
54
+ function options(
55
+ world: Awaited<ReturnType<typeof makeWorld>>,
56
+ lockPath: string,
57
+ ): ApplyOptions {
58
+ const io: Partial<ApplyIO> = {
59
+ alive: () => false,
60
+ run: successfulRun,
61
+ uid: () => 1000,
62
+ verify: async ({ requirement }) => ({
63
+ name: requirement.name,
64
+ status: "present",
65
+ }),
66
+ };
67
+ return {
68
+ confirm: async () => true,
69
+ env: {
70
+ arch: "amd64",
71
+ home: world.home,
72
+ os: "linux",
73
+ path: world.bin,
74
+ userShell: join(world.bin, "zsh"),
75
+ },
76
+ io,
77
+ lockPath,
78
+ yes: true,
79
+ };
80
+ }
81
+
82
+ test("an empty lock file means another apply is starting", async () => {
83
+ const world = await makeWorld();
84
+ try {
85
+ const lockPath = join(world.cache, "doctor-fix.lock");
86
+ await writeFile(lockPath, "");
87
+
88
+ await expect(
89
+ applyDepsPlan(plan(), options(world, lockPath)),
90
+ ).rejects.toBeInstanceOf(DepsLockBusy);
91
+ } finally {
92
+ await removeWorld(world);
93
+ }
94
+ });
@@ -0,0 +1,90 @@
1
+ import { expect, test } from "bun:test";
2
+ import { rmSync } from "node:fs";
3
+ import { writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ type ApplyOptions,
8
+ type DepsPlan,
9
+ applyDepsPlan,
10
+ } from "../../../services/deps-plan.js";
11
+ import type { RunResult } from "../../../utils/run.js";
12
+ import { makeWorld, removeWorld } from "../plugin-deps/helpers.js";
13
+
14
+ const plan: DepsPlan = {
15
+ pathAdvice: [],
16
+ steps: [
17
+ {
18
+ installs: [
19
+ {
20
+ pluginRoot: "",
21
+ requirement: {
22
+ check: ["widget", "--version"],
23
+ install: [],
24
+ name: "widget",
25
+ },
26
+ },
27
+ ],
28
+ needs: [],
29
+ requiredBy: ["testdata@test"],
30
+ step: {
31
+ argv: ["installer", "widget"],
32
+ escalation: "none",
33
+ kind: "argv",
34
+ name: "widget",
35
+ },
36
+ },
37
+ ],
38
+ unfixable: [],
39
+ };
40
+
41
+ function successfulRun(): Promise<RunResult> {
42
+ return Promise.resolve({
43
+ durationMs: 0,
44
+ exitCode: 0,
45
+ outputTail: "",
46
+ signal: null,
47
+ timedOut: false,
48
+ });
49
+ }
50
+
51
+ test("a lock that disappears during inspection is retried", async () => {
52
+ const world = await makeWorld();
53
+ try {
54
+ const lockPath = join(world.cache, "doctor-fix.lock");
55
+ await writeFile(lockPath, "4242");
56
+ let removed = false;
57
+ const options: ApplyOptions = {
58
+ confirm: async () => true,
59
+ env: {
60
+ arch: "amd64",
61
+ home: world.home,
62
+ os: "linux",
63
+ path: world.bin,
64
+ userShell: join(world.bin, "zsh"),
65
+ },
66
+ io: {
67
+ alive: () => {
68
+ if (!removed) {
69
+ removed = true;
70
+ rmSync(lockPath);
71
+ }
72
+ return false;
73
+ },
74
+ run: successfulRun,
75
+ uid: () => 1000,
76
+ verify: async ({ requirement }) => ({
77
+ name: requirement.name,
78
+ status: "present",
79
+ }),
80
+ },
81
+ lockPath,
82
+ yes: true,
83
+ };
84
+
85
+ const outcomes = await applyDepsPlan(plan, options);
86
+ expect(outcomes).toMatchObject([{ name: "widget", status: "installed" }]);
87
+ } finally {
88
+ await removeWorld(world);
89
+ }
90
+ });
@@ -0,0 +1,83 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir, utimes, writeFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+
7
+ import {
8
+ type ApplyOptions,
9
+ type DepsPlan,
10
+ applyDepsPlan,
11
+ } from "../../../services/deps-plan.js";
12
+ import { makeWorld, removeWorld } from "../plugin-deps/helpers.js";
13
+
14
+ test("an apply removes abandoned release directories from the user bin directory", async () => {
15
+ const world = await makeWorld();
16
+ try {
17
+ const destination = join(world.home, ".local", "bin");
18
+ const abandoned = join(destination, ".magus-tmp-abandoned");
19
+ await mkdir(abandoned, { recursive: true });
20
+ await writeFile(join(abandoned, "partial-download"), "interrupted");
21
+ const old = new Date("2020-01-01T00:00:00.000Z");
22
+ await utimes(join(abandoned, "partial-download"), old, old);
23
+ await utimes(abandoned, old, old);
24
+
25
+ const body = new TextEncoder().encode("widget binary\n");
26
+ const digest = createHash("sha256").update(body).digest("hex");
27
+ const plan: DepsPlan = {
28
+ pathAdvice: [],
29
+ steps: [
30
+ {
31
+ installs: [
32
+ {
33
+ pluginRoot: "",
34
+ requirement: {
35
+ check: ["widget", "--version"],
36
+ install: [],
37
+ name: "widget",
38
+ },
39
+ },
40
+ ],
41
+ needs: [],
42
+ requiredBy: ["testdata@test"],
43
+ step: {
44
+ binary: "widget",
45
+ dest: "~/.local/bin",
46
+ format: "binary",
47
+ kind: "release",
48
+ name: "widget",
49
+ sha256: digest,
50
+ url: "https://example.test/widget",
51
+ },
52
+ },
53
+ ],
54
+ unfixable: [],
55
+ };
56
+ const options: ApplyOptions = {
57
+ confirm: async () => true,
58
+ env: {
59
+ arch: "amd64",
60
+ home: world.home,
61
+ os: "linux",
62
+ path: world.bin,
63
+ userShell: join(world.bin, "zsh"),
64
+ },
65
+ io: {
66
+ alive: () => false,
67
+ download: async () => body,
68
+ uid: () => 1000,
69
+ verify: async ({ requirement }) => ({
70
+ name: requirement.name,
71
+ status: "present",
72
+ }),
73
+ },
74
+ lockPath: join(world.cache, "doctor-fix.lock"),
75
+ yes: true,
76
+ };
77
+
78
+ await applyDepsPlan(plan, options);
79
+ expect(existsSync(abandoned)).toBe(false);
80
+ } finally {
81
+ await removeWorld(world);
82
+ }
83
+ });
@@ -0,0 +1,152 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ type ApplyIO,
7
+ type ApplyOptions,
8
+ type DepsPlan,
9
+ applyDepsPlan,
10
+ } from "../../../services/deps-plan.js";
11
+ import {
12
+ makeWorld,
13
+ removeWorld,
14
+ writeExecutable,
15
+ } from "../plugin-deps/helpers.js";
16
+
17
+ function options(
18
+ world: Awaited<ReturnType<typeof makeWorld>>,
19
+ lockName: string,
20
+ verify: ApplyIO["verify"],
21
+ ): ApplyOptions {
22
+ return {
23
+ confirm: async () => true,
24
+ env: {
25
+ arch: "amd64",
26
+ home: world.home,
27
+ os: "linux",
28
+ path: world.bin,
29
+ userShell: join(world.bin, "zsh"),
30
+ },
31
+ io: {
32
+ alive: () => false,
33
+ uid: () => 1000,
34
+ verify,
35
+ },
36
+ lockPath: join(world.cache, lockName),
37
+ yes: true,
38
+ };
39
+ }
40
+
41
+ test("tool answers use stdout without treating stderr warnings as paths", async () => {
42
+ const world = await makeWorld();
43
+ try {
44
+ await writeExecutable(
45
+ join(world.bin, "uv"),
46
+ [
47
+ 'if [ "$1" = "python" ] && [ "$2" = "find" ]; then',
48
+ ' printf "%s\\n" "${0%/*}/python-env"',
49
+ " i=0",
50
+ ' while [ "$i" -lt 100000 ]; do i=$((i + 1)); done',
51
+ ' printf "uv warning: cache metadata is stale\\n" >&2',
52
+ "fi",
53
+ "exit 0",
54
+ ].join("\n"),
55
+ );
56
+ await writeExecutable(join(world.bin, "python-env"), "exit 0");
57
+ const uvPlan: DepsPlan = {
58
+ pathAdvice: [],
59
+ steps: [
60
+ {
61
+ installs: [
62
+ {
63
+ pluginRoot: world.root,
64
+ requirement: {
65
+ check: ["chromium", "--version"],
66
+ install: [],
67
+ name: "chromium",
68
+ },
69
+ },
70
+ ],
71
+ needs: [],
72
+ requiredBy: ["browser-use@test"],
73
+ step: {
74
+ args: ["install", "chromium"],
75
+ escalation: "none",
76
+ kind: "uv-module",
77
+ module: "playwright",
78
+ name: "chromium",
79
+ script: join(world.root, "scripts", "browser.py"),
80
+ },
81
+ },
82
+ ],
83
+ unfixable: [],
84
+ };
85
+ const [uvOutcome] = await applyDepsPlan(
86
+ uvPlan,
87
+ options(world, "uv.lock", async ({ requirement }) => ({
88
+ name: requirement.name,
89
+ status: "present",
90
+ })),
91
+ );
92
+
93
+ const globalBin = join(world.bin, "bun-global");
94
+ await mkdir(globalBin, { recursive: true });
95
+ await writeExecutable(join(globalBin, "widget"), "exit 0");
96
+ await writeExecutable(
97
+ join(world.bin, "bun"),
98
+ [
99
+ 'if [ "$1" = "pm" ] && [ "$2" = "bin" ]; then',
100
+ ' printf "%s\\n" "${0%/*}/bun-global"',
101
+ " i=0",
102
+ ' while [ "$i" -lt 100000 ]; do i=$((i + 1)); done',
103
+ ' printf "bun warning: registry metadata is stale\\n" >&2',
104
+ "fi",
105
+ "exit 0",
106
+ ].join("\n"),
107
+ );
108
+ const bunPlan: DepsPlan = {
109
+ pathAdvice: [],
110
+ steps: [
111
+ {
112
+ installs: [
113
+ {
114
+ pluginRoot: "",
115
+ requirement: {
116
+ check: ["widget", "--version"],
117
+ install: [],
118
+ name: "widget",
119
+ },
120
+ },
121
+ ],
122
+ needs: [],
123
+ requiredBy: ["testdata@test"],
124
+ step: {
125
+ argv: ["bun", "add", "-g", "widget"],
126
+ escalation: "none",
127
+ kind: "argv",
128
+ name: "widget",
129
+ },
130
+ },
131
+ ],
132
+ unfixable: [],
133
+ };
134
+ const [bunOutcome] = await applyDepsPlan(
135
+ bunPlan,
136
+ options(world, "bun.lock", async ({ requirement }) => ({
137
+ name: requirement.name,
138
+ status: "missing",
139
+ })),
140
+ );
141
+
142
+ expect({
143
+ bunGlobalBin: bunOutcome?.dir,
144
+ uvInterpreter: uvOutcome?.status,
145
+ }).toEqual({
146
+ bunGlobalBin: globalBin,
147
+ uvInterpreter: "installed",
148
+ });
149
+ } finally {
150
+ await removeWorld(world);
151
+ }
152
+ });
@@ -0,0 +1,74 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ import { REAL_APPLY_IO } from "../../../services/deps-plan.js";
4
+
5
+ const HTTPS_URL = "https://example.test/install.sh";
6
+ const HTTP_URL = "http://mirror.example.test/install.sh";
7
+
8
+ function responseAt(
9
+ url: string,
10
+ body: BodyInit | null,
11
+ init: ResponseInit,
12
+ redirected = false,
13
+ ): Response {
14
+ const response = new Response(body, init);
15
+ Object.defineProperty(response, "url", { value: url });
16
+ if (redirected) {
17
+ Object.defineProperty(response, "redirected", { value: true });
18
+ }
19
+ return response;
20
+ }
21
+
22
+ test("an installer script redirected to plain HTTP is rejected", async () => {
23
+ const realFetch = globalThis.fetch;
24
+ const requestedUrls: string[] = [];
25
+ const stubFetch: typeof fetch = async (input, init) => {
26
+ const url =
27
+ typeof input === "string"
28
+ ? input
29
+ : input instanceof URL
30
+ ? input.href
31
+ : input.url;
32
+ const redirect =
33
+ init?.redirect ?? (input instanceof Request ? input.redirect : "follow");
34
+ requestedUrls.push(url);
35
+
36
+ if (url === HTTPS_URL) {
37
+ if (redirect === "manual") {
38
+ return responseAt(HTTPS_URL, null, {
39
+ headers: { Location: HTTP_URL },
40
+ status: 302,
41
+ });
42
+ }
43
+ return stubFetch(HTTP_URL, { ...init, redirect: "follow" }).then(
44
+ (response) => {
45
+ Object.defineProperty(response, "redirected", { value: true });
46
+ return response;
47
+ },
48
+ );
49
+ }
50
+ if (url === HTTP_URL) {
51
+ return responseAt(HTTP_URL, "#!/bin/sh\n", { status: 200 });
52
+ }
53
+ throw new Error(`unexpected URL: ${url}`);
54
+ };
55
+
56
+ globalThis.fetch = stubFetch;
57
+ let rejection: unknown;
58
+ try {
59
+ await REAL_APPLY_IO.download(HTTPS_URL);
60
+ } catch (error) {
61
+ rejection = error;
62
+ } finally {
63
+ globalThis.fetch = realFetch;
64
+ }
65
+
66
+ expect({
67
+ errorNamesHttp:
68
+ rejection instanceof Error && rejection.message.includes(HTTP_URL),
69
+ requestedUrls,
70
+ }).toEqual({
71
+ errorNamesHttp: true,
72
+ requestedUrls: [HTTPS_URL],
73
+ });
74
+ });
@@ -0,0 +1,78 @@
1
+ import { expect, test } from "bun:test";
2
+ import { stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ type World,
7
+ hookCommand,
8
+ linkExecutable,
9
+ makeHookPlugin,
10
+ makeShellPath,
11
+ makeWorld,
12
+ pinnedEnv,
13
+ removeWorld,
14
+ runChild,
15
+ sessionInput,
16
+ } from "../plugin-deps/helpers";
17
+
18
+ async function runHookWithPinnedUmask(
19
+ world: World,
20
+ shellPath: string,
21
+ pluginRoot: string,
22
+ label: string,
23
+ pathDirs: string[],
24
+ sessionId: string,
25
+ ): Promise<void> {
26
+ await runChild(
27
+ join(shellPath, "sh"),
28
+ ["-c", `umask 022; ${hookCommand(label)}`],
29
+ {
30
+ cwd: world.project,
31
+ env: pinnedEnv(world, pathDirs, {
32
+ BUN_INSTALL: join(world.home, ".bun"),
33
+ CLAUDE_PLUGIN_ROOT: pluginRoot,
34
+ SHELL: join(shellPath, "sh"),
35
+ }),
36
+ input: sessionInput(sessionId),
37
+ timeoutMs: 9_500,
38
+ },
39
+ );
40
+ }
41
+
42
+ test("the TypeScript hook and shell fallback create ledger directories private to the user", async () => {
43
+ const world = await makeWorld();
44
+ try {
45
+ const plugin = await makeHookPlugin(world, "private-ledger", {
46
+ schema: 1,
47
+ bin: [],
48
+ });
49
+ const shellPath = await makeShellPath(world);
50
+ await linkExecutable(world.bin, "bun", process.execPath);
51
+ await runHookWithPinnedUmask(
52
+ world,
53
+ shellPath,
54
+ plugin,
55
+ "typescript",
56
+ [world.bin, shellPath],
57
+ "typescript-ledger",
58
+ );
59
+ await runHookWithPinnedUmask(
60
+ world,
61
+ shellPath,
62
+ plugin,
63
+ "fallback",
64
+ [shellPath],
65
+ "fallback-ledger",
66
+ );
67
+
68
+ const modes = await Promise.all(
69
+ ["typescript-ledger", "fallback-ledger"].map(
70
+ async (sessionId) =>
71
+ (await stat(join(world.tmp, `magus-deps-${sessionId}`))).mode & 0o777,
72
+ ),
73
+ );
74
+ expect(modes).toEqual([0o700, 0o700]);
75
+ } finally {
76
+ await removeWorld(world);
77
+ }
78
+ });