magus-cli 7.7.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.
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +45 -26
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1178 -0
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +896 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +896 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +740 -0
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/format-update-outcome.test.ts +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +24 -18
- package/src/__tests__/useStaleLoadModal.test.ts +1 -57
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +320 -54
- package/src/cli/install.ts +55 -71
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +55 -12
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +88 -384
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +962 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +35 -37
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -2
- package/src/services/resolver.ts +263 -59
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +345 -45
- package/src/services/update-plan.ts +296 -36
- package/src/shared/plugin/hooks/bun-missing.sh +39 -0
- package/src/shared/plugin/hooks/deps-check.ts +224 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
- package/src/types/index.ts +60 -16
- package/src/ui/App.tsx +81 -171
- package/src/ui/adapters/cliToolsAdapter.ts +252 -12
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- package/src/ui/adapters/startupReports.ts +260 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -12
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -45
- package/src/ui/screens/CliToolsScreen.tsx +199 -98
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +315 -259
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +123 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/plugin-setup.ts +0 -965
- package/src/services/version-snapshot.ts +0 -189
- package/src/utils/go-module.ts +0 -36
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doctor's dependency report: which plugins count (what Claude Code loads
|
|
3
|
+
* here, with or without profiles.json), how declarations merge across
|
|
4
|
+
* plugins, and the `--json` document's shape. PATH and HOME are temp dirs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
chmodSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import type { PlanEnv } from "../services/deps-plan.js";
|
|
18
|
+
import {
|
|
19
|
+
type CollectSources,
|
|
20
|
+
DEPS_REPORT_SCHEMA,
|
|
21
|
+
assessDeps,
|
|
22
|
+
collectDeps,
|
|
23
|
+
depsReportJson,
|
|
24
|
+
} from "../services/deps-report.js";
|
|
25
|
+
import type { ParseResult } from "../shared/plugin/hooks/lib/requires-probe.js";
|
|
26
|
+
import { parseRequires } from "../shared/plugin/hooks/lib/requires-probe.js";
|
|
27
|
+
|
|
28
|
+
let root: string;
|
|
29
|
+
let env: PlanEnv;
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
root = mkdtempSync(join(tmpdir(), "deps-report-"));
|
|
32
|
+
mkdirSync(join(root, "bin"));
|
|
33
|
+
mkdirSync(join(root, "home"));
|
|
34
|
+
env = {
|
|
35
|
+
os: "linux",
|
|
36
|
+
arch: "amd64",
|
|
37
|
+
home: join(root, "home"),
|
|
38
|
+
path: join(root, "bin"),
|
|
39
|
+
userShell: "/bin/bash",
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
43
|
+
|
|
44
|
+
const JQ = {
|
|
45
|
+
name: "jq",
|
|
46
|
+
check: ["jq", "--version"],
|
|
47
|
+
install: [{ via: "apt", packages: ["jq"] }],
|
|
48
|
+
};
|
|
49
|
+
const BUN = {
|
|
50
|
+
name: "bun",
|
|
51
|
+
check: ["bun", "--version"],
|
|
52
|
+
install: [{ via: "bootstrap", tool: "bun" }],
|
|
53
|
+
};
|
|
54
|
+
const TMUX_MCP = {
|
|
55
|
+
name: "tmux-mcp",
|
|
56
|
+
check: ["tmux-mcp", "--version"],
|
|
57
|
+
install: [
|
|
58
|
+
{
|
|
59
|
+
via: "release",
|
|
60
|
+
repo: "MadAppGang/tmux-mcp",
|
|
61
|
+
tag: "v2.0.0",
|
|
62
|
+
asset: "tmux-mcp_{os}_{arch}.tar.gz",
|
|
63
|
+
binary: "tmux-mcp",
|
|
64
|
+
sha256: { linux_amd64: "a".repeat(64) },
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const manifest = (bin: unknown[], schema = 1) => ({
|
|
70
|
+
requires: { schema, bin },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** Plugins as Claude Code would load them, with no profiles.json anywhere. */
|
|
74
|
+
function sources(declared: Record<string, unknown>): CollectSources {
|
|
75
|
+
return {
|
|
76
|
+
enabledPlugins: async () => Object.keys(declared),
|
|
77
|
+
pluginRequires: async (id) => {
|
|
78
|
+
const m = declared[id];
|
|
79
|
+
if (m === undefined) return null;
|
|
80
|
+
return { dir: join(root, id), parse: parseRequires(m) as ParseResult };
|
|
81
|
+
},
|
|
82
|
+
profileCliTools: async () => [],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("collectDeps", () => {
|
|
87
|
+
test("reads every enabled plugin's declaration, with no profiles.json", async () => {
|
|
88
|
+
const collected = await collectDeps(
|
|
89
|
+
root,
|
|
90
|
+
sources({
|
|
91
|
+
"dev@magus": manifest([BUN, JQ]),
|
|
92
|
+
"code-search@magus": manifest([BUN, JQ]),
|
|
93
|
+
"terminal@magus": manifest([BUN, TMUX_MCP]),
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
expect(collected.plugins).toEqual([
|
|
97
|
+
"dev@magus",
|
|
98
|
+
"code-search@magus",
|
|
99
|
+
"terminal@magus",
|
|
100
|
+
]);
|
|
101
|
+
// Identical declarations merge: one bun, one jq, every plugin named.
|
|
102
|
+
expect(collected.bins.map((b) => [b.name, b.sources])).toEqual([
|
|
103
|
+
[
|
|
104
|
+
"bun",
|
|
105
|
+
[
|
|
106
|
+
"plugin:dev@magus",
|
|
107
|
+
"plugin:code-search@magus",
|
|
108
|
+
"plugin:terminal@magus",
|
|
109
|
+
],
|
|
110
|
+
],
|
|
111
|
+
["jq", ["plugin:dev@magus", "plugin:code-search@magus"]],
|
|
112
|
+
["tmux-mcp", ["plugin:terminal@magus"]],
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a declaration this magus cannot read is reported per plugin, by kind", async () => {
|
|
117
|
+
const collected = await collectDeps(
|
|
118
|
+
root,
|
|
119
|
+
sources({
|
|
120
|
+
"new@x": manifest([BUN], 2),
|
|
121
|
+
"old@x": manifest([BUN], 0),
|
|
122
|
+
"bad@x": { requires: { schema: 1, bin: [{ name: "x" }] } },
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
expect(collected.problems.map((p) => [p.plugin, p.kind])).toEqual([
|
|
126
|
+
["new@x", "newer-schema"],
|
|
127
|
+
["old@x", "older-schema"],
|
|
128
|
+
["bad@x", "invalid"],
|
|
129
|
+
]);
|
|
130
|
+
expect(collected.bins).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("depsReportJson", () => {
|
|
135
|
+
test("names tmux-mcp missing with its release install, in the schema-1 shape", async () => {
|
|
136
|
+
const state = await assessDeps(
|
|
137
|
+
root,
|
|
138
|
+
env,
|
|
139
|
+
sources({ "terminal@magus": manifest([TMUX_MCP]) }),
|
|
140
|
+
);
|
|
141
|
+
const json = depsReportJson(state, env);
|
|
142
|
+
expect(json).toEqual({
|
|
143
|
+
schema: DEPS_REPORT_SCHEMA,
|
|
144
|
+
platform: { os: "linux", arch: "amd64" },
|
|
145
|
+
magusOnPath: false,
|
|
146
|
+
plugins: ["terminal@magus"],
|
|
147
|
+
deps: [
|
|
148
|
+
{
|
|
149
|
+
name: "tmux-mcp",
|
|
150
|
+
key: expect.stringMatching(/^tmux-mcp-[0-9a-f]{12}$/),
|
|
151
|
+
status: "missing",
|
|
152
|
+
requiredBy: ["terminal@magus"],
|
|
153
|
+
fix: {
|
|
154
|
+
via: "release",
|
|
155
|
+
escalation: "none",
|
|
156
|
+
text: expect.stringContaining(
|
|
157
|
+
"curl -fsSLO https://github.com/MadAppGang/tmux-mcp/releases/download/v2.0.0/tmux-mcp_linux_amd64.tar.gz",
|
|
158
|
+
),
|
|
159
|
+
prefix: "~/.local/bin",
|
|
160
|
+
prefixOnPath: false,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
plugins_needing_update: [],
|
|
165
|
+
invalid: [],
|
|
166
|
+
unfixable: [],
|
|
167
|
+
pathAdvice: [
|
|
168
|
+
{
|
|
169
|
+
prefix: "~/.local/bin",
|
|
170
|
+
rcFile: "~/.bashrc",
|
|
171
|
+
rcLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
// One document, round-trippable.
|
|
176
|
+
expect(JSON.parse(JSON.stringify(json))).toEqual(json);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("present is reported present, with no fix", async () => {
|
|
180
|
+
const tm = join(root, "bin", "tmux-mcp");
|
|
181
|
+
writeFileSync(tm, "#!/bin/sh\nexit 0\n");
|
|
182
|
+
chmodSync(tm, 0o755);
|
|
183
|
+
const state = await assessDeps(
|
|
184
|
+
root,
|
|
185
|
+
env,
|
|
186
|
+
sources({ "terminal@magus": manifest([TMUX_MCP]) }),
|
|
187
|
+
);
|
|
188
|
+
const [dep] = depsReportJson(state, env).deps;
|
|
189
|
+
expect(dep).toEqual({
|
|
190
|
+
name: "tmux-mcp",
|
|
191
|
+
key: expect.any(String),
|
|
192
|
+
status: "present",
|
|
193
|
+
requiredBy: ["terminal@magus"],
|
|
194
|
+
});
|
|
195
|
+
expect(state.plan.steps).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -1,43 +1,141 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Presence for doctor, install and profile switch goes through requires-probe's
|
|
3
|
+
* `probe` — the same function the SessionStart hook runs. PATH and HOME are
|
|
4
|
+
* pinned to temp dirs, so nothing on this machine decides an answer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
chmodSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import type { PlanEnv } from "../services/deps-plan.js";
|
|
18
|
+
import {
|
|
19
|
+
checkBinaries,
|
|
20
|
+
missingBinaries,
|
|
21
|
+
probeBins,
|
|
22
|
+
} from "../services/doctor-bins.js";
|
|
3
23
|
import type { ResolvedBin } from "../types/index.js";
|
|
4
24
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
let root: string;
|
|
26
|
+
let bin: string;
|
|
27
|
+
let env: PlanEnv;
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
root = mkdtempSync(join(tmpdir(), "doctor-bins-"));
|
|
30
|
+
bin = join(root, "bin");
|
|
31
|
+
mkdirSync(bin);
|
|
32
|
+
mkdirSync(join(root, "home"));
|
|
33
|
+
env = {
|
|
34
|
+
os: "linux",
|
|
35
|
+
arch: "amd64",
|
|
36
|
+
home: join(root, "home"),
|
|
37
|
+
path: bin,
|
|
38
|
+
userShell: "/bin/zsh",
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
42
|
+
|
|
43
|
+
function tool(dir: string, name: string, body = "exit 0"): void {
|
|
44
|
+
mkdirSync(dir, { recursive: true });
|
|
45
|
+
writeFileSync(join(dir, name), `#!/bin/sh\n${body}\n`);
|
|
46
|
+
chmodSync(join(dir, name), 0o755);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tmux: ResolvedBin = {
|
|
50
|
+
name: "tmux",
|
|
51
|
+
check: ["tmux", "-V"],
|
|
52
|
+
install: [{ via: "brew", formula: "tmux" }],
|
|
53
|
+
chosen: { via: "brew", formula: "tmux" },
|
|
54
|
+
sources: ["plugin:terminal@magus"],
|
|
55
|
+
};
|
|
56
|
+
const claudish: ResolvedBin = {
|
|
57
|
+
name: "claudish",
|
|
58
|
+
check: ["claudish", "--version"],
|
|
59
|
+
install: [{ via: "bun", package: "claudish", version: "1.2.0" }],
|
|
60
|
+
chosen: { via: "bun", package: "claudish", version: "1.2.0" },
|
|
61
|
+
sources: ["profile:x.cliTools"],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe("probeBins", () => {
|
|
65
|
+
test("answers each bin in order, with the hook's own probe", async () => {
|
|
66
|
+
tool(bin, "claudish");
|
|
67
|
+
const presences = await probeBins([tmux, claudish], {
|
|
68
|
+
env,
|
|
69
|
+
depth: "full",
|
|
70
|
+
capMs: 2_000,
|
|
71
|
+
});
|
|
72
|
+
expect(presences).toEqual([
|
|
73
|
+
{ name: "tmux", status: "missing" },
|
|
74
|
+
{ name: "claudish", status: "present" },
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a check reading the plugin root runs with ITS plugin's directory", async () => {
|
|
79
|
+
const pluginA = join(root, "a");
|
|
80
|
+
const pluginB = join(root, "b");
|
|
81
|
+
tool(pluginA, "ok.sh");
|
|
82
|
+
mkdirSync(pluginB);
|
|
83
|
+
const rooted = (dir: string): ResolvedBin => ({
|
|
84
|
+
name: "env",
|
|
85
|
+
check: ["${CLAUDE_PLUGIN_ROOT}/ok.sh"],
|
|
86
|
+
install: [{ via: "bun", package: "x" }],
|
|
87
|
+
chosen: { via: "bun", package: "x" },
|
|
88
|
+
root: dir,
|
|
89
|
+
sources: [],
|
|
90
|
+
});
|
|
91
|
+
// Same name twice: probed in separate passes, each answered for itself.
|
|
92
|
+
const presences = await probeBins([rooted(pluginA), rooted(pluginB)], {
|
|
93
|
+
env,
|
|
94
|
+
depth: "full",
|
|
95
|
+
capMs: 2_000,
|
|
96
|
+
});
|
|
97
|
+
expect(presences.map((p) => p.status)).toEqual(["present", "missing"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a dangling symlink reads as missing", async () => {
|
|
101
|
+
const { symlinkSync } = await import("node:fs");
|
|
102
|
+
symlinkSync(join(root, "gone"), join(bin, "tmux"));
|
|
103
|
+
const [p] = await probeBins([tmux], {
|
|
104
|
+
env,
|
|
105
|
+
depth: "presence",
|
|
106
|
+
capMs: 2_000,
|
|
107
|
+
});
|
|
108
|
+
expect(p?.status).toBe("missing");
|
|
34
109
|
});
|
|
35
110
|
});
|
|
36
111
|
|
|
37
|
-
describe("missingBinaries", () => {
|
|
38
|
-
test("
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
112
|
+
describe("checkBinaries / missingBinaries", () => {
|
|
113
|
+
test("pairs each answer with the paste-able install line", async () => {
|
|
114
|
+
tool(join(env.home, ".bun", "bin"), "claudish");
|
|
115
|
+
const results = await checkBinaries([tmux, claudish], {
|
|
116
|
+
env,
|
|
117
|
+
depth: "full",
|
|
118
|
+
capMs: 2_000,
|
|
119
|
+
});
|
|
120
|
+
expect(
|
|
121
|
+
results.map((r) => [r.name, r.presence.status, r.installCommand]),
|
|
122
|
+
).toEqual([
|
|
123
|
+
["tmux", "missing", "brew install tmux"],
|
|
124
|
+
["claudish", "off-path", "bun add -g claudish@1.2.0"],
|
|
125
|
+
]);
|
|
126
|
+
expect(missingBinaries(results).map((r) => r.name)).toEqual([
|
|
127
|
+
"tmux",
|
|
128
|
+
"claudish",
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("present is not actionable", async () => {
|
|
133
|
+
tool(bin, "tmux");
|
|
134
|
+
const results = await checkBinaries([tmux], {
|
|
135
|
+
env,
|
|
136
|
+
depth: "full",
|
|
137
|
+
capMs: 2_000,
|
|
138
|
+
});
|
|
139
|
+
expect(missingBinaries(results)).toEqual([]);
|
|
42
140
|
});
|
|
43
141
|
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `magus doctor --json`, end to end, as a process: a temp HOME where Claude
|
|
3
|
+
* Code has one plugin enabled and installed (no profiles.json anywhere), a
|
|
4
|
+
* PATH pinned to a temp dir, and a fake `brew` that talks on both stdout and
|
|
5
|
+
* stderr while it installs. stdout must stay exactly one JSON document.
|
|
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
|
+
rmSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
const MAIN = join(import.meta.dir, "..", "main.tsx");
|
|
22
|
+
|
|
23
|
+
let home: string;
|
|
24
|
+
let tools: string;
|
|
25
|
+
let project: string;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
home = mkdtempSync(join(tmpdir(), "doctor-json-"));
|
|
28
|
+
tools = join(home, "tools");
|
|
29
|
+
project = join(home, "project");
|
|
30
|
+
mkdirSync(tools);
|
|
31
|
+
mkdirSync(project);
|
|
32
|
+
const plugin = join(home, "plugin");
|
|
33
|
+
mkdirSync(join(plugin, ".claude-plugin"), { recursive: true });
|
|
34
|
+
writeFileSync(
|
|
35
|
+
join(plugin, ".claude-plugin", "plugin.json"),
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
name: "fake",
|
|
38
|
+
requires: {
|
|
39
|
+
schema: 1,
|
|
40
|
+
bin: [
|
|
41
|
+
{
|
|
42
|
+
name: "faketool",
|
|
43
|
+
check: ["faketool", "--version"],
|
|
44
|
+
install: [{ via: "brew", formula: "faketool" }],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
mkdirSync(join(home, ".claude", "plugins"), { recursive: true });
|
|
51
|
+
writeFileSync(
|
|
52
|
+
join(home, ".claude", "settings.json"),
|
|
53
|
+
JSON.stringify({ enabledPlugins: { "fake@magus": true } }),
|
|
54
|
+
);
|
|
55
|
+
const now = new Date().toISOString();
|
|
56
|
+
writeFileSync(
|
|
57
|
+
join(home, ".claude", "plugins", "installed_plugins.json"),
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
version: 2,
|
|
60
|
+
plugins: {
|
|
61
|
+
"fake@magus": [
|
|
62
|
+
{
|
|
63
|
+
scope: "user",
|
|
64
|
+
installPath: plugin,
|
|
65
|
+
version: "1.0.0",
|
|
66
|
+
installedAt: now,
|
|
67
|
+
lastUpdated: now,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
// A brew that is noisy on both streams, then puts the tool on PATH.
|
|
74
|
+
const brew = join(tools, "brew");
|
|
75
|
+
writeFileSync(
|
|
76
|
+
brew,
|
|
77
|
+
[
|
|
78
|
+
"#!/bin/sh",
|
|
79
|
+
'echo "==> Downloading $2"',
|
|
80
|
+
'echo "Warning: noisy installer" >&2',
|
|
81
|
+
`printf '#!/bin/sh\\necho 1.0.0\\n' > "${tools}/faketool"`,
|
|
82
|
+
`chmod 755 "${tools}/faketool"`,
|
|
83
|
+
].join("\n"),
|
|
84
|
+
);
|
|
85
|
+
chmodSync(brew, 0o755);
|
|
86
|
+
});
|
|
87
|
+
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
88
|
+
|
|
89
|
+
function doctor(...args: string[]) {
|
|
90
|
+
return doctorIn(project, ...args);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function doctorIn(cwd: string, ...args: string[]) {
|
|
94
|
+
const r = Bun.spawnSync(
|
|
95
|
+
[process.execPath, "--no-env-file", MAIN, "doctor", ...args],
|
|
96
|
+
{
|
|
97
|
+
cwd,
|
|
98
|
+
env: {
|
|
99
|
+
HOME: home,
|
|
100
|
+
PATH: `${tools}:/usr/bin:/bin`,
|
|
101
|
+
SHELL: "/bin/zsh",
|
|
102
|
+
XDG_CACHE_HOME: join(home, ".cache"),
|
|
103
|
+
NO_COLOR: "1",
|
|
104
|
+
},
|
|
105
|
+
stdin: "ignore",
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
return {
|
|
109
|
+
code: r.exitCode,
|
|
110
|
+
out: r.stdout.toString(),
|
|
111
|
+
err: r.stderr.toString(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe("magus doctor --json", () => {
|
|
116
|
+
test("with no profiles.json, names the enabled plugin's missing dep and its fix", () => {
|
|
117
|
+
const r = doctor("--json");
|
|
118
|
+
expect(r.code).toBe(1);
|
|
119
|
+
const doc = JSON.parse(r.out);
|
|
120
|
+
expect(doc.schema).toBe(1);
|
|
121
|
+
expect(doc.plugins).toEqual(["fake@magus"]);
|
|
122
|
+
expect(doc.deps).toEqual([
|
|
123
|
+
{
|
|
124
|
+
name: "faketool",
|
|
125
|
+
key: expect.any(String),
|
|
126
|
+
status: "missing",
|
|
127
|
+
requiredBy: ["fake@magus"],
|
|
128
|
+
fix: { via: "brew", escalation: "none", text: "brew install faketool" },
|
|
129
|
+
},
|
|
130
|
+
]);
|
|
131
|
+
expect(doc.applied).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("--fix --yes --json: the installer talks on both streams; stdout is still one document", () => {
|
|
135
|
+
const r = doctor("--fix", "--yes", "--json");
|
|
136
|
+
const doc = JSON.parse(r.out); // throws if anything else reached stdout
|
|
137
|
+
expect(doc.applied).toEqual([
|
|
138
|
+
expect.objectContaining({
|
|
139
|
+
name: "faketool",
|
|
140
|
+
status: "installed",
|
|
141
|
+
argv: [join(tools, "brew"), "install", "faketool"],
|
|
142
|
+
exitCode: 0,
|
|
143
|
+
outputTail: expect.stringContaining("noisy installer"),
|
|
144
|
+
}),
|
|
145
|
+
]);
|
|
146
|
+
expect(doc.deps[0].status).toBe("present");
|
|
147
|
+
expect(r.code).toBe(0);
|
|
148
|
+
expect(existsSync(join(tools, "faketool"))).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("reading never writes Claude Code's files: a clone missing a plugin's directory stays byte-identical", () => {
|
|
152
|
+
// A clone of the magus marketplace whose manifest lists a plugin whose
|
|
153
|
+
// source directory is not on disk, and that plugin enabled but not
|
|
154
|
+
// installed, so doctor reads the clone to find its declaration.
|
|
155
|
+
const clone = join(home, ".claude", "plugins", "marketplaces", "magus");
|
|
156
|
+
mkdirSync(join(clone, ".claude-plugin"), { recursive: true });
|
|
157
|
+
mkdirSync(join(clone, "plugins", "fake", ".claude-plugin"), {
|
|
158
|
+
recursive: true,
|
|
159
|
+
});
|
|
160
|
+
writeFileSync(
|
|
161
|
+
join(clone, "plugins", "fake", ".claude-plugin", "plugin.json"),
|
|
162
|
+
JSON.stringify({ name: "fake" }),
|
|
163
|
+
);
|
|
164
|
+
writeFileSync(
|
|
165
|
+
join(clone, ".claude-plugin", "marketplace.json"),
|
|
166
|
+
`${JSON.stringify(
|
|
167
|
+
{
|
|
168
|
+
name: "magus",
|
|
169
|
+
plugins: [
|
|
170
|
+
{ name: "fake", source: "./plugins/fake", version: "1.0.0" },
|
|
171
|
+
{ name: "gone", source: "./plugins/gone", version: "1.0.0" },
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
null,
|
|
175
|
+
4,
|
|
176
|
+
)}\n`,
|
|
177
|
+
);
|
|
178
|
+
writeFileSync(
|
|
179
|
+
join(home, ".claude", "plugins", "known_marketplaces.json"),
|
|
180
|
+
JSON.stringify({
|
|
181
|
+
magus: {
|
|
182
|
+
source: { source: "github", repo: "MadAppGang/magus" },
|
|
183
|
+
installLocation: clone,
|
|
184
|
+
lastUpdated: new Date().toISOString(),
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
writeFileSync(
|
|
189
|
+
join(home, ".claude", "settings.json"),
|
|
190
|
+
JSON.stringify({
|
|
191
|
+
enabledPlugins: { "fake@magus": true, "gone@magus": true },
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
const owned = [
|
|
195
|
+
join(clone, ".claude-plugin", "marketplace.json"),
|
|
196
|
+
join(clone, "plugins", "fake", ".claude-plugin", "plugin.json"),
|
|
197
|
+
join(home, ".claude", "plugins", "known_marketplaces.json"),
|
|
198
|
+
join(home, ".claude", "plugins", "installed_plugins.json"),
|
|
199
|
+
join(home, ".claude", "settings.json"),
|
|
200
|
+
];
|
|
201
|
+
const snapshot = () => owned.map((f) => readFileSync(f, "utf8"));
|
|
202
|
+
const before = snapshot();
|
|
203
|
+
|
|
204
|
+
doctor();
|
|
205
|
+
expect(snapshot()).toEqual(before);
|
|
206
|
+
doctor("--json");
|
|
207
|
+
expect(snapshot()).toEqual(before);
|
|
208
|
+
const fixed = doctor("--fix", "--yes");
|
|
209
|
+
expect(fixed.out).toContain("faketool");
|
|
210
|
+
expect(snapshot()).toEqual(before);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("--project checks that project's plugins from any directory, and names it in the fix", () => {
|
|
214
|
+
// Installed at project scope: enabled only in the project's own settings.
|
|
215
|
+
writeFileSync(
|
|
216
|
+
join(home, ".claude", "settings.json"),
|
|
217
|
+
JSON.stringify({ enabledPlugins: {} }),
|
|
218
|
+
);
|
|
219
|
+
mkdirSync(join(project, ".claude"), { recursive: true });
|
|
220
|
+
writeFileSync(
|
|
221
|
+
join(project, ".claude", "settings.json"),
|
|
222
|
+
JSON.stringify({ enabledPlugins: { "fake@magus": true } }),
|
|
223
|
+
);
|
|
224
|
+
const registry = join(home, ".claude", "plugins", "installed_plugins.json");
|
|
225
|
+
const installed = JSON.parse(readFileSync(registry, "utf8"));
|
|
226
|
+
installed.plugins["fake@magus"][0].scope = "project";
|
|
227
|
+
installed.plugins["fake@magus"][0].projectPath = project;
|
|
228
|
+
writeFileSync(registry, JSON.stringify(installed));
|
|
229
|
+
const elsewhere = join(home, "elsewhere");
|
|
230
|
+
mkdirSync(elsewhere);
|
|
231
|
+
|
|
232
|
+
// Run where the banner's reader may well be: not in the project.
|
|
233
|
+
expect(JSON.parse(doctorIn(elsewhere, "--json").out).plugins).toEqual([]);
|
|
234
|
+
const r = doctorIn(elsewhere, "--json", "--project", project);
|
|
235
|
+
expect(JSON.parse(r.out).plugins).toEqual(["fake@magus"]);
|
|
236
|
+
expect(JSON.parse(r.out).deps[0]).toMatchObject({
|
|
237
|
+
name: "faketool",
|
|
238
|
+
status: "missing",
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const text = doctorIn(elsewhere, "--project", project);
|
|
242
|
+
expect(text.out).toContain(`Fix: magus doctor --fix --project ${project}`);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("--project naming no directory is a usage error", () => {
|
|
246
|
+
const missing = doctor("--json", "--project", join(home, "nope"));
|
|
247
|
+
expect(missing.code).toBe(2);
|
|
248
|
+
expect(missing.out).toBe("");
|
|
249
|
+
expect(missing.err).toContain("is not a directory");
|
|
250
|
+
const bare = doctor("--json", "--project");
|
|
251
|
+
expect(bare.code).toBe(2);
|
|
252
|
+
expect(bare.err).toContain("--project needs a directory");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("--fix --json without --yes refuses: there is nowhere to ask", () => {
|
|
256
|
+
const r = doctor("--fix", "--json");
|
|
257
|
+
expect(r.code).toBe(2);
|
|
258
|
+
expect(r.out).toBe("");
|
|
259
|
+
expect(r.err).toContain("needs --yes");
|
|
260
|
+
});
|
|
261
|
+
});
|