magus-cli 7.8.0 → 7.9.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 (78) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +16 -11
  4. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  5. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  6. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  7. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  8. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  9. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  10. package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
  11. package/src/__tests__/blind-780-review2.test.ts +0 -25
  12. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  13. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  14. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  15. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  16. package/src/__tests__/deps-adapter.test.ts +191 -0
  17. package/src/__tests__/deps-check-hook.test.ts +220 -0
  18. package/src/__tests__/deps-plan.test.ts +896 -0
  19. package/src/__tests__/deps-report.test.ts +197 -0
  20. package/src/__tests__/doctor-bins.test.ts +134 -36
  21. package/src/__tests__/doctor-json.test.ts +261 -0
  22. package/src/__tests__/failure-popup-review2.test.tsx +1 -25
  23. package/src/__tests__/final-fixes-780.test.tsx +25 -9
  24. package/src/__tests__/fixtures/bins.ts +55 -0
  25. package/src/__tests__/install-plan.test.ts +15 -1
  26. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  27. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  28. package/src/__tests__/npm-package-name.test.ts +6 -9
  29. package/src/__tests__/plugin-requires.test.ts +138 -153
  30. package/src/__tests__/requires-probe.test.ts +858 -0
  31. package/src/__tests__/resolver.test.ts +129 -52
  32. package/src/__tests__/run.test.ts +108 -0
  33. package/src/__tests__/tool-probe.test.ts +1 -1
  34. package/src/__tests__/toolchain.test.ts +123 -52
  35. package/src/__tests__/update-plan.test.ts +1 -1
  36. package/src/cli/ansi.ts +2 -7
  37. package/src/cli/deps-view.ts +213 -0
  38. package/src/cli/doctor.ts +169 -24
  39. package/src/cli/install.ts +51 -66
  40. package/src/cli/profile.ts +20 -2
  41. package/src/cli/router.ts +6 -2
  42. package/src/cli/update.ts +28 -7
  43. package/src/data/cli-tools.ts +24 -9
  44. package/src/services/active-closure.ts +3 -3
  45. package/src/services/claude-settings.ts +4 -348
  46. package/src/services/cli-tool-commands.ts +104 -3
  47. package/src/services/deps-plan.ts +962 -0
  48. package/src/services/deps-report.ts +265 -0
  49. package/src/services/doctor-bins.ts +115 -43
  50. package/src/services/install-plan.ts +11 -1
  51. package/src/services/local-marketplace.ts +12 -206
  52. package/src/services/marketplace-refresh.ts +4 -4
  53. package/src/services/plugin-manager.ts +4 -18
  54. package/src/services/plugin-requires.ts +93 -162
  55. package/src/services/resolver.ts +168 -58
  56. package/src/services/tool-probe.ts +18 -25
  57. package/src/services/toolchain.ts +117 -111
  58. package/src/services/update-engine.ts +19 -14
  59. package/src/services/update-plan.ts +3 -3
  60. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  61. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  62. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  63. package/src/types/index.ts +53 -16
  64. package/src/ui/App.tsx +6 -40
  65. package/src/ui/adapters/cliToolsAdapter.ts +75 -37
  66. package/src/ui/adapters/depsAdapter.ts +129 -0
  67. package/src/ui/adapters/startupReports.ts +0 -22
  68. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  69. package/src/ui/screens/CliToolsScreen.tsx +141 -51
  70. package/src/ui/screens/PluginsScreen.tsx +12 -48
  71. package/src/utils/command-utils.ts +4 -5
  72. package/src/utils/run.ts +123 -52
  73. package/src/utils/string-utils.ts +9 -0
  74. package/src/__tests__/go-module.test.ts +0 -43
  75. package/src/__tests__/plugin-setup.test.ts +0 -1016
  76. package/src/__tests__/shell-script-callers.test.ts +0 -92
  77. package/src/services/plugin-setup.ts +0 -965
  78. package/src/utils/go-module.ts +0 -36
@@ -0,0 +1,220 @@
1
+ /**
2
+ * The SessionStart deps-check hook, run as Claude Code runs it: a child process
3
+ * with the hook input on stdin, `CLAUDE_PLUGIN_ROOT` set, and the label as argv.
4
+ * PATH, HOME and TMPDIR are temp dirs, so the result does not depend on what
5
+ * this machine has installed.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import {
10
+ chmodSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ readdirSync,
16
+ rmSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import {
22
+ type Requirement,
23
+ dedupeKey,
24
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
25
+
26
+ const HOOK = join(
27
+ import.meta.dir,
28
+ "..",
29
+ "shared",
30
+ "plugin",
31
+ "hooks",
32
+ "deps-check.ts",
33
+ );
34
+
35
+ const JQ: Requirement = {
36
+ name: "jq",
37
+ check: ["jq", "--version"],
38
+ install: [{ via: "apt", packages: ["jq"] }],
39
+ };
40
+
41
+ let root: string;
42
+ let bin: string;
43
+ let tmp: string;
44
+ beforeEach(() => {
45
+ root = mkdtempSync(join(tmpdir(), "deps-check-"));
46
+ bin = join(root, "bin");
47
+ tmp = join(root, "tmp");
48
+ mkdirSync(bin);
49
+ mkdirSync(tmp);
50
+ mkdirSync(join(root, "home"));
51
+ tool("apt-get");
52
+ });
53
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
54
+
55
+ function tool(name: string): void {
56
+ writeFileSync(join(bin, name), "#!/bin/sh\nexit 0\n");
57
+ chmodSync(join(bin, name), 0o755);
58
+ }
59
+
60
+ function plugin(label: string, requires: unknown): string {
61
+ const dir = join(root, "plugins", label);
62
+ mkdirSync(join(dir, ".claude-plugin"), { recursive: true });
63
+ writeFileSync(
64
+ join(dir, ".claude-plugin", "plugin.json"),
65
+ JSON.stringify({ name: label, requires }),
66
+ );
67
+ return dir;
68
+ }
69
+
70
+ function runHook(
71
+ label: string,
72
+ pluginRoot: string,
73
+ sessionId: string | null = "s-1",
74
+ extraInput: Record<string, unknown> = {},
75
+ ): { stdout: string; exitCode: number } {
76
+ const r = Bun.spawnSync(
77
+ [
78
+ process.execPath,
79
+ "--env-file=/dev/null",
80
+ "--config=/dev/null",
81
+ HOOK,
82
+ label,
83
+ ],
84
+ {
85
+ stdin: Buffer.from(
86
+ JSON.stringify({
87
+ ...(sessionId ? { session_id: sessionId } : {}),
88
+ ...extraInput,
89
+ }),
90
+ ),
91
+ env: {
92
+ PATH: bin,
93
+ HOME: join(root, "home"),
94
+ TMPDIR: tmp,
95
+ SHELL: "/bin/zsh",
96
+ CLAUDE_PLUGIN_ROOT: pluginRoot,
97
+ },
98
+ },
99
+ );
100
+ return { stdout: r.stdout.toString(), exitCode: r.exitCode };
101
+ }
102
+
103
+ const ledger = (sessionId = "s-1") => join(tmp, `magus-deps-${sessionId}`);
104
+
105
+ describe("deps-check hook", () => {
106
+ test("healthy: prints nothing, and the ledger proves it ran", () => {
107
+ tool("jq");
108
+ const dir = plugin("dev", { schema: 1, bin: [JQ] });
109
+ const r = runHook("dev", dir);
110
+ expect(r).toEqual({ stdout: "", exitCode: 0 });
111
+ expect(readFileSync(join(ledger(), "seen", "dev"), "utf8")).toBe("ts");
112
+ });
113
+
114
+ test("missing: one systemMessage with the marker, the dep and its fix", () => {
115
+ const dir = plugin("dev", { schema: 1, bin: [JQ] });
116
+ const r = runHook("dev", dir);
117
+ expect(r.exitCode).toBe(0);
118
+ const out = JSON.parse(r.stdout) as {
119
+ systemMessage: string;
120
+ hookSpecificOutput: { hookEventName: string; additionalContext: string };
121
+ };
122
+ expect(out.systemMessage.split("\n")[0]).toBe(
123
+ "magus-deps · dev: missing jq.",
124
+ );
125
+ expect(out.systemMessage).toContain(
126
+ "Fix all: bun add -g magus-cli, then magus doctor --fix",
127
+ );
128
+ expect(out.systemMessage).toContain(
129
+ "jq: sudo apt-get install -y --no-install-recommends jq",
130
+ );
131
+ expect(out.hookSpecificOutput.hookEventName).toBe("SessionStart");
132
+ expect(readdirSync(join(ledger(), "claimed"))).toEqual([dedupeKey(JQ)]);
133
+ });
134
+
135
+ test("magus on PATH: the fix names magus alone", () => {
136
+ tool("magus");
137
+ const dir = plugin("dev", { schema: 1, bin: [JQ] });
138
+ const out = JSON.parse(runHook("dev", dir).stdout) as {
139
+ systemMessage: string;
140
+ };
141
+ expect(out.systemMessage).toContain("Fix all: magus doctor --fix");
142
+ expect(out.systemMessage).not.toContain("magus-cli");
143
+ });
144
+
145
+ test("the fix runs doctor against the directory the session runs in", () => {
146
+ tool("magus");
147
+ const message = (input: Record<string, unknown>, session: string) =>
148
+ (
149
+ JSON.parse(
150
+ runHook(
151
+ "dev",
152
+ plugin("dev", { schema: 1, bin: [JQ] }),
153
+ session,
154
+ input,
155
+ ).stdout,
156
+ ) as { systemMessage: string }
157
+ ).systemMessage;
158
+ expect(message({ cwd: "/work/repo" }, "s-a")).toContain(
159
+ "Fix all: magus doctor --fix --project /work/repo\n",
160
+ );
161
+ // Quoted for the shell it is pasted into.
162
+ expect(message({ cwd: "/work/my repo" }, "s-b")).toContain(
163
+ "Fix all: magus doctor --fix --project '/work/my repo'\n",
164
+ );
165
+ // Not a directory a terminal can name: the fix is the bare command.
166
+ for (const [cwd, session] of [
167
+ ["relative/dir", "s-c"],
168
+ ["/work/a\nb", "s-d"],
169
+ ] as const) {
170
+ expect(message({ cwd }, session)).toContain(
171
+ "Fix all: magus doctor --fix\n",
172
+ );
173
+ }
174
+ });
175
+
176
+ test("one identical declaration in two plugins is reported once per session", () => {
177
+ const first = runHook("dev", plugin("dev", { schema: 1, bin: [JQ] }));
178
+ const second = runHook(
179
+ "code-search",
180
+ plugin("code-search", { schema: 1, bin: [JQ] }),
181
+ );
182
+ expect(first.stdout).toContain("magus-deps · dev: missing jq");
183
+ expect(second.stdout).toBe("");
184
+ expect(readdirSync(join(ledger(), "seen")).sort()).toEqual([
185
+ "code-search",
186
+ "dev",
187
+ ]);
188
+ });
189
+
190
+ test("no readable session id: no ledger, and the banner still prints", () => {
191
+ const dir = plugin("dev", { schema: 1, bin: [JQ] });
192
+ const r = runHook("dev", dir, null);
193
+ expect(r.stdout).toContain("magus-deps · dev: missing jq");
194
+ expect(existsSync(tmp) && readdirSync(tmp)).toEqual([]);
195
+ });
196
+
197
+ test("an invalid declaration is reported, not swallowed", () => {
198
+ const dir = plugin("dev", { schema: 1, bin: [{ name: "jq" }] });
199
+ const out = JSON.parse(runHook("dev", dir).stdout) as {
200
+ systemMessage: string;
201
+ };
202
+ expect(out.systemMessage).toStartWith(
203
+ "magus-deps · dev: requires is invalid (",
204
+ );
205
+ expect(out.systemMessage).toEndWith("— please report");
206
+ });
207
+
208
+ test("a ledger older than a day is pruned; a fresh one is kept", () => {
209
+ const old = join(tmp, "magus-deps-old");
210
+ const fresh = join(tmp, "magus-deps-fresh");
211
+ mkdirSync(old);
212
+ mkdirSync(fresh);
213
+ const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
214
+ require("node:fs").utimesSync(old, twoDaysAgo, twoDaysAgo);
215
+ tool("jq");
216
+ runHook("dev", plugin("dev", { schema: 1, bin: [JQ] }));
217
+ expect(existsSync(old)).toBe(false);
218
+ expect(existsSync(fresh)).toBe(true);
219
+ });
220
+ });