@scp-dev/cli 0.0.0-stage → 0.1.2
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/LICENSE +201 -0
- package/README.md +33 -2
- package/dist/chunk-4DNQFATX.js +221 -0
- package/dist/chunk-4QYUPPGV.js +147 -0
- package/dist/chunk-6UFMKYPH.js +255 -0
- package/dist/chunk-7KP6TAJW.js +336 -0
- package/dist/chunk-DA3EB2YF.js +359 -0
- package/dist/chunk-KN7WEIE4.js +41 -0
- package/dist/chunk-N3ZROLFA.js +1182 -0
- package/dist/chunk-V5I7KEU7.js +948 -0
- package/dist/chunk-XWLF7ON2.js +757 -0
- package/dist/chunk-YYSLYVNZ.js +38 -0
- package/dist/cli.js +22 -0
- package/dist/hooks-O3UZXDYG.js +71 -0
- package/dist/install-HFBSKSTG.js +14 -0
- package/dist/login-JTRUWG6Q.js +129 -0
- package/dist/main-5CAHHN55.js +1271 -0
- package/dist/mcp-P2AZYP5G.js +72 -0
- package/dist/peek-YCOWBEPD.js +60 -0
- package/dist/relay-VVJPYVXE.js +30 -0
- package/dist/remote-3BWFISXJ.js +27 -0
- package/dist/setup-RDPVGE4H.js +39 -0
- package/dist/spawn-TQ3HOZTQ.js +74 -0
- package/dist/up-KXOYO7QV.js +37 -0
- package/package.json +38 -4
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hooksTarget
|
|
3
|
+
} from "./chunk-4DNQFATX.js";
|
|
4
|
+
import {
|
|
5
|
+
RelayUsage
|
|
6
|
+
} from "./chunk-7KP6TAJW.js";
|
|
7
|
+
import {
|
|
8
|
+
AGY_HOOK_SET,
|
|
9
|
+
BLOCK_STOP,
|
|
10
|
+
HOOK_COMMAND,
|
|
11
|
+
agyHookSet,
|
|
12
|
+
claudeVersionFile,
|
|
13
|
+
hasOurHooks,
|
|
14
|
+
hookEntries,
|
|
15
|
+
mergeHooks,
|
|
16
|
+
musePlugin,
|
|
17
|
+
opencodePlugin
|
|
18
|
+
} from "./chunk-XWLF7ON2.js";
|
|
19
|
+
import {
|
|
20
|
+
gitToplevel
|
|
21
|
+
} from "./chunk-DA3EB2YF.js";
|
|
22
|
+
|
|
23
|
+
// src/setup.ts
|
|
24
|
+
import { spawnSync } from "child_process";
|
|
25
|
+
import { CLI_NAME } from "@scp-dev/core";
|
|
26
|
+
import { parse } from "smol-toml";
|
|
27
|
+
import { createHash } from "crypto";
|
|
28
|
+
import {
|
|
29
|
+
accessSync,
|
|
30
|
+
chmodSync,
|
|
31
|
+
constants,
|
|
32
|
+
copyFileSync,
|
|
33
|
+
existsSync,
|
|
34
|
+
lstatSync,
|
|
35
|
+
mkdirSync,
|
|
36
|
+
mkdtempSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
readlinkSync,
|
|
39
|
+
renameSync,
|
|
40
|
+
rmSync,
|
|
41
|
+
statSync,
|
|
42
|
+
unlinkSync,
|
|
43
|
+
writeFileSync
|
|
44
|
+
} from "fs";
|
|
45
|
+
import { homedir, tmpdir } from "os";
|
|
46
|
+
import { basename, dirname, isAbsolute, join, resolve } from "path";
|
|
47
|
+
import { parseArgs } from "util";
|
|
48
|
+
import { TOOLS } from "@scp-dev/mcp";
|
|
49
|
+
var SETUP_HARNESSES = ["claude", "codex", "opencode", "grok", "muse", "agy"];
|
|
50
|
+
var SERVER = "scp";
|
|
51
|
+
var HOOKED = /* @__PURE__ */ new Set(["claude", "codex", "opencode", "agy", "muse"]);
|
|
52
|
+
var VERIFY_TIMEOUT_MS = 6e4;
|
|
53
|
+
var SETUP_HELP = `${CLI_NAME} setup [--write] [--no-hooks] [--block-stop-on-unread] [--undo] [--bin PATH] [--harness H]
|
|
54
|
+
mounts the relay MCP shim in every installed harness (claude, codex, opencode, grok, muse, agy) at user scope, running
|
|
55
|
+
the ${CLI_NAME} PATH finds (by that path) or --bin, and installs the relay hooks at user scope (Muse's as a plugin). Prints
|
|
56
|
+
the plan; --write applies it (each file backed up next to itself as <file>.bak-<time>, its mode kept), then checks
|
|
57
|
+
each harness's own MCP listing. Rerunning changes nothing.
|
|
58
|
+
The hooks report activity and hand the model new messages after tools and with prompts; a stop names each request
|
|
59
|
+
not yet answered once, and is held while a require_reply request waits. --block-stop-on-unread also holds it while
|
|
60
|
+
messages are unread; without it a hooks file keeps the stop hook it has. ${CLI_NAME} spawn returns at once from an
|
|
61
|
+
agent's shell; while the workers it started run, a stop waits up to 600 s for their next message or handback (hooks
|
|
62
|
+
install --push-wait), so results arrive by themselves. --no-hooks mounts the MCP shim only.
|
|
63
|
+
--undo removes only what setup added (it keeps a list in ~/.config/scp/setup.json), its hooks too unless --no-hooks.`;
|
|
64
|
+
var KNOWN = {
|
|
65
|
+
claude: [".local/bin/claude", ".claude/local/claude"],
|
|
66
|
+
codex: [".local/bin/codex"],
|
|
67
|
+
opencode: [".opencode/bin/opencode", ".local/bin/opencode"],
|
|
68
|
+
grok: [".grok/bin/grok", ".local/bin/grok"],
|
|
69
|
+
muse: [".local/bin/muse"],
|
|
70
|
+
agy: [".local/bin/agy"]
|
|
71
|
+
};
|
|
72
|
+
function home(env) {
|
|
73
|
+
return env.HOME || homedir();
|
|
74
|
+
}
|
|
75
|
+
function executable(path) {
|
|
76
|
+
try {
|
|
77
|
+
accessSync(path, constants.X_OK);
|
|
78
|
+
return statSync(path).isFile();
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function whichOnPath(name, env) {
|
|
84
|
+
for (const dir of (env.PATH ?? "").split(":")) if (dir && executable(join(dir, name))) return join(dir, name);
|
|
85
|
+
return void 0;
|
|
86
|
+
}
|
|
87
|
+
function runCommand(argv, env, cwd) {
|
|
88
|
+
try {
|
|
89
|
+
const result = spawnSync(argv[0], argv.slice(1), { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: VERIFY_TIMEOUT_MS });
|
|
90
|
+
return {
|
|
91
|
+
code: result.status ?? (result.error?.code === "ETIMEDOUT" ? 124 : 127),
|
|
92
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}${result.error ? message(result.error) : ""}`
|
|
93
|
+
};
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return { code: 127, output: error instanceof Error ? error.message : String(error) };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function detectHarnesses(env, which) {
|
|
99
|
+
const found = /* @__PURE__ */ new Map();
|
|
100
|
+
for (const harness of SETUP_HARNESSES) {
|
|
101
|
+
const path = which(harness) ?? KNOWN[harness].map((rel) => join(home(env), rel)).find(executable);
|
|
102
|
+
if (path) found.set(harness, path);
|
|
103
|
+
}
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
function configHome(env) {
|
|
107
|
+
return env.XDG_CONFIG_HOME || join(home(env), ".config");
|
|
108
|
+
}
|
|
109
|
+
function mcpFile(harness, env) {
|
|
110
|
+
switch (harness) {
|
|
111
|
+
case "claude":
|
|
112
|
+
return env.CLAUDE_CONFIG_DIR ? join(env.CLAUDE_CONFIG_DIR, ".claude.json") : join(home(env), ".claude.json");
|
|
113
|
+
case "codex":
|
|
114
|
+
return join(env.CODEX_HOME || join(home(env), ".codex"), "config.toml");
|
|
115
|
+
case "grok":
|
|
116
|
+
return join(home(env), ".grok", "config.toml");
|
|
117
|
+
case "muse":
|
|
118
|
+
return join(configHome(env), "muse", "settings.json");
|
|
119
|
+
case "agy":
|
|
120
|
+
return join(home(env), ".gemini", "config", "mcp_config.json");
|
|
121
|
+
case "opencode": {
|
|
122
|
+
const dir = join(configHome(env), "opencode");
|
|
123
|
+
const files = ["opencode.json", "opencode.jsonc", "config.json"].map((name) => join(dir, name)).filter((file) => existsSync(file));
|
|
124
|
+
const unreadable = files.find((file) => {
|
|
125
|
+
try {
|
|
126
|
+
readJson(file);
|
|
127
|
+
return false;
|
|
128
|
+
} catch {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
const mounted = files.find((file) => !unreadable && Boolean(readJson(file)?.mcp?.[SERVER]));
|
|
133
|
+
return unreadable ?? mounted ?? files[0] ?? join(dir, "opencode.json");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function agySettingsFile(env) {
|
|
138
|
+
return join(home(env), ".gemini", "antigravity-cli", "settings.json");
|
|
139
|
+
}
|
|
140
|
+
function manifestFile(env) {
|
|
141
|
+
return join(configHome(env), "scp", "setup.json");
|
|
142
|
+
}
|
|
143
|
+
function museInstalledFile(env) {
|
|
144
|
+
return join(env.XDG_DATA_HOME || join(home(env), ".local", "share"), "muse", "plugins", "installed.json");
|
|
145
|
+
}
|
|
146
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
147
|
+
function readJson(file) {
|
|
148
|
+
if (!existsSync(file)) return void 0;
|
|
149
|
+
const text = readFileSync(file, "utf8");
|
|
150
|
+
if (!text.trim()) return {};
|
|
151
|
+
const parsed = JSON.parse(text);
|
|
152
|
+
if (!isObject(parsed)) throw new Error("not a JSON object");
|
|
153
|
+
return parsed;
|
|
154
|
+
}
|
|
155
|
+
function readToml(file) {
|
|
156
|
+
if (!existsSync(file)) return void 0;
|
|
157
|
+
return parse(readFileSync(file, "utf8"));
|
|
158
|
+
}
|
|
159
|
+
function mountedEntry(harness, file) {
|
|
160
|
+
const config = harness === "codex" || harness === "grok" ? readToml(file) : readJson(file);
|
|
161
|
+
const table = config?.[harness === "codex" || harness === "grok" ? "mcp_servers" : harness === "opencode" ? "mcp" : "mcpServers"];
|
|
162
|
+
const entry = isObject(table) ? table[SERVER] : void 0;
|
|
163
|
+
return isObject(entry) ? entry : void 0;
|
|
164
|
+
}
|
|
165
|
+
function entryArgv(harness, entry) {
|
|
166
|
+
if (harness === "opencode") return Array.isArray(entry.command) ? entry.command.map(String) : [];
|
|
167
|
+
return [String(entry.command ?? ""), ...Array.isArray(entry.args) ? entry.args.map(String) : []];
|
|
168
|
+
}
|
|
169
|
+
function entryEnabled(harness, entry) {
|
|
170
|
+
if (harness === "agy") return entry.disabled !== true;
|
|
171
|
+
return entry.enabled !== false;
|
|
172
|
+
}
|
|
173
|
+
var LOCAL_TYPE = { claude: "stdio", opencode: "local", muse: "stdio" };
|
|
174
|
+
var REMOTE_KEYS = ["url", "httpUrl", "serverUrl", "headers", "oauth"];
|
|
175
|
+
function entryRight(harness, entry, argv) {
|
|
176
|
+
const type = LOCAL_TYPE[harness];
|
|
177
|
+
const local = !["url", "httpUrl", "serverUrl"].some((key) => key in entry) && (entry.type === void 0 || type === void 0 || entry.type === type);
|
|
178
|
+
return local && entryEnabled(harness, entry) && entryArgv(harness, entry).join("\0") === argv.join("\0");
|
|
179
|
+
}
|
|
180
|
+
var withoutRemote = (entry) => Object.fromEntries(Object.entries(entry).filter(([key]) => !REMOTE_KEYS.includes(key)));
|
|
181
|
+
function mountArgv(harness, bin) {
|
|
182
|
+
return [bin, "mcp", "--harness", harness];
|
|
183
|
+
}
|
|
184
|
+
function agyRules() {
|
|
185
|
+
return TOOLS.map((tool) => `mcp(${SERVER}/${tool.name})`);
|
|
186
|
+
}
|
|
187
|
+
function shellWord(word) {
|
|
188
|
+
return /^[A-Za-z0-9._/=@:<>-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
|
|
189
|
+
}
|
|
190
|
+
function tilde(text, env) {
|
|
191
|
+
const prefix = home(env).replace(/\/+$/, "");
|
|
192
|
+
return prefix.length > 1 ? text.replaceAll(`${prefix}/`, "~/") : text;
|
|
193
|
+
}
|
|
194
|
+
function stamp(now) {
|
|
195
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
196
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
197
|
+
}
|
|
198
|
+
function resolved(file) {
|
|
199
|
+
let path = file;
|
|
200
|
+
for (let hops = 0; hops < 40; hops++) {
|
|
201
|
+
let link;
|
|
202
|
+
try {
|
|
203
|
+
link = lstatSync(path).isSymbolicLink();
|
|
204
|
+
} catch {
|
|
205
|
+
return path;
|
|
206
|
+
}
|
|
207
|
+
if (!link) return path;
|
|
208
|
+
path = resolve(dirname(path), readlinkSync(path));
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`${file}: too many levels of symlinks`);
|
|
211
|
+
}
|
|
212
|
+
function linkProblem(file, byCli) {
|
|
213
|
+
let target;
|
|
214
|
+
try {
|
|
215
|
+
target = resolved(file);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return message(error);
|
|
218
|
+
}
|
|
219
|
+
if (target === file || existsSync(target)) return void 0;
|
|
220
|
+
if (byCli) return `${file} is a symlink to ${target}, which does not exist, and the harness CLI would replace the link; create ${target} or remove the link`;
|
|
221
|
+
if (!existsSync(dirname(target))) return `${file} is a symlink to ${target}, whose directory does not exist; create it or remove the link`;
|
|
222
|
+
return void 0;
|
|
223
|
+
}
|
|
224
|
+
function backup(file, now) {
|
|
225
|
+
const stamped = `${file}.bak-${stamp(now)}`;
|
|
226
|
+
let path = stamped;
|
|
227
|
+
for (let n = 2; existsSync(path); n++) path = `${stamped}-${n}`;
|
|
228
|
+
copyFileSync(file, path);
|
|
229
|
+
chmodSync(path, statSync(file).mode & 4095);
|
|
230
|
+
return path;
|
|
231
|
+
}
|
|
232
|
+
function writeFile(target, content, now, options = {}) {
|
|
233
|
+
const file = resolved(target);
|
|
234
|
+
let saved;
|
|
235
|
+
let mode = options.mode ?? 384;
|
|
236
|
+
if (existsSync(file)) {
|
|
237
|
+
mode = statSync(file).mode & 4095;
|
|
238
|
+
if (options.keep !== false) saved = backup(file, now);
|
|
239
|
+
} else if (file !== target && !existsSync(dirname(file))) {
|
|
240
|
+
throw new Error(`${target} is a symlink to ${file}, whose directory does not exist`);
|
|
241
|
+
} else mkdirSync(dirname(file), { recursive: true });
|
|
242
|
+
const temp = join(dirname(file), `.${basename(file)}.tmp-${process.pid}`);
|
|
243
|
+
try {
|
|
244
|
+
writeFileSync(temp, content, { mode });
|
|
245
|
+
chmodSync(temp, mode);
|
|
246
|
+
renameSync(temp, file);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
try {
|
|
249
|
+
unlinkSync(temp);
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
return saved;
|
|
255
|
+
}
|
|
256
|
+
function runEditing(target, now, run, commands, rollback = []) {
|
|
257
|
+
const file = resolved(target);
|
|
258
|
+
const before = existsSync(file) ? statSync(file).mode & 4095 : void 0;
|
|
259
|
+
const saved = before === void 0 ? void 0 : backup(file, now);
|
|
260
|
+
try {
|
|
261
|
+
for (const [index, argv] of commands.entries()) {
|
|
262
|
+
const result = run(argv);
|
|
263
|
+
if (result.code === 0) continue;
|
|
264
|
+
const failed = `${basename(argv[0])} ${argv.slice(1, 3).join(" ")} exited ${result.code}: ${lastLine(result.output)}`;
|
|
265
|
+
if (index === 0 || !rollback.length) throw new Error(failed);
|
|
266
|
+
const undone = rollback.every((step) => run(step).code === 0);
|
|
267
|
+
throw new Error(`${failed}; ${undone ? "the previous mount is back" : `putting the previous mount back failed too, it is in ${saved}`}`);
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
if (before !== void 0 && existsSync(file) && (statSync(file).mode & 4095) !== before) chmodSync(file, before);
|
|
271
|
+
}
|
|
272
|
+
return saved;
|
|
273
|
+
}
|
|
274
|
+
function lastLine(text) {
|
|
275
|
+
return stripAnsi(text).trim().split("\n").filter(Boolean).at(-1) ?? "no output";
|
|
276
|
+
}
|
|
277
|
+
function stripAnsi(text) {
|
|
278
|
+
return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\[[0-9;]*m/g, "");
|
|
279
|
+
}
|
|
280
|
+
function indentOf(text) {
|
|
281
|
+
return /\n([ \t]+)["}\]]/.exec(text)?.[1] ?? 2;
|
|
282
|
+
}
|
|
283
|
+
function jsonText(original, value) {
|
|
284
|
+
return `${JSON.stringify(value, null, original ? indentOf(original) : 2)}
|
|
285
|
+
`;
|
|
286
|
+
}
|
|
287
|
+
var message = (error) => error instanceof Error ? error.message : String(error);
|
|
288
|
+
var SECRET_KEYS = /* @__PURE__ */ new Set(["env", "environment", "headers", "http_headers"]);
|
|
289
|
+
function redacted(value) {
|
|
290
|
+
if (Array.isArray(value)) return value.map(redacted);
|
|
291
|
+
if (!isObject(value)) return value;
|
|
292
|
+
return Object.fromEntries(
|
|
293
|
+
Object.entries(value).map(([key, inner]) => [
|
|
294
|
+
key,
|
|
295
|
+
SECRET_KEYS.has(key) && isObject(inner) ? Object.fromEntries(Object.keys(inner).map((name) => [name, "<kept>"])) : redacted(inner)
|
|
296
|
+
])
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
function shown(argv) {
|
|
300
|
+
const words = [basename(argv[0]), ...argv.slice(1)].map((word, index, all) => {
|
|
301
|
+
if (all[index - 1] === "--env") return `${word.split("=")[0]}=<kept>`;
|
|
302
|
+
if (all[2] === "add-json" && index === all.length - 1) return JSON.stringify(redacted(JSON.parse(word)));
|
|
303
|
+
return word;
|
|
304
|
+
});
|
|
305
|
+
return `$ ${words.map(shellWord).join(" ")}`;
|
|
306
|
+
}
|
|
307
|
+
function readManifest(env) {
|
|
308
|
+
const config = readJson(manifestFile(env));
|
|
309
|
+
if (config === void 0) return [];
|
|
310
|
+
if (!Array.isArray(config.added)) throw new Error("its added key is not a list");
|
|
311
|
+
return config.added.filter(
|
|
312
|
+
(item) => isObject(item) && typeof item.harness === "string" && typeof item.kind === "string" && typeof item.file === "string"
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
var sameItem = (a, b) => a.harness === b.harness && a.kind === b.kind && a.file === b.file;
|
|
316
|
+
function writeManifest(env, now, change) {
|
|
317
|
+
const file = manifestFile(env);
|
|
318
|
+
const next = change(readManifest(env));
|
|
319
|
+
if (next.length) writeFile(file, `${JSON.stringify({ version: 1, added: next }, null, 2)}
|
|
320
|
+
`, now, { keep: false });
|
|
321
|
+
else if (existsSync(file)) unlinkSync(file);
|
|
322
|
+
}
|
|
323
|
+
var remember = (item) => (added) => {
|
|
324
|
+
const existing = added.find((other) => sameItem(other, item));
|
|
325
|
+
if (!existing) return [...added, item];
|
|
326
|
+
if (item.rules) existing.rules = [.../* @__PURE__ */ new Set([...existing.rules ?? [], ...item.rules])];
|
|
327
|
+
return added;
|
|
328
|
+
};
|
|
329
|
+
var forget = (item) => (added) => added.filter((other) => !sameItem(other, item));
|
|
330
|
+
function note(plan, harness, text) {
|
|
331
|
+
plan.lines.push({ harness, text });
|
|
332
|
+
}
|
|
333
|
+
function problem(plan, harness, text) {
|
|
334
|
+
plan.problems++;
|
|
335
|
+
note(plan, harness, `! ${text}`);
|
|
336
|
+
}
|
|
337
|
+
function addStep(plan, step) {
|
|
338
|
+
plan.steps.push(step);
|
|
339
|
+
plan.lines.push({ harness: step.harness, step });
|
|
340
|
+
}
|
|
341
|
+
function editedJson(file, edit) {
|
|
342
|
+
const target = resolved(file);
|
|
343
|
+
const original = existsSync(target) ? readFileSync(target, "utf8") : void 0;
|
|
344
|
+
const next = edit(readJson(target) ?? {});
|
|
345
|
+
if (!next) return void 0;
|
|
346
|
+
const content = jsonText(original, next);
|
|
347
|
+
return content === original ? void 0 : content;
|
|
348
|
+
}
|
|
349
|
+
function jsonEdit(file, edit, now) {
|
|
350
|
+
if (editedJson(file, edit) === void 0) return void 0;
|
|
351
|
+
return {
|
|
352
|
+
apply: () => {
|
|
353
|
+
const content = editedJson(file, edit);
|
|
354
|
+
return content === void 0 ? void 0 : writeFile(file, content, now);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
var CLI_KEEPS = {
|
|
359
|
+
codex: /* @__PURE__ */ new Set(["command", "args", "env", "enabled"]),
|
|
360
|
+
grok: /* @__PURE__ */ new Set(["command", "args", "env", "enabled"]),
|
|
361
|
+
agy: /* @__PURE__ */ new Set(["command", "args", "env", "disabled"])
|
|
362
|
+
};
|
|
363
|
+
function claudeShadows(file, cwd) {
|
|
364
|
+
const found = [];
|
|
365
|
+
let projects;
|
|
366
|
+
try {
|
|
367
|
+
projects = readJson(resolved(file))?.projects;
|
|
368
|
+
} catch {
|
|
369
|
+
return found;
|
|
370
|
+
}
|
|
371
|
+
const dirs = /* @__PURE__ */ new Set([gitToplevel(cwd) ?? cwd]);
|
|
372
|
+
if (isObject(projects)) {
|
|
373
|
+
for (const [dir, project] of Object.entries(projects)) {
|
|
374
|
+
dirs.add(dir);
|
|
375
|
+
if (isObject(project) && isObject(project.mcpServers) && project.mcpServers[SERVER] !== void 0)
|
|
376
|
+
found.push(`a local-scope ${SERVER} for ${dir} (in ${file})`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
for (const dir of dirs) {
|
|
380
|
+
try {
|
|
381
|
+
const servers = readJson(join(dir, ".mcp.json"))?.mcpServers;
|
|
382
|
+
if (isObject(servers) && servers[SERVER] !== void 0) found.push(`a project-scope ${SERVER} in ${join(dir, ".mcp.json")}`);
|
|
383
|
+
} catch {
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return found;
|
|
387
|
+
}
|
|
388
|
+
function planMount(plan, harness, bin, ctx) {
|
|
389
|
+
const { now, run } = ctx;
|
|
390
|
+
const cli = ctx.found.get(harness);
|
|
391
|
+
const file = mcpFile(harness, ctx.env);
|
|
392
|
+
const argv = mountArgv(harness, bin);
|
|
393
|
+
const blocked = linkProblem(file, harness !== "opencode" && harness !== "muse");
|
|
394
|
+
if (blocked) {
|
|
395
|
+
problem(plan, harness, `mcp: skipped, ${blocked}`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
let entry;
|
|
399
|
+
try {
|
|
400
|
+
entry = mountedEntry(harness, file);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
problem(plan, harness, `mcp: skipped, ${file} is unreadable (${message(error)}); fix it or rerun after`);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (harness === "claude")
|
|
406
|
+
for (const shadow of claudeShadows(file, ctx.cwd)) note(plan, harness, `mcp: warning, ${shadow}: in that project it is used instead of the user mount`);
|
|
407
|
+
if (entry && entryRight(harness, entry, argv)) {
|
|
408
|
+
note(plan, harness, `mcp: up to date in ${file}`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const current = entry ? entryArgv(harness, entry) : void 0;
|
|
412
|
+
const manifest = entry ? void 0 : remember({ harness, kind: "mcp", file });
|
|
413
|
+
const verb = entry ? `update ${SERVER} (now: ${current?.map(shellWord).join(" ") || "unparsed"}${entryEnabled(harness, entry) ? "" : ", disabled; setup enables it"})` : `add ${SERVER}`;
|
|
414
|
+
const dropped = REMOTE_KEYS.filter((key) => entry && key in entry);
|
|
415
|
+
const landed = () => {
|
|
416
|
+
try {
|
|
417
|
+
return mountedEntry(harness, file) ? "the mount it wrote" : void 0;
|
|
418
|
+
} catch {
|
|
419
|
+
return void 0;
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
const cliStep = (commands, rollback = []) => addStep(plan, {
|
|
423
|
+
harness,
|
|
424
|
+
label: "mcp",
|
|
425
|
+
file,
|
|
426
|
+
plan: [`${verb} in ${file}`, ...commands.map(shown)],
|
|
427
|
+
apply: () => runEditing(file, now, run, commands, rollback),
|
|
428
|
+
manifest,
|
|
429
|
+
landed
|
|
430
|
+
});
|
|
431
|
+
switch (harness) {
|
|
432
|
+
case "claude": {
|
|
433
|
+
if (!entry) {
|
|
434
|
+
cliStep([[cli, "mcp", "add", "--scope", "user", SERVER, "--", ...argv]]);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const merged = { ...withoutRemote(entry), type: LOCAL_TYPE.claude, command: argv[0], args: argv.slice(1) };
|
|
438
|
+
if (dropped.length) note(plan, harness, `mcp: the update drops ${dropped.join(", ")} from the entry; the backup keeps them`);
|
|
439
|
+
cliStep(
|
|
440
|
+
[
|
|
441
|
+
[cli, "mcp", "remove", "--scope", "user", SERVER],
|
|
442
|
+
[cli, "mcp", "add-json", "--scope", "user", SERVER, JSON.stringify(merged)]
|
|
443
|
+
],
|
|
444
|
+
[[cli, "mcp", "add-json", "--scope", "user", SERVER, JSON.stringify(entry)]]
|
|
445
|
+
);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
case "codex":
|
|
449
|
+
case "grok":
|
|
450
|
+
case "agy": {
|
|
451
|
+
const vars = isObject(entry?.env) ? Object.entries(entry.env) : [];
|
|
452
|
+
const flags = vars.filter(([, value]) => typeof value === "string").flatMap(([key, value]) => ["--env", `${key}=${value}`]);
|
|
453
|
+
const lost = [
|
|
454
|
+
...Object.keys(entry ?? {}).filter((key) => !CLI_KEEPS[harness].has(key)),
|
|
455
|
+
...vars.filter(([, value]) => typeof value !== "string").map(([key]) => `env.${key}`)
|
|
456
|
+
];
|
|
457
|
+
cliStep([[cli, "mcp", "add", ...harness === "grok" ? ["--scope", "user"] : [], ...flags, SERVER, "--", ...argv]]);
|
|
458
|
+
if (lost.length) note(plan, harness, `mcp: ${harness} mcp add writes the entry anew and drops ${lost.join(", ")} from it; the backup keeps them`);
|
|
459
|
+
if (harness === "grok" && existsSync(file) && /^\s*#/m.test(readFileSync(file, "utf8")))
|
|
460
|
+
note(plan, harness, `mcp: grok mcp add rewrites ${file} without its comments; the backup keeps them`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
case "opencode":
|
|
464
|
+
case "muse": {
|
|
465
|
+
const key = harness === "opencode" ? "mcp" : "mcpServers";
|
|
466
|
+
const fresh = harness === "opencode" ? { type: "local", command: argv, enabled: true } : { type: "stdio", command: argv[0], args: argv.slice(1), mode: "optional" };
|
|
467
|
+
const type = entry && entry.type !== LOCAL_TYPE[harness] ? { type: LOCAL_TYPE[harness] } : {};
|
|
468
|
+
const changed = harness === "opencode" ? { ...type, command: argv, enabled: true } : { ...type, command: argv[0], args: argv.slice(1), ...entry && "enabled" in entry ? { enabled: true } : {} };
|
|
469
|
+
let value = fresh;
|
|
470
|
+
let edit;
|
|
471
|
+
try {
|
|
472
|
+
edit = jsonEdit(
|
|
473
|
+
file,
|
|
474
|
+
(config) => {
|
|
475
|
+
if (harness === "muse" && config.mcp_servers !== void 0)
|
|
476
|
+
throw new Error("it has a mcp_servers key, and Muse drops every MCP server when both mcp_servers and mcpServers exist; merge them by hand first");
|
|
477
|
+
const table = config[key] ?? {};
|
|
478
|
+
if (!isObject(table)) throw new Error(`its ${key} key is not an object`);
|
|
479
|
+
const existing = table[SERVER];
|
|
480
|
+
value = isObject(existing) ? { ...withoutRemote(existing), ...changed } : fresh;
|
|
481
|
+
const base = Object.keys(config).length ? config : harness === "opencode" ? { $schema: "https://opencode.ai/config.json" } : { schema_version: 1 };
|
|
482
|
+
return { ...base, [key]: { ...table, [SERVER]: value } };
|
|
483
|
+
},
|
|
484
|
+
now
|
|
485
|
+
);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
problem(plan, harness, `mcp: skipped, ${file}: ${message(error)}`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (!edit) return;
|
|
491
|
+
const detail = entry ? `${Object.entries(changed).map(([name, inner]) => `${key}.${SERVER}.${name} = ${JSON.stringify(inner)}`).join(", ")} (its other keys kept${dropped.length ? `, and ${dropped.join(", ")} dropped` : ""})` : `${key}.${SERVER} = ${JSON.stringify(redacted(value))}`;
|
|
492
|
+
addStep(plan, { harness, label: "mcp", file, plan: [`${verb} in ${file}`, detail], apply: edit.apply, manifest });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function planAgyRules(plan, ctx, remove) {
|
|
497
|
+
const file = agySettingsFile(ctx.env);
|
|
498
|
+
const item = { harness: "agy", kind: "rules", file };
|
|
499
|
+
const ours = ctx.added.find((other) => sameItem(other, item))?.rules ?? [];
|
|
500
|
+
const wanted = remove ? ours : agyRules();
|
|
501
|
+
const blocked = linkProblem(file, false);
|
|
502
|
+
if (blocked) {
|
|
503
|
+
problem(plan, "agy", `permissions: skipped, ${blocked}`);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
let changed = [];
|
|
507
|
+
let kept = 0;
|
|
508
|
+
let edit;
|
|
509
|
+
try {
|
|
510
|
+
edit = jsonEdit(
|
|
511
|
+
file,
|
|
512
|
+
(config) => {
|
|
513
|
+
if (remove && config.permissions === void 0) return void 0;
|
|
514
|
+
const permissions = config.permissions ?? {};
|
|
515
|
+
if (!isObject(permissions)) throw new Error("its permissions key is not an object");
|
|
516
|
+
const allow = permissions.allow ?? [];
|
|
517
|
+
if (!Array.isArray(allow)) throw new Error("its permissions.allow is not a list");
|
|
518
|
+
kept = remove ? allow.filter((rule) => typeof rule === "string" && agyRules().includes(rule) && !wanted.includes(rule)).length : 0;
|
|
519
|
+
const next = remove ? allow.filter((rule) => !(typeof rule === "string" && wanted.includes(rule))) : [...allow, ...wanted.filter((rule) => !allow.includes(rule))];
|
|
520
|
+
changed = remove ? allow.filter((rule) => !next.includes(rule)).map(String) : next.slice(allow.length).map(String);
|
|
521
|
+
return changed.length ? { ...config, permissions: { ...permissions, allow: next } } : void 0;
|
|
522
|
+
},
|
|
523
|
+
ctx.now
|
|
524
|
+
);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
problem(plan, "agy", `permissions: skipped, ${file}: ${message(error)}`);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (kept) note(plan, "agy", `permissions: keeps ${kept} relay tool rule${kept === 1 ? "" : "s"} in ${file} that setup did not add`);
|
|
530
|
+
if (!edit) {
|
|
531
|
+
if (!remove) note(plan, "agy", `permissions: ${wanted.length} relay tool rules already in ${file}`);
|
|
532
|
+
else if (ours.length)
|
|
533
|
+
addStep(plan, {
|
|
534
|
+
harness: "agy",
|
|
535
|
+
label: "permissions",
|
|
536
|
+
file,
|
|
537
|
+
plan: [`forget the rules setup added to ${file}, which are gone`],
|
|
538
|
+
apply: () => void 0,
|
|
539
|
+
manifest: forget(item)
|
|
540
|
+
});
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
addStep(plan, {
|
|
544
|
+
harness: "agy",
|
|
545
|
+
label: "permissions",
|
|
546
|
+
file,
|
|
547
|
+
plan: [
|
|
548
|
+
`${remove ? "remove" : "add"} ${changed.length} permissions.allow rules ${remove ? "from" : "to"} ${file}${remove ? "" : " (headless agy denies MCP tools without them)"}`,
|
|
549
|
+
changed.join(" ")
|
|
550
|
+
],
|
|
551
|
+
apply: edit.apply,
|
|
552
|
+
manifest: remove ? forget(item) : remember({ ...item, rules: changed })
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
function hookContent(kind, existing, blockStop) {
|
|
556
|
+
const options = { blockStop };
|
|
557
|
+
if (kind === "opencode") return opencodePlugin(options);
|
|
558
|
+
if (kind === "muse") return `${JSON.stringify(musePlugin(options), null, 2)}
|
|
559
|
+
`;
|
|
560
|
+
const settings = existing === void 0 ? {} : JSON.parse(existing);
|
|
561
|
+
if (kind === "agy") {
|
|
562
|
+
if (!isObject(settings)) throw new Error("not a JSON object");
|
|
563
|
+
return `${JSON.stringify({ ...settings, [AGY_HOOK_SET]: agyHookSet(options) }, null, 2)}
|
|
564
|
+
`;
|
|
565
|
+
}
|
|
566
|
+
return `${JSON.stringify(mergeHooks(settings, hookEntries(kind, options)), null, 2)}
|
|
567
|
+
`;
|
|
568
|
+
}
|
|
569
|
+
function desiredHooks(kind, target, blockStop) {
|
|
570
|
+
const file = resolved(target);
|
|
571
|
+
const existing = existsSync(file) ? readFileSync(file, "utf8") : void 0;
|
|
572
|
+
const content = hookContent(kind, existing, blockStop);
|
|
573
|
+
return content === existing ? void 0 : content;
|
|
574
|
+
}
|
|
575
|
+
function holdsStops(kind, target) {
|
|
576
|
+
try {
|
|
577
|
+
const file = resolved(target);
|
|
578
|
+
if (!existsSync(file)) return false;
|
|
579
|
+
const text = readFileSync(file, "utf8");
|
|
580
|
+
if (kind === "opencode") return text.includes("const BLOCK_STOP = true");
|
|
581
|
+
const config = JSON.parse(text);
|
|
582
|
+
if (!isObject(config)) return false;
|
|
583
|
+
if (kind === "muse") return JSON.stringify(config).includes(BLOCK_STOP);
|
|
584
|
+
if (kind === "agy") return JSON.stringify(config[AGY_HOOK_SET] ?? null).includes(BLOCK_STOP);
|
|
585
|
+
return JSON.stringify(config.hooks ?? null).includes(`${HOOK_COMMAND} ${kind} ${BLOCK_STOP}`);
|
|
586
|
+
} catch {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function hasHooks(kind, target) {
|
|
591
|
+
try {
|
|
592
|
+
const file = resolved(target);
|
|
593
|
+
if (!existsSync(file)) return false;
|
|
594
|
+
if (kind === "opencode" || kind === "muse") return true;
|
|
595
|
+
if (kind !== "agy") return hasOurHooks(file);
|
|
596
|
+
return AGY_HOOK_SET in (readJson(file) ?? {});
|
|
597
|
+
} catch {
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
var OPENCODE_MARK = "// scp:";
|
|
602
|
+
var sha256 = (text) => `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
603
|
+
function museInstalled(env) {
|
|
604
|
+
try {
|
|
605
|
+
const plugins = readJson(museInstalledFile(env))?.plugins;
|
|
606
|
+
const record = isObject(plugins) ? plugins[SERVER] : void 0;
|
|
607
|
+
return isObject(record) ? record : void 0;
|
|
608
|
+
} catch {
|
|
609
|
+
return void 0;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
var installedFrom = (record) => record && isObject(record.source) ? String(record.source.path) : void 0;
|
|
613
|
+
function pluginDir(manifest) {
|
|
614
|
+
return dirname(dirname(manifest));
|
|
615
|
+
}
|
|
616
|
+
function planHooks(plan, harness, ctx) {
|
|
617
|
+
const { env, cwd, now, run } = ctx;
|
|
618
|
+
let kind;
|
|
619
|
+
if (harness === "grok") {
|
|
620
|
+
if (ctx.found.has("claude")) {
|
|
621
|
+
note(plan, harness, "hooks: Grok runs the Claude Code hooks from ~/.claude/settings.json and labels them grok");
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
kind = "claude";
|
|
625
|
+
} else if (HOOKED.has(harness)) kind = harness;
|
|
626
|
+
else return;
|
|
627
|
+
const file = hooksTarget(kind, "user", env, cwd);
|
|
628
|
+
const had = holdsStops(kind, file);
|
|
629
|
+
const blockStop = ctx.blockStop || had;
|
|
630
|
+
plan.hooks.set(harness, { kind, file, blockStop });
|
|
631
|
+
if (kind === "claude" || kind === "codex") {
|
|
632
|
+
const others = kind === "claude" ? [hooksTarget("claude", "local", env, cwd), hooksTarget("claude", "project", env, cwd)] : [hooksTarget("codex", "project", env, cwd)];
|
|
633
|
+
for (const other of new Set(others))
|
|
634
|
+
if (other !== file && hasOurHooks(other))
|
|
635
|
+
note(plan, harness, `hooks: warning, ${other} already runs the ${CLI_NAME} hooks: in that project every event reports twice; remove them there`);
|
|
636
|
+
}
|
|
637
|
+
const blocked = linkProblem(file, false);
|
|
638
|
+
if (blocked) {
|
|
639
|
+
problem(plan, harness, `hooks: skipped, ${blocked}`);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
let content;
|
|
643
|
+
try {
|
|
644
|
+
content = desiredHooks(kind, file, blockStop);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
problem(plan, harness, `hooks: skipped, ${file} is unreadable (${message(error)})`);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const held = had && !ctx.blockStop ? ` (it keeps ${BLOCK_STOP}, which the file has)` : ctx.blockStop && !had ? `, with ${BLOCK_STOP}` : "";
|
|
650
|
+
const manifest = hasHooks(kind, file) ? void 0 : remember({ harness, kind: "hooks", file });
|
|
651
|
+
if (kind === "muse") {
|
|
652
|
+
const dir = pluginDir(file);
|
|
653
|
+
const record = museInstalled(env);
|
|
654
|
+
const from = installedFrom(record);
|
|
655
|
+
const current = content ?? readFileSync(resolved(file), "utf8");
|
|
656
|
+
const disabled = record?.enabled === false;
|
|
657
|
+
const install = content !== void 0 || from !== dir || record?.manifest_sha256 !== sha256(current);
|
|
658
|
+
if (!install && !disabled) {
|
|
659
|
+
note(plan, harness, `hooks: up to date in ${file}${held}`);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const cli = ctx.found.get("muse");
|
|
663
|
+
const commands = [...install ? [[cli, "plugins", "install", dir, "--scope", "user"]] : [], ...disabled ? [[cli, "plugins", "enable", SERVER]] : []];
|
|
664
|
+
const head = install ? `${content ? "write" : "install"} the ${CLI_NAME} plugin ${file}${held}${from && from !== dir ? `, replacing the scp plugin installed from ${from}` : ""}${disabled ? ", and enable it, which Muse has disabled" : ""}` : `enable the ${CLI_NAME} plugin ${file}, which Muse has disabled`;
|
|
665
|
+
addStep(plan, {
|
|
666
|
+
harness,
|
|
667
|
+
label: "hooks",
|
|
668
|
+
file,
|
|
669
|
+
plan: [head, ...commands.map(shown), ...install ? ["Muse asks you to review a plugin's hooks before it runs them"] : []],
|
|
670
|
+
apply: () => {
|
|
671
|
+
const next = install ? desiredHooks(kind, file, blockStop) : void 0;
|
|
672
|
+
const saved = next === void 0 ? void 0 : writeFile(file, next, now, { mode: 420 });
|
|
673
|
+
for (const argv of commands) {
|
|
674
|
+
const result = run(argv);
|
|
675
|
+
if (result.code !== 0) throw new Error(`muse plugins ${argv[2]} exited ${result.code}: ${lastLine(result.output)}`);
|
|
676
|
+
}
|
|
677
|
+
return saved;
|
|
678
|
+
},
|
|
679
|
+
manifest,
|
|
680
|
+
landed: () => existsSync(resolved(file)) ? "the plugin it wrote" : void 0
|
|
681
|
+
});
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (!content) {
|
|
685
|
+
note(plan, harness, `hooks: up to date in ${file}${held}`);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const what = kind === "opencode" ? `write the ${CLI_NAME} plugin ${file}` : `merge the ${CLI_NAME} hooks into ${file}`;
|
|
689
|
+
const why = harness === "grok" ? " (Grok runs the Claude Code hooks; claude is not installed)" : kind === "codex" ? " (Codex runs them after you review them in /hooks)" : "";
|
|
690
|
+
addStep(plan, {
|
|
691
|
+
harness,
|
|
692
|
+
label: "hooks",
|
|
693
|
+
file,
|
|
694
|
+
plan: [`${what}${why}${held}`],
|
|
695
|
+
apply: () => {
|
|
696
|
+
const next = desiredHooks(kind, file, blockStop);
|
|
697
|
+
return next === void 0 ? void 0 : writeFile(file, next, now, { mode: 420 });
|
|
698
|
+
},
|
|
699
|
+
manifest
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
function planUnhooks(plan, harness, ctx) {
|
|
703
|
+
const kind = harness === "grok" ? "claude" : HOOKED.has(harness) ? harness : void 0;
|
|
704
|
+
if (!kind) return;
|
|
705
|
+
const file = hooksTarget(kind, "user", ctx.env, ctx.cwd);
|
|
706
|
+
const items = ctx.added.filter(
|
|
707
|
+
(item) => item.kind === "hooks" && (kind === "claude" ? item.harness === "claude" || item.harness === "grok" : item.harness === harness)
|
|
708
|
+
);
|
|
709
|
+
if (harness === "grok" && items.length) {
|
|
710
|
+
if (ctx.wanted.includes("claude")) return;
|
|
711
|
+
if (ctx.found.has("claude")) {
|
|
712
|
+
note(plan, harness, `hooks: keeps the hooks in ${file}, which Claude Code runs too; undo them with --harness claude`);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (!items.length) {
|
|
717
|
+
if (harness !== "grok" && hasHooks(kind, file)) note(plan, harness, `hooks: keeps the hooks in ${file}, which setup did not add`);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
for (const path of new Set(items.map((item) => item.file)))
|
|
721
|
+
planUnhook(
|
|
722
|
+
plan,
|
|
723
|
+
harness,
|
|
724
|
+
kind,
|
|
725
|
+
path,
|
|
726
|
+
items.filter((item) => item.file === path),
|
|
727
|
+
ctx
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
function planUnhook(plan, harness, kind, file, items, ctx) {
|
|
731
|
+
const { now, run } = ctx;
|
|
732
|
+
let target;
|
|
733
|
+
try {
|
|
734
|
+
target = resolved(file);
|
|
735
|
+
} catch (error) {
|
|
736
|
+
problem(plan, harness, `hooks: skipped, ${message(error)}`);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const forgotten = (added) => added.filter((other) => !items.some((item) => sameItem(item, other)));
|
|
740
|
+
const gone = { harness, label: "hooks", file, plan: [`forget ${file}, which is gone`], apply: () => void 0, manifest: forgotten };
|
|
741
|
+
if (kind === "muse") {
|
|
742
|
+
const cli = ctx.found.get("muse");
|
|
743
|
+
const installed = installedFrom(museInstalled(ctx.env)) === pluginDir(file);
|
|
744
|
+
if (!existsSync(target) && !installed) {
|
|
745
|
+
addStep(plan, gone);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
if (installed && !cli) {
|
|
749
|
+
problem(plan, harness, "hooks: Muse still has the scp plugin, and muse is not installed to remove it");
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const remove = installed ? [cli, "plugins", "remove", SERVER] : void 0;
|
|
753
|
+
addStep(plan, {
|
|
754
|
+
harness,
|
|
755
|
+
label: "hooks",
|
|
756
|
+
file,
|
|
757
|
+
plan: [`remove the ${CLI_NAME} plugin ${file}`, ...remove ? [shown(remove)] : []],
|
|
758
|
+
apply: () => {
|
|
759
|
+
if (remove) {
|
|
760
|
+
const result = run(remove);
|
|
761
|
+
if (result.code !== 0) throw new Error(`muse plugins remove exited ${result.code}: ${lastLine(result.output)}`);
|
|
762
|
+
}
|
|
763
|
+
if (!existsSync(target)) return void 0;
|
|
764
|
+
const saved = backup(target, now);
|
|
765
|
+
unlinkSync(target);
|
|
766
|
+
return saved;
|
|
767
|
+
},
|
|
768
|
+
manifest: forgotten
|
|
769
|
+
});
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (!existsSync(target)) {
|
|
773
|
+
addStep(plan, gone);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (kind === "opencode") {
|
|
777
|
+
const text = readFileSync(target, "utf8");
|
|
778
|
+
if (!text.startsWith(OPENCODE_MARK) || !/^const BLOCK_STOP = (true|false)$/m.test(text)) {
|
|
779
|
+
note(plan, harness, `hooks: ${file} is not a ${CLI_NAME} plugin any more; left in place`);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
addStep(plan, {
|
|
783
|
+
harness,
|
|
784
|
+
label: "hooks",
|
|
785
|
+
file,
|
|
786
|
+
plan: [`remove the plugin file ${file}`],
|
|
787
|
+
apply: () => {
|
|
788
|
+
const saved = backup(target, now);
|
|
789
|
+
unlinkSync(target);
|
|
790
|
+
return saved;
|
|
791
|
+
},
|
|
792
|
+
manifest: forgotten
|
|
793
|
+
});
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
let edit;
|
|
797
|
+
try {
|
|
798
|
+
edit = jsonEdit(
|
|
799
|
+
file,
|
|
800
|
+
(config) => {
|
|
801
|
+
if (kind === "agy") {
|
|
802
|
+
if (!(AGY_HOOK_SET in config)) return void 0;
|
|
803
|
+
const { [AGY_HOOK_SET]: _ours, ...rest } = config;
|
|
804
|
+
return rest;
|
|
805
|
+
}
|
|
806
|
+
if (!isObject(config.hooks)) return void 0;
|
|
807
|
+
const merged = mergeHooks(config, {});
|
|
808
|
+
return JSON.stringify(merged.hooks) === JSON.stringify(config.hooks) ? void 0 : merged;
|
|
809
|
+
},
|
|
810
|
+
now
|
|
811
|
+
);
|
|
812
|
+
} catch (error) {
|
|
813
|
+
problem(plan, harness, `hooks: skipped, ${file} is unreadable (${message(error)})`);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
addStep(
|
|
817
|
+
plan,
|
|
818
|
+
edit ? { harness, label: "hooks", file, plan: [`remove the ${CLI_NAME} hooks from ${file}`], apply: edit.apply, manifest: forgotten } : { ...gone, plan: [`forget ${file}, which has no ${CLI_NAME} hooks left`] }
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
function planUnmount(plan, harness, ctx) {
|
|
822
|
+
const { now, run } = ctx;
|
|
823
|
+
const cli = ctx.found.get(harness);
|
|
824
|
+
const item = ctx.added.find((other) => other.harness === harness && other.kind === "mcp");
|
|
825
|
+
const file = item?.file ?? mcpFile(harness, ctx.env);
|
|
826
|
+
let entry;
|
|
827
|
+
try {
|
|
828
|
+
entry = mountedEntry(harness, file);
|
|
829
|
+
} catch (error) {
|
|
830
|
+
problem(plan, harness, `mcp: skipped, ${file} is unreadable (${message(error)})`);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (!item) {
|
|
834
|
+
if (entry) note(plan, harness, `mcp: keeps the ${SERVER} in ${file}, which setup did not add`);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (!entry) {
|
|
838
|
+
addStep(plan, { harness, label: "mcp", file, plan: [`forget ${SERVER} in ${file}, which is gone`], apply: () => void 0, manifest: forget(item) });
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (harness !== "opencode" && harness !== "muse" && !cli) {
|
|
842
|
+
problem(plan, harness, `mcp: ${file} still mounts ${SERVER}, and ${harness} is not installed to remove it; remove it by hand`);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const command = harness === "claude" || harness === "grok" ? [cli, "mcp", "remove", "--scope", "user", SERVER] : harness === "codex" || harness === "agy" ? [cli, "mcp", "remove", SERVER] : void 0;
|
|
846
|
+
if (command) {
|
|
847
|
+
addStep(plan, {
|
|
848
|
+
harness,
|
|
849
|
+
label: "mcp",
|
|
850
|
+
file,
|
|
851
|
+
plan: [`remove ${SERVER} from ${file}`, shown(command)],
|
|
852
|
+
apply: () => runEditing(file, now, run, [command]),
|
|
853
|
+
manifest: forget(item)
|
|
854
|
+
});
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const key = harness === "opencode" ? "mcp" : "mcpServers";
|
|
858
|
+
const edit = jsonEdit(
|
|
859
|
+
file,
|
|
860
|
+
(config) => {
|
|
861
|
+
const { [SERVER]: _ours, ...rest } = config[key];
|
|
862
|
+
return { ...config, [key]: rest };
|
|
863
|
+
},
|
|
864
|
+
now
|
|
865
|
+
);
|
|
866
|
+
if (edit) addStep(plan, { harness, label: "mcp", file, plan: [`remove ${key}.${SERVER} from ${file}`], apply: edit.apply, manifest: forget(item) });
|
|
867
|
+
}
|
|
868
|
+
var LISTING = {
|
|
869
|
+
claude: ["mcp", "get", SERVER],
|
|
870
|
+
codex: ["mcp", "list"],
|
|
871
|
+
opencode: ["mcp", "list"],
|
|
872
|
+
grok: ["mcp", "list"],
|
|
873
|
+
agy: ["mcp", "list"]
|
|
874
|
+
};
|
|
875
|
+
function verifyMount(harness, cli, env, run, expected) {
|
|
876
|
+
const args = LISTING[harness];
|
|
877
|
+
if (!args) {
|
|
878
|
+
let entry;
|
|
879
|
+
try {
|
|
880
|
+
entry = mountedEntry(harness, mcpFile(harness, env));
|
|
881
|
+
} catch {
|
|
882
|
+
}
|
|
883
|
+
const right2 = Boolean(entry) && (!expected || entryRight(harness, entry, expected));
|
|
884
|
+
const where = `in ${mcpFile(harness, env)} (Muse has no MCP listing command)`;
|
|
885
|
+
if (!expected) return { ok: !entry, line: `${entry ? "still configured" : "removed"} ${where}` };
|
|
886
|
+
return { ok: right2, line: `${right2 ? "configured" : entry ? "failed: a different scp is configured" : "failed: missing"} ${where}` };
|
|
887
|
+
}
|
|
888
|
+
const command = `${basename(cli)} ${args.join(" ")}`;
|
|
889
|
+
const result = run([cli, ...args]);
|
|
890
|
+
const text = stripAnsi(result.output);
|
|
891
|
+
const lines = text.split("\n").map((line2) => line2.trim());
|
|
892
|
+
const named = new RegExp(`(^|\\s)${SERVER}(:|\\s|$)`);
|
|
893
|
+
const listed = harness === "claude" ? result.code === 0 && text.includes(`${SERVER}:`) : lines.some((line2) => named.test(line2));
|
|
894
|
+
if (!expected) {
|
|
895
|
+
const gone = harness === "claude" ? new RegExp(`No MCP server named "${SERVER}"`).test(text) : result.code === 0 && !listed;
|
|
896
|
+
return {
|
|
897
|
+
ok: gone,
|
|
898
|
+
line: gone ? `removed (${command})` : listed ? `still listed by ${command}` : `unknown: ${command} exited ${result.code}: ${lastLine(result.output)}`
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
if (!listed)
|
|
902
|
+
return { ok: false, line: `failed: ${command} does not list ${SERVER}${result.code ? ` (exit ${result.code}: ${lastLine(result.output)})` : ""}` };
|
|
903
|
+
if (harness === "claude") {
|
|
904
|
+
const field = (name) => lines.find((line2) => line2.startsWith(`${name}:`))?.slice(name.length + 1).trim();
|
|
905
|
+
const runs = [field("Command"), field("Args")].filter(Boolean).join(" ");
|
|
906
|
+
if (runs !== expected.join(" ")) return { ok: false, line: `failed (${command}: it runs ${runs || "an unlisted command"}, not ${expected.join(" ")})` };
|
|
907
|
+
}
|
|
908
|
+
if (harness === "opencode") {
|
|
909
|
+
const file = mcpFile(harness, env);
|
|
910
|
+
let entry;
|
|
911
|
+
try {
|
|
912
|
+
entry = mountedEntry(harness, file);
|
|
913
|
+
} catch {
|
|
914
|
+
}
|
|
915
|
+
if (!entry || !entryRight(harness, entry, expected))
|
|
916
|
+
return { ok: false, line: `failed: the ${SERVER} in ${file} is not this local one (${command} lists a ${SERVER})` };
|
|
917
|
+
}
|
|
918
|
+
if (harness === "claude" || harness === "opencode") {
|
|
919
|
+
const status = harness === "claude" ? lines.find((line2) => line2.startsWith("Status:")) : lines.find((line2) => named.test(line2));
|
|
920
|
+
const connected = /[✔✓]/.test(status ?? "") && /\bconnected\b/i.test(status ?? "") && !/disconnected|fail/i.test(status ?? "");
|
|
921
|
+
return { ok: connected, line: `${connected ? "mounted" : "failed"} (${command}: ${status ?? "no status"})` };
|
|
922
|
+
}
|
|
923
|
+
const line = lines.find((entry) => named.test(entry));
|
|
924
|
+
const flat = line.replace(/\s+/g, " ");
|
|
925
|
+
const right = ` ${flat} `.includes(` ${expected.join(" ")} `) && !/\bdisabled\b/i.test(flat);
|
|
926
|
+
return { ok: right, line: right ? `configured (${command} lists it; ${basename(cli)} does not start servers to check them)` : `failed (${command}: ${line})` };
|
|
927
|
+
}
|
|
928
|
+
function verifyMusePlugin(cli, dir, run, manifest) {
|
|
929
|
+
const result = run([cli, "plugins", "list", "--json"]);
|
|
930
|
+
let record;
|
|
931
|
+
try {
|
|
932
|
+
const plugins = JSON.parse(result.output).plugins;
|
|
933
|
+
const found = Array.isArray(plugins) ? plugins.find((plugin) => isObject(plugin) && isObject(plugin.record) && plugin.record.id === SERVER) : void 0;
|
|
934
|
+
record = isObject(found) && isObject(found.record) ? found.record : void 0;
|
|
935
|
+
} catch {
|
|
936
|
+
return { ok: false, line: `unknown: muse plugins list --json exited ${result.code}: ${lastLine(result.output)}` };
|
|
937
|
+
}
|
|
938
|
+
const ours = installedFrom(record) === dir;
|
|
939
|
+
if (manifest === void 0) return { ok: !ours, line: ours ? "still installed (muse plugins list)" : "removed (muse plugins list)" };
|
|
940
|
+
if (!ours) return { ok: false, line: "failed: muse plugins list does not show the scp plugin from setup" };
|
|
941
|
+
const current = record.manifest_sha256 === sha256(manifest) && record.enabled !== false;
|
|
942
|
+
return {
|
|
943
|
+
ok: current,
|
|
944
|
+
line: current ? "installed (muse plugins list)" : "failed: Muse has another version of the plugin, or it is disabled (muse plugins list)"
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
function relaySetup(options, deps) {
|
|
948
|
+
const env = deps.env;
|
|
949
|
+
const now = deps.now ?? /* @__PURE__ */ new Date();
|
|
950
|
+
const which = deps.which ?? ((name) => whichOnPath(name, env));
|
|
951
|
+
const run = deps.run ?? ((argv, cwd) => runCommand(argv, env, cwd));
|
|
952
|
+
const out = (line) => deps.out(tilde(line, env));
|
|
953
|
+
if (options.harness !== void 0 && !SETUP_HARNESSES.includes(options.harness)) {
|
|
954
|
+
throw new RelayUsage(`--harness is one of ${SETUP_HARNESSES.join(", ")}`);
|
|
955
|
+
}
|
|
956
|
+
if (options.blockStop && (options.hooks === false || options.undo)) throw new RelayUsage(`${BLOCK_STOP} goes with the hooks: not with --no-hooks or --undo`);
|
|
957
|
+
let added;
|
|
958
|
+
try {
|
|
959
|
+
added = readManifest(env);
|
|
960
|
+
} catch (error) {
|
|
961
|
+
throw new RelayUsage(`${manifestFile(env)}, setup's list of what it added, is unreadable (${message(error)}); fix or remove it`);
|
|
962
|
+
}
|
|
963
|
+
const onPath = which(CLI_NAME);
|
|
964
|
+
let bin;
|
|
965
|
+
if (options.bin !== void 0) {
|
|
966
|
+
if (!isAbsolute(options.bin)) throw new RelayUsage(`--bin takes an absolute path to the ${CLI_NAME} binary`);
|
|
967
|
+
if (!executable(options.bin)) throw new RelayUsage(`--bin ${options.bin} is not an executable file`);
|
|
968
|
+
bin = options.bin;
|
|
969
|
+
} else if (onPath) bin = onPath;
|
|
970
|
+
else if (options.undo) bin = CLI_NAME;
|
|
971
|
+
else throw new RelayUsage(`${CLI_NAME} is not on PATH: pass --bin /absolute/path/to/${CLI_NAME} (the mounts then run that path)`);
|
|
972
|
+
const found = detectHarnesses(env, which);
|
|
973
|
+
const wanted = SETUP_HARNESSES.filter((harness) => options.harness === void 0 || options.harness === harness);
|
|
974
|
+
const selected = wanted.filter((harness) => found.has(harness));
|
|
975
|
+
const missing = wanted.filter((harness) => !found.has(harness));
|
|
976
|
+
out(`${CLI_NAME} setup${options.undo ? " --undo" : ""}: ${options.write ? "applying" : "dry run, nothing is written (add --write to apply)"}`);
|
|
977
|
+
let hooks = options.hooks !== false;
|
|
978
|
+
let unresolved = 0;
|
|
979
|
+
if (options.hooks === false) out(`hooks: ${options.undo ? "kept (--no-hooks)" : "not installed (--no-hooks); only the MCP shim is mounted"}`);
|
|
980
|
+
if (!options.undo) {
|
|
981
|
+
if (options.bin !== void 0) out(`mounts run: ${bin} (from --bin; they break if that file moves)`);
|
|
982
|
+
else {
|
|
983
|
+
const target = resolved(bin);
|
|
984
|
+
out(
|
|
985
|
+
`mounts run: ${bin} (${CLI_NAME} on PATH, by that path${target !== bin ? ` and not its target ${target}` : ""}, so a harness started without your PATH finds it)`
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
const help = run([bin, "help", "--all"]).output;
|
|
989
|
+
if (!help.includes(`${CLI_NAME} mcp`)) {
|
|
990
|
+
out(`! ${bin} has no \`${CLI_NAME} mcp\` (its \`help --all\` does not list it): update it, or pass --bin with a ${CLI_NAME} that has the relay`);
|
|
991
|
+
if (options.write) return 1;
|
|
992
|
+
}
|
|
993
|
+
if (hooks && env.SCP_TRANSPORT === "cloud") {
|
|
994
|
+
out(
|
|
995
|
+
`! the hooks report to the local broker, and SCP_TRANSPORT=cloud bypasses it: hooks are skipped; link the broker instead (${CLI_NAME} cloud enable), unset it and rerun`
|
|
996
|
+
);
|
|
997
|
+
hooks = false;
|
|
998
|
+
unresolved++;
|
|
999
|
+
} else if (hooks && !onPath) {
|
|
1000
|
+
out(`! the hooks run \`${CLI_NAME} hook\` from PATH, where there is no ${CLI_NAME}: hooks are skipped until it is there`);
|
|
1001
|
+
hooks = false;
|
|
1002
|
+
unresolved++;
|
|
1003
|
+
} else if (hooks && !(options.bin === void 0 ? help : run([onPath, "help", "--all"]).output).includes(`${CLI_NAME} hook`)) {
|
|
1004
|
+
out(`! the hooks run \`${CLI_NAME} hook\` from PATH, and ${onPath} has no \`${CLI_NAME} hook\`: hooks are skipped until it does`);
|
|
1005
|
+
hooks = false;
|
|
1006
|
+
unresolved++;
|
|
1007
|
+
} else if (hooks) out(`hooks run: ${CLI_NAME} from each harness's PATH (${onPath} here${options.bin !== void 0 ? ", not --bin" : ""})`);
|
|
1008
|
+
}
|
|
1009
|
+
out(`found: ${selected.map((harness) => `${harness} (${found.get(harness)})`).join(", ") || "none"}`);
|
|
1010
|
+
if (missing.length) out(`not installed: ${missing.join(", ")}`);
|
|
1011
|
+
const plan = { steps: [], lines: [], problems: 0, hooks: /* @__PURE__ */ new Map() };
|
|
1012
|
+
const ctx = { env, cwd: deps.cwd, now, run, found, added, wanted, blockStop: options.blockStop === true };
|
|
1013
|
+
for (const harness of options.undo ? wanted : selected) {
|
|
1014
|
+
if (options.undo) planUnmount(plan, harness, ctx);
|
|
1015
|
+
else planMount(plan, harness, bin, ctx);
|
|
1016
|
+
if (harness === "agy") planAgyRules(plan, ctx, options.undo === true);
|
|
1017
|
+
if (hooks) (options.undo ? planUnhooks : planHooks)(plan, harness, ctx);
|
|
1018
|
+
}
|
|
1019
|
+
for (const harness of wanted) {
|
|
1020
|
+
const mine = plan.lines.filter((line) => line.harness === harness);
|
|
1021
|
+
if (!found.has(harness) && !mine.length) continue;
|
|
1022
|
+
out("");
|
|
1023
|
+
out(`${harness}${found.has(harness) ? "" : " (not installed)"}:`);
|
|
1024
|
+
for (const { text, step } of mine) {
|
|
1025
|
+
if (text) out(` ${text}`);
|
|
1026
|
+
if (!step) continue;
|
|
1027
|
+
const [head, ...rest] = step.plan;
|
|
1028
|
+
out(` ${step.label}: ${head}`);
|
|
1029
|
+
for (const line of rest) deps.out(` ${line}`);
|
|
1030
|
+
}
|
|
1031
|
+
if (options.undo && !mine.length) out(" nothing setup added");
|
|
1032
|
+
}
|
|
1033
|
+
unresolved += plan.problems;
|
|
1034
|
+
out("");
|
|
1035
|
+
if (!plan.steps.length) out(options.undo ? "nothing to remove" : "everything is in place; nothing to do");
|
|
1036
|
+
else if (!options.write) out(`${plan.steps.length} change${plan.steps.length === 1 ? "" : "s"} planned; rerun with --write to apply`);
|
|
1037
|
+
if (unresolved) out(`${unresolved} item${unresolved === 1 ? "" : "s"} marked ! ${options.write ? "need" : "will need"} a fix by hand or a rerun`);
|
|
1038
|
+
if (!options.write) return 0;
|
|
1039
|
+
let failures = unresolved;
|
|
1040
|
+
const applied = /* @__PURE__ */ new Set();
|
|
1041
|
+
for (const step of plan.steps) {
|
|
1042
|
+
try {
|
|
1043
|
+
const saved = step.apply();
|
|
1044
|
+
applied.add(step);
|
|
1045
|
+
if (step.manifest) writeManifest(env, now, step.manifest);
|
|
1046
|
+
out(`${step.harness} ${step.label}: done${saved ? ` (backup ${saved})` : ""}`);
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
failures++;
|
|
1049
|
+
let kept;
|
|
1050
|
+
try {
|
|
1051
|
+
kept = step.manifest && step.landed?.();
|
|
1052
|
+
if (kept) writeManifest(env, now, step.manifest);
|
|
1053
|
+
} catch {
|
|
1054
|
+
kept = void 0;
|
|
1055
|
+
}
|
|
1056
|
+
out(`${step.harness} ${step.label}: FAILED, ${message(error)}${kept ? `; ${kept} is recorded, so --undo removes it` : ""}`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
out("");
|
|
1060
|
+
out("verify:");
|
|
1061
|
+
const check = (ok, line) => {
|
|
1062
|
+
if (!ok) failures++;
|
|
1063
|
+
out(` ${line}`);
|
|
1064
|
+
};
|
|
1065
|
+
const scratch = mkdtempSync(join(tmpdir(), "scp-setup-"));
|
|
1066
|
+
const neutral = join(scratch, "relay-setup-check");
|
|
1067
|
+
mkdirSync(neutral);
|
|
1068
|
+
const runNeutral = (argv) => run(argv, neutral);
|
|
1069
|
+
try {
|
|
1070
|
+
for (const harness of selected) {
|
|
1071
|
+
const cli = found.get(harness);
|
|
1072
|
+
if (options.undo) {
|
|
1073
|
+
if (plan.steps.some((step) => step.harness === harness && step.label === "mcp" && applied.has(step))) {
|
|
1074
|
+
const result2 = verifyMount(harness, cli, env, runNeutral);
|
|
1075
|
+
check(result2.ok, `${harness}: ${result2.line}`);
|
|
1076
|
+
}
|
|
1077
|
+
const plugin = plan.steps.find((step) => step.harness === harness && harness === "muse" && step.label === "hooks" && applied.has(step));
|
|
1078
|
+
if (plugin) {
|
|
1079
|
+
const result2 = verifyMusePlugin(cli, pluginDir(plugin.file), runNeutral);
|
|
1080
|
+
check(result2.ok, `muse hooks: ${result2.line}`);
|
|
1081
|
+
}
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
const result = verifyMount(harness, cli, env, runNeutral, mountArgv(harness, bin));
|
|
1085
|
+
check(result.ok, `${harness}: ${result.line}`);
|
|
1086
|
+
if (harness === "agy") {
|
|
1087
|
+
let allow;
|
|
1088
|
+
try {
|
|
1089
|
+
allow = readJson(agySettingsFile(env))?.permissions?.allow;
|
|
1090
|
+
} catch {
|
|
1091
|
+
}
|
|
1092
|
+
const present = agyRules().filter((rule) => Array.isArray(allow) && allow.includes(rule)).length;
|
|
1093
|
+
check(present === agyRules().length, `agy permissions: ${present} of ${agyRules().length} relay tool rules in ${agySettingsFile(env)}`);
|
|
1094
|
+
}
|
|
1095
|
+
const target = plan.hooks.get(harness);
|
|
1096
|
+
if (!target) continue;
|
|
1097
|
+
let inPlace = false;
|
|
1098
|
+
try {
|
|
1099
|
+
inPlace = desiredHooks(target.kind, target.file, target.blockStop) === void 0;
|
|
1100
|
+
} catch {
|
|
1101
|
+
}
|
|
1102
|
+
check(inPlace, `${harness} hooks: ${inPlace ? "in place" : "missing"} in ${target.file}`);
|
|
1103
|
+
if (harness === "claude" && inPlace) cacheClaudeVersion(cli, env, runNeutral);
|
|
1104
|
+
if (target.kind === "muse" && inPlace) {
|
|
1105
|
+
const plugin = verifyMusePlugin(cli, pluginDir(target.file), runNeutral, readFileSync(resolved(target.file), "utf8"));
|
|
1106
|
+
check(plugin.ok, `muse hooks: ${plugin.line}`);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
} finally {
|
|
1110
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
1111
|
+
}
|
|
1112
|
+
if (plan.steps.some((step) => step.label === "hooks" && applied.has(step))) {
|
|
1113
|
+
out("");
|
|
1114
|
+
out("restart sessions started before setup: harnesses read hooks only at session start");
|
|
1115
|
+
}
|
|
1116
|
+
return failures ? 1 : 0;
|
|
1117
|
+
}
|
|
1118
|
+
function cacheClaudeVersion(cli, env, run) {
|
|
1119
|
+
const result = run([cli, "--version"]);
|
|
1120
|
+
const version = /\d+\.\d+\.\d+/.exec(result.output)?.[0];
|
|
1121
|
+
if (result.code !== 0 || !version) return;
|
|
1122
|
+
try {
|
|
1123
|
+
const file = claudeVersionFile(env);
|
|
1124
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1125
|
+
writeFileSync(file, `${version}
|
|
1126
|
+
`);
|
|
1127
|
+
} catch {
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
function parseSetupArgs(args) {
|
|
1131
|
+
const options = {
|
|
1132
|
+
write: { type: "boolean" },
|
|
1133
|
+
undo: { type: "boolean" },
|
|
1134
|
+
hooks: { type: "boolean" },
|
|
1135
|
+
"no-hooks": { type: "boolean" },
|
|
1136
|
+
"block-stop-on-unread": { type: "boolean" },
|
|
1137
|
+
bin: { type: "string" },
|
|
1138
|
+
harness: { type: "string" },
|
|
1139
|
+
help: { type: "boolean", short: "h" }
|
|
1140
|
+
};
|
|
1141
|
+
let parsed;
|
|
1142
|
+
try {
|
|
1143
|
+
parsed = parseArgs({
|
|
1144
|
+
args,
|
|
1145
|
+
options,
|
|
1146
|
+
allowPositionals: false,
|
|
1147
|
+
strict: true
|
|
1148
|
+
});
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
throw new RelayUsage(message(error));
|
|
1151
|
+
}
|
|
1152
|
+
const { help, hooks, "no-hooks": noHooks, "block-stop-on-unread": blockStop, ...rest } = parsed.values;
|
|
1153
|
+
if (help) return "help";
|
|
1154
|
+
if (hooks && noHooks) throw new RelayUsage("--hooks and --no-hooks contradict each other; the hooks are installed unless --no-hooks");
|
|
1155
|
+
return { ...rest, ...noHooks ? { hooks: false } : {}, ...blockStop ? { blockStop } : {} };
|
|
1156
|
+
}
|
|
1157
|
+
function relaySetupCommand(args, cwd, out) {
|
|
1158
|
+
const options = parseSetupArgs(args);
|
|
1159
|
+
if (options === "help") {
|
|
1160
|
+
out(SETUP_HELP);
|
|
1161
|
+
return 0;
|
|
1162
|
+
}
|
|
1163
|
+
return relaySetup(options, { env: process.env, cwd, out });
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
export {
|
|
1167
|
+
SETUP_HARNESSES,
|
|
1168
|
+
SETUP_HELP,
|
|
1169
|
+
whichOnPath,
|
|
1170
|
+
detectHarnesses,
|
|
1171
|
+
mcpFile,
|
|
1172
|
+
agySettingsFile,
|
|
1173
|
+
manifestFile,
|
|
1174
|
+
mountedEntry,
|
|
1175
|
+
mountArgv,
|
|
1176
|
+
agyRules,
|
|
1177
|
+
verifyMount,
|
|
1178
|
+
verifyMusePlugin,
|
|
1179
|
+
relaySetup,
|
|
1180
|
+
parseSetupArgs,
|
|
1181
|
+
relaySetupCommand
|
|
1182
|
+
};
|