magus-cli 7.8.0 → 7.9.1
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 +16 -11
- 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/review-followups/bun-install-path.test.ts +57 -0
- package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
- package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
- package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
- package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
- package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
- package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
- package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
- package/src/__tests__/blind-780-review2.test.ts +0 -25
- 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__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +897 -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__/failure-popup-review2.test.tsx +1 -25
- package/src/__tests__/final-fixes-780.test.tsx +25 -9
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- 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__/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__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-plan.test.ts +1 -1
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +169 -24
- package/src/cli/install.ts +51 -66
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update.ts +28 -7
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +4 -348
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +1154 -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-manager.ts +4 -18
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/resolver.ts +168 -58
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +19 -14
- package/src/services/update-plan.ts +3 -3
- package/src/shared/plugin/hooks/bun-missing.sh +64 -0
- package/src/shared/plugin/hooks/deps-check.ts +240 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -0
- package/src/types/index.ts +53 -16
- package/src/ui/App.tsx +6 -40
- package/src/ui/adapters/cliToolsAdapter.ts +75 -37
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/startupReports.ts +0 -22
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/screens/CliToolsScreen.tsx +141 -51
- package/src/ui/screens/PluginsScreen.tsx +12 -48
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +143 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/services/plugin-setup.ts +0 -965
- package/src/utils/go-module.ts +0 -36
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* SessionStart (matcher `startup`) — say loudly what this plugin needs and does
|
|
4
|
+
* not have. `argv[2]` is the plugin's label; the registration passes it.
|
|
5
|
+
*
|
|
6
|
+
* Reads the plugin's own `.claude-plugin/plugin.json` through
|
|
7
|
+
* lib/requires-probe.ts, probes each requirement at presence depth (a PATH
|
|
8
|
+
* lookup for a check that runs the requirement's own binary, the declared check
|
|
9
|
+
* for every other), and prints a `systemMessage` carrying the `magus-deps` marker when something is
|
|
10
|
+
* missing or off PATH. Prints nothing when healthy. Its "Fix all" line runs
|
|
11
|
+
* doctor with `--project <cwd>`, the session's directory from the hook input:
|
|
12
|
+
* doctor reads the plugins enabled in the project it checks, and the banner is
|
|
13
|
+
* read in a terminal that may be anywhere.
|
|
14
|
+
*
|
|
15
|
+
* THE LEDGER. Every copy writes `$TMPDIR/magus-deps-<session_id>/seen/<label>`
|
|
16
|
+
* (`ts` here, `sh` from bun-missing.sh), then claims each dep it reports with an
|
|
17
|
+
* atomic `mkdir claimed/<dedupeKey>`. A failed mkdir means another plugin's copy
|
|
18
|
+
* reports that dep this session, so one dep is one banner line however many
|
|
19
|
+
* plugins need it. An unreadable session id means no ledger and no claims:
|
|
20
|
+
* more banners, never fewer. The ledger is 0700. Ledgers older than a day are
|
|
21
|
+
* pruned on write, each on its own, so one this user cannot remove skips only
|
|
22
|
+
* itself.
|
|
23
|
+
*
|
|
24
|
+
* Exit 0 always. A watchdog keeps the hook inside its registration timeout.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
mkdirSync,
|
|
29
|
+
readFileSync,
|
|
30
|
+
readdirSync,
|
|
31
|
+
rmSync,
|
|
32
|
+
statSync,
|
|
33
|
+
writeFileSync,
|
|
34
|
+
} from "node:fs";
|
|
35
|
+
import { homedir, tmpdir } from "node:os";
|
|
36
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
37
|
+
import { fileURLToPath } from "node:url";
|
|
38
|
+
import {
|
|
39
|
+
type Presence,
|
|
40
|
+
applicable,
|
|
41
|
+
currentPlatform,
|
|
42
|
+
dedupeKey,
|
|
43
|
+
onPath,
|
|
44
|
+
parseRequires,
|
|
45
|
+
probe,
|
|
46
|
+
renderFix,
|
|
47
|
+
} from "./lib/requires-probe.js";
|
|
48
|
+
|
|
49
|
+
const WATCHDOG_MS = 8_000;
|
|
50
|
+
const CHECK_CAP_MS = 3_000;
|
|
51
|
+
const STDIN_MS = 1_000;
|
|
52
|
+
const LEDGER_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
53
|
+
const SESSION_RE = /^[A-Za-z0-9_-]+$/;
|
|
54
|
+
|
|
55
|
+
const hasControlChar = (s: string): boolean =>
|
|
56
|
+
[...s].some((c) => {
|
|
57
|
+
const code = c.charCodeAt(0);
|
|
58
|
+
return code < 0x20 || code === 0x7f;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const quiet = (): never => process.exit(0);
|
|
62
|
+
setTimeout(quiet, WATCHDOG_MS).unref();
|
|
63
|
+
process.on("uncaughtException", quiet);
|
|
64
|
+
process.on("unhandledRejection", quiet);
|
|
65
|
+
|
|
66
|
+
interface HookInput {
|
|
67
|
+
/** Null when unreadable: no ledger, so more banners, never fewer. */
|
|
68
|
+
session: string | null;
|
|
69
|
+
/** The directory the session runs in; null leaves `--project` off the fix. */
|
|
70
|
+
cwd: string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readHookInput(): Promise<HookInput> {
|
|
74
|
+
const chunks: Buffer[] = [];
|
|
75
|
+
const read = (async () => {
|
|
76
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
77
|
+
})();
|
|
78
|
+
await Promise.race([read, new Promise((r) => setTimeout(r, STDIN_MS))]);
|
|
79
|
+
try {
|
|
80
|
+
const input = JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
|
|
81
|
+
session_id?: unknown;
|
|
82
|
+
cwd?: unknown;
|
|
83
|
+
};
|
|
84
|
+
const id = input.session_id;
|
|
85
|
+
const cwd = input.cwd;
|
|
86
|
+
return {
|
|
87
|
+
session: typeof id === "string" && SESSION_RE.test(id) ? id : null,
|
|
88
|
+
// A control character would break the banner line it is printed in.
|
|
89
|
+
cwd:
|
|
90
|
+
typeof cwd === "string" && isAbsolute(cwd) && !hasControlChar(cwd)
|
|
91
|
+
? cwd
|
|
92
|
+
: null,
|
|
93
|
+
};
|
|
94
|
+
} catch {
|
|
95
|
+
return { session: null, cwd: null };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remove ledgers older than a day. Each is tried on its own: a shared `/tmp`
|
|
101
|
+
* holds other users' ledgers, which this user cannot remove, and one of those
|
|
102
|
+
* must not stop this user's own from being pruned.
|
|
103
|
+
*/
|
|
104
|
+
function pruneOldLedgers(base: string): void {
|
|
105
|
+
let entries: string[];
|
|
106
|
+
try {
|
|
107
|
+
entries = readdirSync(base);
|
|
108
|
+
} catch {
|
|
109
|
+
return; // Pruning is housekeeping; a failure costs nothing now.
|
|
110
|
+
}
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (!entry.startsWith("magus-deps-")) continue;
|
|
113
|
+
const dir = join(base, entry);
|
|
114
|
+
try {
|
|
115
|
+
if (Date.now() - statSync(dir).mtimeMs > LEDGER_MAX_AGE_MS) {
|
|
116
|
+
rmSync(dir, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Another user's, or already gone: the next entry is still pruned.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The ledger directory, with `seen/<label>` written, or null without a session.
|
|
126
|
+
* Created 0700: in a shared `/tmp`, no other user reads which plugins ran.
|
|
127
|
+
*/
|
|
128
|
+
function openLedger(hookSession: string | null, label: string): string | null {
|
|
129
|
+
if (!hookSession) return null;
|
|
130
|
+
const base = process.env.TMPDIR || tmpdir();
|
|
131
|
+
const dir = join(base, `magus-deps-${hookSession}`);
|
|
132
|
+
try {
|
|
133
|
+
mkdirSync(join(dir, "seen"), { recursive: true, mode: 0o700 });
|
|
134
|
+
mkdirSync(join(dir, "claimed"), { recursive: true, mode: 0o700 });
|
|
135
|
+
writeFileSync(join(dir, "seen", label), "ts");
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
pruneOldLedgers(base);
|
|
140
|
+
return dir;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** True when this copy is the one to report `key` this session. */
|
|
144
|
+
function claim(ledger: string | null, key: string): boolean {
|
|
145
|
+
if (!ledger) return true;
|
|
146
|
+
try {
|
|
147
|
+
mkdirSync(join(ledger, "claimed", key));
|
|
148
|
+
return true;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return (error as NodeJS.ErrnoException).code !== "EEXIST";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function emit(systemMessage: string, additionalContext: string): void {
|
|
155
|
+
process.stdout.write(
|
|
156
|
+
`${JSON.stringify({
|
|
157
|
+
systemMessage,
|
|
158
|
+
hookSpecificOutput: {
|
|
159
|
+
hookEventName: "SessionStart",
|
|
160
|
+
additionalContext,
|
|
161
|
+
},
|
|
162
|
+
})}\n`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function main(): Promise<void> {
|
|
167
|
+
const label = process.argv[2] ?? "";
|
|
168
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(label)) return;
|
|
169
|
+
const pluginRoot =
|
|
170
|
+
process.env.CLAUDE_PLUGIN_ROOT ||
|
|
171
|
+
dirname(dirname(fileURLToPath(import.meta.url)));
|
|
172
|
+
const input = await readHookInput();
|
|
173
|
+
const ledger = openLedger(input.session, label);
|
|
174
|
+
|
|
175
|
+
const manifest = JSON.parse(
|
|
176
|
+
readFileSync(join(pluginRoot, ".claude-plugin", "plugin.json"), "utf8"),
|
|
177
|
+
) as unknown;
|
|
178
|
+
const parsed = parseRequires(manifest);
|
|
179
|
+
if (!parsed.ok) {
|
|
180
|
+
if (claim(ledger, `invalid-${label}`)) {
|
|
181
|
+
emit(
|
|
182
|
+
`magus-deps · ${label}: requires is invalid (${parsed.errors.join("; ")}) — please report`,
|
|
183
|
+
`${label}'s dependency declaration is invalid`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const platform = currentPlatform();
|
|
189
|
+
if (!platform) return;
|
|
190
|
+
const reqs = applicable(parsed.requirements, platform.os);
|
|
191
|
+
if (reqs.length === 0) return;
|
|
192
|
+
|
|
193
|
+
const env = {
|
|
194
|
+
pluginRoot,
|
|
195
|
+
...platform,
|
|
196
|
+
home: process.env.HOME || homedir(),
|
|
197
|
+
path: process.env.PATH ?? "",
|
|
198
|
+
capMs: CHECK_CAP_MS,
|
|
199
|
+
depth: "presence" as const,
|
|
200
|
+
userShell: process.env.SHELL ?? "",
|
|
201
|
+
};
|
|
202
|
+
const presences = await probe(reqs, env);
|
|
203
|
+
const reportable = (p: Presence) =>
|
|
204
|
+
p.status === "missing" || p.status === "off-path";
|
|
205
|
+
if (!presences.some(reportable)) return;
|
|
206
|
+
|
|
207
|
+
const byName = new Map(reqs.map((r) => [r.name, r]));
|
|
208
|
+
const claimed = (p: Presence) => {
|
|
209
|
+
const r = byName.get(p.name);
|
|
210
|
+
return r !== undefined && claim(ledger, dedupeKey(r));
|
|
211
|
+
};
|
|
212
|
+
const findings = presences.filter((p) => reportable(p) && claimed(p));
|
|
213
|
+
if (findings.length === 0) return;
|
|
214
|
+
const slow = presences.filter((p) => p.status === "unknown" && claimed(p));
|
|
215
|
+
const mine = [...findings, ...slow];
|
|
216
|
+
const message = renderFix(
|
|
217
|
+
label,
|
|
218
|
+
mine,
|
|
219
|
+
reqs,
|
|
220
|
+
env,
|
|
221
|
+
onPath("magus", env.path) !== null,
|
|
222
|
+
input.cwd,
|
|
223
|
+
);
|
|
224
|
+
if (message === null) return;
|
|
225
|
+
const failing = findings
|
|
226
|
+
.map((p) => p.name)
|
|
227
|
+
.sort()
|
|
228
|
+
.join(", ");
|
|
229
|
+
emit(
|
|
230
|
+
message,
|
|
231
|
+
`${label}'s MCP servers and hooks will fail until these are installed: ${failing}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
await main();
|
|
237
|
+
} catch {
|
|
238
|
+
// A hook that cannot tell has nothing useful to print.
|
|
239
|
+
}
|
|
240
|
+
process.exit(0);
|