@getmarrow/install 0.1.67 → 0.1.68
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 +174 -290
- package/package.json +1 -1
- package/src/controller-manager.js +3 -1
- package/src/first-hour.js +1 -1
- package/src/governed-runner.js +1978 -114
- package/src/installer.js +821 -72
- package/src/mcp-runtime.js +447 -0
- package/src/pins.js +19 -0
package/src/installer.js
CHANGED
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
controllerStatus,
|
|
9
9
|
controllerSupportedPlatform,
|
|
10
10
|
ensureCurrentGovernanceController,
|
|
11
|
+
stopProjectControllers,
|
|
11
12
|
} = require('./controller-manager');
|
|
12
13
|
const { firstCapturePath, harnessReloadPlan } = require('./first-hour');
|
|
13
14
|
const { evidence: localControlEvidence } = require('./control-state');
|
|
@@ -19,15 +20,26 @@ const {
|
|
|
19
20
|
planHermesMcpConfig,
|
|
20
21
|
} = require('./hermes-config');
|
|
21
22
|
const { ensureOwnerApiKey, readOwnerApiKey } = require('./owner-env');
|
|
23
|
+
const {
|
|
24
|
+
delocalizeHookCommand,
|
|
25
|
+
ensureMcpRuntime,
|
|
26
|
+
localizeHookSettingsText,
|
|
27
|
+
mapHookCommands,
|
|
28
|
+
removeMcpRuntime,
|
|
29
|
+
verifyMcpRuntime,
|
|
30
|
+
} = require('./mcp-runtime');
|
|
31
|
+
const {
|
|
32
|
+
MCP_ADAPTER_VERSION,
|
|
33
|
+
MCP_ADAPTER_SOURCE_SHA,
|
|
34
|
+
MCP_ADAPTER_INTEGRITY,
|
|
35
|
+
MCP_HOST_APPROVAL_HOOKS_SINCE,
|
|
36
|
+
SDK_ADAPTER_VERSION,
|
|
37
|
+
SDK_ADAPTER_INTEGRITY,
|
|
38
|
+
} = require('./pins');
|
|
22
39
|
|
|
23
40
|
const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
|
|
24
41
|
const MARROW_BLOCK_START = '<!-- marrow:passive-start -->';
|
|
25
42
|
const MARROW_BLOCK_END = '<!-- marrow:passive-end -->';
|
|
26
|
-
const MCP_ADAPTER_VERSION = '3.9.98';
|
|
27
|
-
const MCP_ADAPTER_SOURCE_SHA = 'e40d3cb40479456fd937bce0b9488eb0c3f10863';
|
|
28
|
-
const MCP_ADAPTER_INTEGRITY = 'sha512-AmDT3afwdm7+Dc555zDs+yGIG4RyC/YbaQm+9O1mThlC6g/9EujTr7y7UvRMEtYDvVWAAaG6CFM7/u/ytjKhwQ==';
|
|
29
|
-
const SDK_ADAPTER_VERSION = '3.7.64';
|
|
30
|
-
const SDK_ADAPTER_INTEGRITY = 'sha512-8qJj/8ouHEz1NnZkmujtFxUm/fWldqR/rHv62/sqabaxT0H90xCCHxodLOkV0SVxDscAtnahioZakqkeGNUwyA==';
|
|
31
43
|
const SDK_ADAPTER_TARBALL = `https://registry.npmjs.org/@getmarrow/sdk/-/sdk-${SDK_ADAPTER_VERSION}.tgz`;
|
|
32
44
|
const MCP_PACKAGE_SPEC = `@getmarrow/mcp@${MCP_ADAPTER_VERSION}`;
|
|
33
45
|
const ADAPTER_PROVENANCE = Object.freeze({
|
|
@@ -97,6 +109,12 @@ function compatibleMcpTargetVersion(value) {
|
|
|
97
109
|
&& compareMcpVersions(value, MCP_ADAPTER_VERSION) >= 0);
|
|
98
110
|
}
|
|
99
111
|
|
|
112
|
+
// Whether an MCP version answers the host-approval hooks (see MCP_HOST_APPROVAL_HOOKS_SINCE).
|
|
113
|
+
function hostApprovalHooksSupported(version = MCP_ADAPTER_VERSION) {
|
|
114
|
+
const comparison = compareMcpVersions(version, MCP_HOST_APPROVAL_HOOKS_SINCE);
|
|
115
|
+
return comparison !== null && comparison >= 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
100
118
|
function mcpVersionsInText(value) {
|
|
101
119
|
return [...String(value || '').matchAll(/@getmarrow\/mcp@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g)]
|
|
102
120
|
.map((match) => match[1]);
|
|
@@ -248,6 +266,11 @@ const MCP_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mc
|
|
|
248
266
|
const MCP_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-pre-action-hook`;
|
|
249
267
|
const MCP_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-hook`;
|
|
250
268
|
const MCP_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-session-hook`;
|
|
269
|
+
// Host approvals: a pass-through PermissionRequest hook (installed async) that only notes that
|
|
270
|
+
// Claude Code is about to show its own permission dialog for a held call, and PostToolBatch
|
|
271
|
+
// (async, the result entrypoint) that settles a declined dialog. Same spelling and fields as
|
|
272
|
+
// `marrow-mcp setup` writes, so neither writer rewrites the other.
|
|
273
|
+
const MCP_PERMISSION_REQUEST_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-permission-request-hook`;
|
|
251
274
|
const CODEX_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp codex-context-hook`;
|
|
252
275
|
const CODEX_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp codex-pre-action-hook`;
|
|
253
276
|
const CODEX_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp codex-hook`;
|
|
@@ -255,6 +278,29 @@ const CODEX_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} mar
|
|
|
255
278
|
const CURSOR_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cursor-pre-action-hook`;
|
|
256
279
|
const CURSOR_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cursor-hook`;
|
|
257
280
|
const CURSOR_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cursor-session-hook`;
|
|
281
|
+
const CURSOR_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cursor-context-hook`;
|
|
282
|
+
// Cursor's beforeMCPExecution takes no matcher, so this guard answers allow at once for
|
|
283
|
+
// Marrow's own tools (server "marrow", tools marrow_*), as the old preToolUse matcher exempted
|
|
284
|
+
// them, without starting npx. Every other call goes to the MCP entrypoint with its input
|
|
285
|
+
// unchanged; a crash, a non-zero exit or output that is not a JSON object blocks (exit 2).
|
|
286
|
+
const CURSOR_MCP_GUARD_FAILURE = 'Marrow governance adapter was unavailable; this MCP call is blocked.';
|
|
287
|
+
const CURSOR_MCP_PRE_ACTION_GUARD_SOURCE = [
|
|
288
|
+
'const {spawn}=require("node:child_process");',
|
|
289
|
+
'const chunks=[];let size=0,over=false,done=false;',
|
|
290
|
+
`const fail=()=>{if(done)return;done=true;process.exitCode=2;process.stderr.write(${JSON.stringify(`${CURSOR_MCP_GUARD_FAILURE}\n`)},()=>process.exit(2));};`,
|
|
291
|
+
'process.stdin.on("error",fail);',
|
|
292
|
+
'process.stdin.on("data",c=>{size+=c.length;if(size>16777216){over=true;return;}chunks.push(c);});',
|
|
293
|
+
'process.stdin.on("end",()=>{if(over){fail();return;}const raw=Buffer.concat(chunks);let ev=null;try{ev=JSON.parse(raw.toString("utf8"));}catch{ev=null;}',
|
|
294
|
+
'if(ev&&typeof ev==="object"&&ev.mcp_server_name==="marrow"&&typeof ev.tool_name==="string"&&/^marrow_[a-z0-9_]{1,64}$/.test(ev.tool_name)){done=true;process.stdout.write("{\\"permission\\":\\"allow\\"}\\n");return;}',
|
|
295
|
+
'let child;try{',
|
|
296
|
+
`child=spawn(process.platform==="win32"?"npx.cmd":"npx",${JSON.stringify(['-y', `--package=${MCP_PACKAGE_SPEC}`, 'marrow-mcp', 'cursor-pre-action-hook'])},{stdio:["pipe","pipe","ignore"]});`,
|
|
297
|
+
'}catch{fail();return;}',
|
|
298
|
+
'let out="",bytes=0;child.stdout.on("data",c=>{bytes+=c.length;if(bytes<=65536)out+=c.toString("utf8");});',
|
|
299
|
+
'child.on("error",fail);child.stdin.on("error",()=>{});',
|
|
300
|
+
'child.on("close",code=>{if(done)return;let p=null;try{p=JSON.parse(out);}catch{p=null;}if(code!==0||bytes>65536||!p||typeof p!=="object"||Array.isArray(p)){fail();return;}done=true;process.stdout.write(JSON.stringify(p)+"\\n");});',
|
|
301
|
+
'child.stdin.end(raw);});',
|
|
302
|
+
].join('');
|
|
303
|
+
const CURSOR_MCP_PRE_ACTION_GUARD_COMMAND = `node -e '${CURSOR_MCP_PRE_ACTION_GUARD_SOURCE}'`;
|
|
258
304
|
const CLINE_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cline-pre-action-hook`;
|
|
259
305
|
const CLINE_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cline-hook`;
|
|
260
306
|
const CLINE_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cline-session-hook`;
|
|
@@ -271,6 +317,53 @@ const GEMINI_SESSION_END_ENTRYPOINT = `npx -y --package=${MCP_PACKAGE_SPEC} marr
|
|
|
271
317
|
const GEMINI_FIXED_DENIAL = 'Marrow blocked this action because required governance approval or proof is unavailable.';
|
|
272
318
|
const GEMINI_LAUNCH_FAILURE = 'Marrow governance adapter was unavailable; this action is blocked.';
|
|
273
319
|
const GEMINI_PRE_ACTION_HOOK_COMMAND = `sh -c 'output="$(${GEMINI_PRE_ACTION_ENTRYPOINT} 2>/dev/null)" && { case "$output" in "{\\"decision\\":\\"allow\\"}"|"{\\"decision\\":\\"deny\\",\\"reason\\":\\"${GEMINI_FIXED_DENIAL}\\"}") printf "%s\\n" "$output"; exit 0 ;; esac; }; printf "%s\\n" "${GEMINI_LAUNCH_FAILURE}" >&2; exit 2'`;
|
|
320
|
+
// Host approvals: the BeforeTool guard also passes the hold denial that carries a typed-reply
|
|
321
|
+
// code for the user only (`systemMessage`), so it checks the JSON shape instead of two fixed
|
|
322
|
+
// strings. Anything else (a crash, a timeout before Gemini's own, invalid or oversized output)
|
|
323
|
+
// still blocks with the fixed launch-failure text. stdin goes straight to the MCP entrypoint.
|
|
324
|
+
const GEMINI_HOOK_GUARD_TIMEOUT_MS = 4500;
|
|
325
|
+
const GEMINI_PRE_ACTION_GUARD_SOURCE = [
|
|
326
|
+
'const {spawn}=require("node:child_process");',
|
|
327
|
+
'const valid=value=>{try{const p=JSON.parse(value);if(!p||typeof p!=="object"||Array.isArray(p)||JSON.stringify(p)!==value)return false;const keys=Object.keys(p);',
|
|
328
|
+
'if(p.decision==="allow")return keys.length===1;',
|
|
329
|
+
'if(p.decision!=="deny"||typeof p.reason!=="string"||p.reason.length<1||p.reason.length>500)return false;',
|
|
330
|
+
'if(keys.length===2)return keys.includes("reason");',
|
|
331
|
+
'return keys.length===3&&keys.includes("systemMessage")&&typeof p.systemMessage==="string"&&p.systemMessage.length>0&&p.systemMessage.length<=600;}catch{return false;}};',
|
|
332
|
+
'let child=null,timer=null,done=false,output="",bytes=0;',
|
|
333
|
+
// Exits at once: a grandchild still holding the pipe must not keep the hook alive until the
|
|
334
|
+
// host's own timeout, which Gemini treats as a non-blocking failure.
|
|
335
|
+
`const fail=()=>{if(done)return;done=true;if(timer)clearTimeout(timer);try{if(child){if(child.stdout)child.stdout.destroy();if(!child.killed)child.kill("SIGKILL");}}catch{}process.exitCode=2;process.stderr.write(${JSON.stringify(`${GEMINI_LAUNCH_FAILURE}\n`)},()=>process.exit(2));};`,
|
|
336
|
+
'try{',
|
|
337
|
+
`child=spawn(process.platform==="win32"?"npx.cmd":"npx",${JSON.stringify(['-y', `--package=${MCP_PACKAGE_SPEC}`, 'marrow-mcp', 'gemini-pre-action-hook'])},{stdio:["inherit","pipe","ignore"]});`,
|
|
338
|
+
`timer=setTimeout(fail,${GEMINI_HOOK_GUARD_TIMEOUT_MS});`,
|
|
339
|
+
'child.stdout.on("data",chunk=>{if(done)return;bytes+=chunk.length;if(bytes>4096){fail();return;}output+=chunk.toString("utf8");});',
|
|
340
|
+
'child.on("error",fail);',
|
|
341
|
+
'child.on("close",code=>{if(done)return;const out=output.replace(/\\n+$/,"");if(code!==0||!valid(out)){fail();return;}done=true;clearTimeout(timer);process.stdout.write(out+"\\n");});',
|
|
342
|
+
'}catch{fail();}',
|
|
343
|
+
].join('');
|
|
344
|
+
const GEMINI_PRE_ACTION_GUARD_COMMAND = `node -e '${GEMINI_PRE_ACTION_GUARD_SOURCE}'`;
|
|
345
|
+
// BeforeAgent records a typed reply ("marrow approve CODE") in a local interactive session. It
|
|
346
|
+
// never blocks the prompt: only a confirmation for the user (`systemMessage`) and a retry note
|
|
347
|
+
// for the agent pass; any failure prints `{}` and the prompt continues. A held action stays
|
|
348
|
+
// denied at BeforeTool until Marrow has the answer, so this hook cannot let one run.
|
|
349
|
+
const GEMINI_CONTEXT_GUARD_SOURCE = [
|
|
350
|
+
'const {spawn}=require("node:child_process");',
|
|
351
|
+
'const text=(v)=>typeof v==="string"&&v.length<=1000;',
|
|
352
|
+
'const valid=value=>{try{const p=JSON.parse(value);if(!p||typeof p!=="object"||Array.isArray(p)||JSON.stringify(p)!==value)return false;',
|
|
353
|
+
'for(const key of Object.keys(p)){if(key==="systemMessage"){if(!text(p.systemMessage))return false;}',
|
|
354
|
+
'else if(key==="hookSpecificOutput"){const h=p.hookSpecificOutput;if(!h||typeof h!=="object"||Array.isArray(h)||Object.keys(h).sort().join()!=="additionalContext,hookEventName"||h.hookEventName!=="BeforeAgent"||!text(h.additionalContext))return false;}',
|
|
355
|
+
'else return false;}return true;}catch{return false;}};',
|
|
356
|
+
'let child=null,timer=null,done=false,output="",bytes=0;',
|
|
357
|
+
'const neutral=()=>{if(done)return;done=true;if(timer)clearTimeout(timer);try{if(child){if(child.stdout)child.stdout.destroy();if(!child.killed)child.kill("SIGKILL");}}catch{}process.exitCode=0;process.stdout.write("{}\\n",()=>process.exit(0));};',
|
|
358
|
+
'try{',
|
|
359
|
+
`child=spawn(process.platform==="win32"?"npx.cmd":"npx",${JSON.stringify(['-y', `--package=${MCP_PACKAGE_SPEC}`, 'marrow-mcp', 'gemini-context-hook'])},{stdio:["inherit","pipe","ignore"]});`,
|
|
360
|
+
`timer=setTimeout(neutral,${GEMINI_HOOK_GUARD_TIMEOUT_MS});`,
|
|
361
|
+
'child.stdout.on("data",chunk=>{if(done)return;bytes+=chunk.length;if(bytes>4096){neutral();return;}output+=chunk.toString("utf8");});',
|
|
362
|
+
'child.on("error",neutral);',
|
|
363
|
+
'child.on("close",code=>{if(done)return;const out=output.replace(/\\n+$/,"");if(code!==0||!valid(out)){neutral();return;}done=true;clearTimeout(timer);process.stdout.write(out+"\\n");});',
|
|
364
|
+
'}catch{neutral();}',
|
|
365
|
+
].join('');
|
|
366
|
+
const GEMINI_CONTEXT_HOOK_COMMAND = `node -e '${GEMINI_CONTEXT_GUARD_SOURCE}'`;
|
|
274
367
|
const GEMINI_ACTION_RESULT_HOOK_COMMAND = `sh -c '${GEMINI_ACTION_RESULT_ENTRYPOINT} >/dev/null 2>&1 || :; printf "%s\\n" "{}"; exit 0'`;
|
|
275
368
|
const GEMINI_SESSION_END_HOOK_COMMAND = `sh -c '${GEMINI_SESSION_END_ENTRYPOINT} >/dev/null 2>&1 || :; printf "%s\\n" "{}"; exit 0'`;
|
|
276
369
|
const GROK_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp grok-context-hook`;
|
|
@@ -297,6 +390,17 @@ const GROK_PRE_ACTION_HOOK_COMMAND = `node -e '${GROK_PRE_ACTION_GUARD_SOURCE}'`
|
|
|
297
390
|
const NATIVE_HOOK_MATCHER = 'Bash|Edit|Write|MultiEdit|Read|Glob|Grep|Search|WebSearch|Task|functions\\.(?!mcp__marrow__marrow_).*|mcp__(?!marrow__marrow_).*';
|
|
298
391
|
const CODEX_NATIVE_HOOK_MATCHER = 'Bash|apply_patch|Edit|Write|MultiEdit|mcp__(?!marrow__marrow_).*|functions\\.(?!marrow_).*';
|
|
299
392
|
const CURSOR_NATIVE_HOOK_MATCHER = 'Shell|Write|Delete|Task|Read|Glob|Grep|Search|WebSearch|List|MCP:(?!marrow(?:_.*|:marrow_.*)$).*';
|
|
393
|
+
// Host approvals: Cursor enforces an "ask" only on beforeShellExecution and beforeMCPExecution,
|
|
394
|
+
// never on preToolUse. Shell calls move to beforeShellExecution (it runs in cloud agents too).
|
|
395
|
+
// MCP calls stay in preToolUse as well, because cloud agents never run beforeMCPExecution: the
|
|
396
|
+
// MCP hook defers there to beforeMCPExecution only in a local interactive session (sessionStart
|
|
397
|
+
// evidence) and holds in cloud and background agents. The result hooks keep the full matcher.
|
|
398
|
+
const CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER = 'Write|Delete|Task|Read|Glob|Grep|Search|WebSearch|List|MCP:(?!marrow(?:_.*|:marrow_.*)$).*';
|
|
399
|
+
// Cursor's gating hooks are failClosed: a cold npx start on a slow network must not block a
|
|
400
|
+
// call, so they get room beyond the MCP hook's own 4-second budget.
|
|
401
|
+
const CURSOR_GATE_HOOK_TIMEOUT_SECONDS = 15;
|
|
402
|
+
const CURSOR_EXECUTION_PRE_EVENTS = Object.freeze(['beforeShellExecution', 'beforeMCPExecution']);
|
|
403
|
+
const CURSOR_EXECUTION_POST_EVENTS = Object.freeze(['afterShellExecution', 'afterMCPExecution']);
|
|
300
404
|
const GEMINI_NATIVE_HOOK_MATCHER = '^(?:run_shell_command|write_file|replace|edit_file|delete_file|read_file|read_many_files|glob|grep_search|list_directory|get_file_info|web_search|google_web_search|mcp_(?!marrow_marrow_)[A-Za-z0-9_]{1,192})$';
|
|
301
405
|
const GROK_NATIVE_HOOK_MATCHER = 'run_terminal_command|search_replace|write|spawn_subagent|use_tool|workflow|image_gen|image_edit|image_to_video|reference_to_video';
|
|
302
406
|
const CODEX_HOOK_TIMEOUT_SECONDS = 5;
|
|
@@ -723,6 +827,18 @@ function sourceClient() {
|
|
|
723
827
|
return aliases[raw] || (SOURCE_CLIENTS.has(raw) ? raw : 'custom');
|
|
724
828
|
}
|
|
725
829
|
|
|
830
|
+
// What an error may print of an argument it does not know: an option's name (the text before
|
|
831
|
+
// `=`, never its value) when it looks like one (--lower-case-name, or one letter after one dash),
|
|
832
|
+
// or a short plain word. Anything else may be a pasted key (-pPASSWORD) and is not shown.
|
|
833
|
+
function argumentLabel(arg) {
|
|
834
|
+
const text = String(arg ?? '');
|
|
835
|
+
if (text.startsWith('-')) {
|
|
836
|
+
const name = text.split('=')[0];
|
|
837
|
+
return /^(?:--[a-z][a-z0-9-]{0,40}|-[A-Za-z])$/.test(name) ? name : '(an option that is not known; not shown)';
|
|
838
|
+
}
|
|
839
|
+
return /^[a-z][a-z-]{0,23}$/.test(text) ? text : '(a value that is not an option; not shown)';
|
|
840
|
+
}
|
|
841
|
+
|
|
726
842
|
function parseArgs(argv, env = process.env) {
|
|
727
843
|
const options = {
|
|
728
844
|
cwd: process.cwd(),
|
|
@@ -742,6 +858,9 @@ function parseArgs(argv, env = process.env) {
|
|
|
742
858
|
json: false,
|
|
743
859
|
activate: false,
|
|
744
860
|
controller: true,
|
|
861
|
+
// Hooks start a verified local copy of the pinned MCP (installed on write runs) instead of
|
|
862
|
+
// npx. MARROW_LOCAL_RUNTIME=0 or --no-local-runtime keeps them on npx.
|
|
863
|
+
mcpLocalRuntime: env.MARROW_LOCAL_RUNTIME !== '0',
|
|
745
864
|
};
|
|
746
865
|
let explicitOperation = false;
|
|
747
866
|
|
|
@@ -767,6 +886,10 @@ function parseArgs(argv, env = process.env) {
|
|
|
767
886
|
explicitOperation = true;
|
|
768
887
|
options.dryRun = true;
|
|
769
888
|
}
|
|
889
|
+
else if (arg === 'uninstall' || arg === '--uninstall') {
|
|
890
|
+
explicitOperation = true;
|
|
891
|
+
options.uninstall = true;
|
|
892
|
+
}
|
|
770
893
|
else if (arg === '--doctor' || arg === 'doctor' || arg === 'check') {
|
|
771
894
|
explicitOperation = true;
|
|
772
895
|
options.doctor = true;
|
|
@@ -779,6 +902,7 @@ function parseArgs(argv, env = process.env) {
|
|
|
779
902
|
options.selfTestExplicitlyDisabled = true;
|
|
780
903
|
}
|
|
781
904
|
else if (arg === '--no-controller') options.controller = false;
|
|
905
|
+
else if (arg === '--no-local-runtime') options.mcpLocalRuntime = false;
|
|
782
906
|
else if (arg === '--self-test') options.selfTest = true;
|
|
783
907
|
else if (arg === '--cwd') options.cwd = path.resolve(argv[++i] || options.cwd);
|
|
784
908
|
else if (arg === '--mode') {
|
|
@@ -810,10 +934,13 @@ function parseArgs(argv, env = process.env) {
|
|
|
810
934
|
else if (arg === '--help' || arg === '-h') {
|
|
811
935
|
options.help = true;
|
|
812
936
|
} else {
|
|
813
|
-
throw new Error(`Unknown argument: ${arg}`);
|
|
937
|
+
throw new Error(`Unknown argument: ${argumentLabel(arg)}`);
|
|
814
938
|
}
|
|
815
939
|
}
|
|
816
940
|
|
|
941
|
+
if (options.uninstall && (options.activate || options.repair || options.doctor)) {
|
|
942
|
+
throw new Error('uninstall cannot be combined with activate, update, repair, or doctor');
|
|
943
|
+
}
|
|
817
944
|
if (!['auto', 'mcp', 'sdk', 'both', 'md'].includes(options.mode)) {
|
|
818
945
|
throw new Error('--mode must be one of auto, mcp, sdk, both, md');
|
|
819
946
|
}
|
|
@@ -845,6 +972,7 @@ function usage() {
|
|
|
845
972
|
npx @getmarrow/install doctor
|
|
846
973
|
npx @getmarrow/install --mcp --yes
|
|
847
974
|
npx @getmarrow/install --sdk --yes
|
|
975
|
+
npx @getmarrow/install uninstall [--yes]
|
|
848
976
|
|
|
849
977
|
Options:
|
|
850
978
|
(no command) Detect, install, self-test, and start the supported persistent controller
|
|
@@ -859,8 +987,12 @@ Options:
|
|
|
859
987
|
--base-url <url> Marrow API base URL
|
|
860
988
|
--agent-id <id> Agent/fleet id for self-test headers
|
|
861
989
|
--no-controller Do not start the local background controller during install/repair
|
|
990
|
+
--no-local-runtime Keep hooks on npx instead of the verified local MCP copy in ~/.marrow/runtime
|
|
862
991
|
--no-self-test Skip API smoke/self-test
|
|
863
992
|
--verbose Print the full report instead of the one-line summary and log file
|
|
993
|
+
uninstall Preview removing only Marrow's own hooks, MCP server entries, instructions and
|
|
994
|
+
the local MCP runtime; add --yes to remove them. Your own hooks and settings
|
|
995
|
+
are kept.
|
|
864
996
|
|
|
865
997
|
The API key comes from MARROW_API_KEY, or from the owner-only ~/.marrow/env when unset.
|
|
866
998
|
|
|
@@ -878,13 +1010,14 @@ function stableAgentId(root, client = sourceClient()) {
|
|
|
878
1010
|
function detectedClient(detection) {
|
|
879
1011
|
if (sourceClient() !== 'custom') return sourceClient();
|
|
880
1012
|
if (detection.openclaw) return 'openclaw';
|
|
881
|
-
if (detection.claudeCode) return 'claude-code';
|
|
1013
|
+
if (detection.claudeCodeProject ?? detection.claudeCode) return 'claude-code';
|
|
882
1014
|
if (detection.cursor) return 'cursor';
|
|
883
1015
|
if (detection.cline) return 'cline';
|
|
884
1016
|
if (detection.windsurf) return 'windsurf';
|
|
885
1017
|
if (detection.gemini) return 'gemini';
|
|
886
1018
|
if (detection.codexProject) return 'codex';
|
|
887
1019
|
// Home-level harnesses rank below project markers; a shared AGENTS.md is a weaker signal.
|
|
1020
|
+
if (detection.claudeCode) return 'claude-code';
|
|
888
1021
|
if (detection.hermes) return 'hermes';
|
|
889
1022
|
if (detection.codex) return 'codex';
|
|
890
1023
|
return 'custom';
|
|
@@ -1037,6 +1170,14 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
|
|
|
1037
1170
|
};
|
|
1038
1171
|
const homeHarnesses = detectHomeHarnesses(home, env);
|
|
1039
1172
|
const codexProject = codexDirectoryIsOwnerConfigured(path.join(root, '.codex'));
|
|
1173
|
+
// Claude Code is detected from the project (CLAUDE.md, .claude/settings.json) or, for a repo
|
|
1174
|
+
// that has neither yet, from the user's installation: ~/.claude/, ~/.claude.json or `claude`
|
|
1175
|
+
// on PATH. Either way the project gets .claude/settings.json.
|
|
1176
|
+
const claudeCodeProject = exists(paths.claudeSettings) || exists(paths.claudeMd);
|
|
1177
|
+
const claudeCodeSource = claudeCodeProject ? 'project'
|
|
1178
|
+
: exists(path.join(home, '.claude')) || exists(path.join(home, '.claude.json')) ? 'home'
|
|
1179
|
+
: executableOnPath('claude', env) ? 'path'
|
|
1180
|
+
: null;
|
|
1040
1181
|
|
|
1041
1182
|
return {
|
|
1042
1183
|
root,
|
|
@@ -1044,7 +1185,9 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
|
|
|
1044
1185
|
paths,
|
|
1045
1186
|
node: exists(paths.packageJson),
|
|
1046
1187
|
python: exists(paths.pyproject) || exists(paths.requirements) || exists(paths.setupPy),
|
|
1047
|
-
claudeCode:
|
|
1188
|
+
claudeCode: Boolean(claudeCodeSource),
|
|
1189
|
+
claudeCodeProject,
|
|
1190
|
+
claudeCodeSource,
|
|
1048
1191
|
cursor: exists(path.join(root, '.cursor')),
|
|
1049
1192
|
cline: exists(path.join(root, '.clinerules')),
|
|
1050
1193
|
windsurf: exists(path.join(root, '.windsurf')),
|
|
@@ -1123,7 +1266,12 @@ function findLikelyEnvFiles(detection, env = process.env) {
|
|
|
1123
1266
|
});
|
|
1124
1267
|
}
|
|
1125
1268
|
|
|
1126
|
-
function passiveInstructions() {
|
|
1269
|
+
function passiveInstructions(options = {}) {
|
|
1270
|
+
const hostApprovals = options.hostApprovals === true;
|
|
1271
|
+
const geminiHooks = hostApprovals ? 'BeforeTool, AfterTool, AfterAgent, and BeforeAgent' : 'BeforeTool, AfterTool, and AfterAgent';
|
|
1272
|
+
const approvals = hostApprovals
|
|
1273
|
+
? '\n- When Marrow holds an action for approval, the operator approves it in the host\'s own permission prompt, or with the typed reply the hook shows them. With nobody at the prompt, the action waits quietly: do not run it, carry on with other work, and tell the person it is waiting. Marrow sends the account owner a one-tap link only for actions the owner approves personally, to reverse the owner\'s own decline when asked, or when the owner turned on unattended pings. Never ask anyone to log in to a dashboard to approve, and never report, write, or claim an approval yourself. Retry the held action only after the approval or when the operator asks.'
|
|
1274
|
+
: '';
|
|
1127
1275
|
return `${MARROW_BLOCK_START}
|
|
1128
1276
|
## Marrow Passive Agent Memory
|
|
1129
1277
|
|
|
@@ -1137,10 +1285,10 @@ Marrow should run passively after install:
|
|
|
1137
1285
|
- Before risky work, use Marrow's decision brief or passive prompt hook.
|
|
1138
1286
|
- After activate writes MCP or hooks, restart this harness, then run \`npx @getmarrow/install@latest doctor --self-test\`. Do not claim Marrow is live in a process that has not reloaded.
|
|
1139
1287
|
- First turn: tell the owner the gate is live and empty savings are honest. The next deploy, merge, or publish must go through Marrow.
|
|
1140
|
-
- Cursor and Composer use configured native hooks after restart and \`/hooks\` trust review. Cline uses its configured PreToolUse, PostToolUse, and TaskCancel hooks only after Enable Hooks, restart, executable trust, and workspace trust; TaskComplete is not claimed as current coverage. Windsurf uses configured native pre-action, success-result, and response-closeout hooks only after restart, trust review, and leaving Restricted Mode. Gemini CLI uses configured
|
|
1288
|
+
- Cursor and Composer use configured native hooks after restart and \`/hooks\` trust review. Cline uses its configured PreToolUse, PostToolUse, and TaskCancel hooks only after Enable Hooks, restart, executable trust, and workspace trust; TaskComplete is not claimed as current coverage. Windsurf uses configured native pre-action, success-result, and response-closeout hooks only after restart, trust review, and leaving Restricted Mode. Gemini CLI uses configured ${geminiHooks} hooks only after restart and project fingerprint review and approval in \`/hooks panel\`; explicit user disablement is preserved. Grok uses global native PreToolUse, PostToolUse/PostToolUseFailure, and one nonblocking Stop closeout only after restart and \`/hooks\` inspection; Grok hooks remain user-toggleable. MCP tools remain on demand. Codex uses configured native hooks after restart and \`/hooks\` trust review. The governed wrapper remains an explicit bounded fallback.
|
|
1141
1289
|
- Before the session ends, close open work with session-end auto-commit or \`marrow_commit\`. Record model usage only when the host response includes counts.
|
|
1142
1290
|
- After meaningful work, record the outcome so future agents learn from it.
|
|
1143
|
-
- After Marrow blocks, warns, or requires review, use the decision trace receipt to tell the operator what changed and which recorded workflow or proof is required. Stay quiet for routine low-risk work
|
|
1291
|
+
- After Marrow blocks, warns, or requires review, use the decision trace receipt to tell the operator what changed and which recorded workflow or proof is required. Stay quiet for routine low-risk work.${approvals}
|
|
1144
1292
|
- Check health with \`marrow_agent_status\` or \`GET /v1/agent/status\`.
|
|
1145
1293
|
- When status/runtime returns a \`client_update\` notice, tell the operator and use its exact update and verification commands only when local change policy permits.
|
|
1146
1294
|
|
|
@@ -1208,6 +1356,9 @@ MARROW_PASSIVE_TOKEN_USAGE=true
|
|
|
1208
1356
|
`;
|
|
1209
1357
|
}
|
|
1210
1358
|
|
|
1359
|
+
// Hook files are read with Marrow's hook commands in their canonical (npx) form, whether they
|
|
1360
|
+
// start through npx or the local runtime, so every check and merge sees one spelling; writes
|
|
1361
|
+
// switch them to the local runtime again when it is verified.
|
|
1211
1362
|
function parseJsonObject(filePath) {
|
|
1212
1363
|
const raw = safeRead(filePath).trim();
|
|
1213
1364
|
if (!raw) return {};
|
|
@@ -1215,7 +1366,7 @@ function parseJsonObject(filePath) {
|
|
|
1215
1366
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1216
1367
|
throw new Error(`Expected JSON object in ${filePath}`);
|
|
1217
1368
|
}
|
|
1218
|
-
return parsed;
|
|
1369
|
+
return mapHookCommands(parsed, delocalizeHookCommand);
|
|
1219
1370
|
}
|
|
1220
1371
|
|
|
1221
1372
|
function canonicalJsonValue(value) {
|
|
@@ -1293,7 +1444,7 @@ function exactHookDescriptors(settings, eventName, command, matcher) {
|
|
|
1293
1444
|
function marrowHookSubcommand(command) {
|
|
1294
1445
|
if (typeof command !== 'string') return null;
|
|
1295
1446
|
const match = command.trim().match(
|
|
1296
|
-
/^npx\s+(?:-y\s+)?(?:--package=@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp|@getmarrow\/mcp(?:@[^\s]+)?)\s+(?:(?:claude|cline|codex|cursor|gemini|grok|windsurf)-)?(context-hook|pre-action-hook|hook|session-hook)$/,
|
|
1447
|
+
/^npx\s+(?:-y\s+)?(?:--package=@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp|@getmarrow\/mcp(?:@[^\s]+)?)\s+(?:(?:claude|cline|codex|cursor|gemini|grok|windsurf)-)?(context-hook|pre-action-hook|hook|session-hook|permission-request-hook)$/,
|
|
1297
1448
|
);
|
|
1298
1449
|
return match?.[1] || null;
|
|
1299
1450
|
}
|
|
@@ -1367,11 +1518,33 @@ function safeJsonObject(filePath) {
|
|
|
1367
1518
|
}
|
|
1368
1519
|
}
|
|
1369
1520
|
|
|
1370
|
-
|
|
1521
|
+
// The host-approval hooks Claude Code needs besides the core five (see upsertClaudeHooks).
|
|
1522
|
+
function claudeHostApprovalHooksConfigured(settings, targetCommand = (command) => command) {
|
|
1523
|
+
return {
|
|
1524
|
+
permission_request: exactHookConfigured(settings, 'PermissionRequest', targetCommand(MCP_PERMISSION_REQUEST_HOOK_COMMAND), NATIVE_HOOK_MATCHER),
|
|
1525
|
+
post_tool_batch: exactHookConfigured(settings, 'PostToolBatch', targetCommand(MCP_ACTION_RESULT_HOOK_COMMAND)),
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function claudeNativeHookFingerprint(settings, options = {}) {
|
|
1530
|
+
const hostApprovals = options.hostApprovals ?? hostApprovalHooksSupported();
|
|
1371
1531
|
const contract = {
|
|
1372
1532
|
schema: 'marrow-claude-native-hooks.v3',
|
|
1373
1533
|
adapter_version: MCP_ADAPTER_VERSION,
|
|
1374
1534
|
expected_hooks: NATIVE_EXPECTED_HOOKS,
|
|
1535
|
+
...(hostApprovals ? {
|
|
1536
|
+
host_approvals: {
|
|
1537
|
+
configured: claudeHostApprovalHooksConfigured(settings),
|
|
1538
|
+
descriptors: {
|
|
1539
|
+
permission_request: exactHookDescriptors(settings, 'PermissionRequest', MCP_PERMISSION_REQUEST_HOOK_COMMAND, NATIVE_HOOK_MATCHER),
|
|
1540
|
+
post_tool_batch: exactHookDescriptors(settings, 'PostToolBatch', MCP_ACTION_RESULT_HOOK_COMMAND),
|
|
1541
|
+
},
|
|
1542
|
+
active_marrow_handlers: {
|
|
1543
|
+
permission_request: marrowHookDescriptors(settings, 'PermissionRequest'),
|
|
1544
|
+
post_tool_batch: marrowHookDescriptors(settings, 'PostToolBatch'),
|
|
1545
|
+
},
|
|
1546
|
+
},
|
|
1547
|
+
} : {}),
|
|
1375
1548
|
configured: {
|
|
1376
1549
|
prompt: exactHookConfigured(settings, 'UserPromptSubmit', MCP_CONTEXT_HOOK_COMMAND),
|
|
1377
1550
|
pre_action: exactHookConfigured(settings, 'PreToolUse', MCP_PRE_ACTION_HOOK_COMMAND, NATIVE_HOOK_MATCHER),
|
|
@@ -1397,7 +1570,7 @@ function claudeNativeHookFingerprint(settings) {
|
|
|
1397
1570
|
return crypto.createHash('sha256').update(JSON.stringify(contract)).digest('hex');
|
|
1398
1571
|
}
|
|
1399
1572
|
|
|
1400
|
-
function upsertClaudeHooks(settingsPath) {
|
|
1573
|
+
function upsertClaudeHooks(settingsPath, options = {}) {
|
|
1401
1574
|
const settings = parseJsonObject(settingsPath);
|
|
1402
1575
|
const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
|
|
1403
1576
|
? settings.hooks
|
|
@@ -1426,6 +1599,18 @@ function upsertClaudeHooks(settingsPath) {
|
|
|
1426
1599
|
UserPromptSubmit: userPromptSubmit,
|
|
1427
1600
|
Stop: stop,
|
|
1428
1601
|
};
|
|
1602
|
+
if (options.hostApprovals) {
|
|
1603
|
+
// Both async, exactly as `marrow-mcp setup` writes them: the marker never delays or answers
|
|
1604
|
+
// the dialog, and PostToolBatch settles a declined dialog in the background.
|
|
1605
|
+
settings.hooks.PermissionRequest = reconcileMarrowCommandHook(
|
|
1606
|
+
settings, 'PermissionRequest', 'permission-request-hook', MCP_PERMISSION_REQUEST_HOOK_COMMAND, NATIVE_HOOK_MATCHER,
|
|
1607
|
+
{ async: true },
|
|
1608
|
+
);
|
|
1609
|
+
settings.hooks.PostToolBatch = reconcileMarrowCommandHook(
|
|
1610
|
+
settings, 'PostToolBatch', 'hook', MCP_ACTION_RESULT_HOOK_COMMAND, undefined,
|
|
1611
|
+
{ async: true },
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1429
1614
|
|
|
1430
1615
|
return JSON.stringify(settings, null, 2) + '\n';
|
|
1431
1616
|
}
|
|
@@ -1484,11 +1669,20 @@ function upsertCodexHooks(hooksPath) {
|
|
|
1484
1669
|
return JSON.stringify(settings, null, 2) + '\n';
|
|
1485
1670
|
}
|
|
1486
1671
|
|
|
1672
|
+
// A Marrow Cursor entry: the npx entrypoint, or the node guard that names it as JSON items.
|
|
1673
|
+
function cursorMarrowHookSubcommand(command) {
|
|
1674
|
+
const direct = marrowHookSubcommand(command);
|
|
1675
|
+
if (direct) return direct;
|
|
1676
|
+
if (typeof command !== 'string') return null;
|
|
1677
|
+
const guarded = command.match(/@getmarrow\/mcp@[^\s"',]+["'],["']marrow-mcp["'],["']cursor-(pre-action-hook|context-hook|hook|session-hook)["']/);
|
|
1678
|
+
return guarded?.[1] || null;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1487
1681
|
function reconcileCursorHook(settings, eventName, subcommand, canonical) {
|
|
1488
1682
|
const original = Array.isArray(settings?.hooks?.[eventName]) ? settings.hooks[eventName] : [];
|
|
1489
1683
|
const retained = original.filter((entry) => !(
|
|
1490
1684
|
entry && typeof entry === 'object' && !Array.isArray(entry)
|
|
1491
|
-
&&
|
|
1685
|
+
&& cursorMarrowHookSubcommand(entry.command)
|
|
1492
1686
|
));
|
|
1493
1687
|
return [...retained, canonical];
|
|
1494
1688
|
}
|
|
@@ -1506,7 +1700,7 @@ function cursorHookDescriptors(settings, eventName) {
|
|
|
1506
1700
|
const entries = settings?.hooks?.[eventName];
|
|
1507
1701
|
if (!Array.isArray(entries)) return [];
|
|
1508
1702
|
return entries.flatMap((entry) => {
|
|
1509
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !
|
|
1703
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !cursorMarrowHookSubcommand(entry.command)) return [];
|
|
1510
1704
|
return [{
|
|
1511
1705
|
matcher: typeof entry.matcher === 'string' ? entry.matcher : null,
|
|
1512
1706
|
command: String(entry.command).trim(),
|
|
@@ -1517,14 +1711,43 @@ function cursorHookDescriptors(settings, eventName) {
|
|
|
1517
1711
|
});
|
|
1518
1712
|
}
|
|
1519
1713
|
|
|
1520
|
-
|
|
1714
|
+
// The Cursor hooks that host approvals add (see upsertCursorHooks), as configured checks.
|
|
1715
|
+
function cursorHostApprovalHooksConfigured(settings, targetCommand = (command) => command) {
|
|
1716
|
+
const pre = targetCommand(CURSOR_PRE_ACTION_HOOK_COMMAND);
|
|
1717
|
+
const post = targetCommand(CURSOR_ACTION_RESULT_HOOK_COMMAND);
|
|
1718
|
+
const gate = { timeout: CURSOR_GATE_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false };
|
|
1719
|
+
return {
|
|
1720
|
+
pre_tool_use: exactCursorHookConfigured(settings, 'preToolUse', pre, CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER, gate),
|
|
1721
|
+
beforeShellExecution: exactCursorHookConfigured(settings, 'beforeShellExecution', pre, undefined, gate),
|
|
1722
|
+
beforeMCPExecution: exactCursorHookConfigured(settings, 'beforeMCPExecution', targetCommand(CURSOR_MCP_PRE_ACTION_GUARD_COMMAND), undefined, gate),
|
|
1723
|
+
...Object.fromEntries(CURSOR_EXECUTION_POST_EVENTS.map((event) => [event, exactCursorHookConfigured(settings, event, post, undefined, {
|
|
1724
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1725
|
+
})])),
|
|
1726
|
+
sessionStart: exactCursorHookConfigured(settings, 'sessionStart', targetCommand(CURSOR_SESSION_END_HOOK_COMMAND), undefined, {
|
|
1727
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1728
|
+
}),
|
|
1729
|
+
beforeSubmitPrompt: exactCursorHookConfigured(settings, 'beforeSubmitPrompt', targetCommand(CURSOR_CONTEXT_HOOK_COMMAND), undefined, {
|
|
1730
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1731
|
+
}),
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function cursorNativeHookFingerprint(settings, options = {}) {
|
|
1736
|
+
const hostApprovals = options.hostApprovals ?? hostApprovalHooksSupported();
|
|
1521
1737
|
const contract = {
|
|
1522
1738
|
schema: 'marrow-cursor-native-hooks.v1',
|
|
1523
1739
|
adapter_version: MCP_ADAPTER_VERSION,
|
|
1524
1740
|
expected_hooks: ['pre_action', 'action_result', 'outcome_closure'],
|
|
1741
|
+
...(hostApprovals ? {
|
|
1742
|
+
host_approvals: {
|
|
1743
|
+
configured: cursorHostApprovalHooksConfigured(settings),
|
|
1744
|
+
descriptors: Object.fromEntries([...CURSOR_EXECUTION_PRE_EVENTS, ...CURSOR_EXECUTION_POST_EVENTS, 'sessionStart', 'beforeSubmitPrompt']
|
|
1745
|
+
.map((event) => [event, cursorHookDescriptors(settings, event)])),
|
|
1746
|
+
},
|
|
1747
|
+
} : {}),
|
|
1525
1748
|
configured: {
|
|
1526
|
-
pre_action: exactCursorHookConfigured(settings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, CURSOR_NATIVE_HOOK_MATCHER, {
|
|
1527
|
-
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1749
|
+
pre_action: exactCursorHookConfigured(settings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, hostApprovals ? CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER : CURSOR_NATIVE_HOOK_MATCHER, {
|
|
1750
|
+
timeout: hostApprovals ? CURSOR_GATE_HOOK_TIMEOUT_SECONDS : CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1528
1751
|
failClosed: true,
|
|
1529
1752
|
async: false,
|
|
1530
1753
|
}),
|
|
@@ -1548,18 +1771,19 @@ function cursorNativeHookFingerprint(settings) {
|
|
|
1548
1771
|
return crypto.createHash('sha256').update(JSON.stringify(contract)).digest('hex');
|
|
1549
1772
|
}
|
|
1550
1773
|
|
|
1551
|
-
function upsertCursorHooks(hooksPath) {
|
|
1774
|
+
function upsertCursorHooks(hooksPath, options = {}) {
|
|
1552
1775
|
const settings = parseJsonObject(hooksPath);
|
|
1553
1776
|
const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
|
|
1554
1777
|
? settings.hooks
|
|
1555
1778
|
: {};
|
|
1556
1779
|
settings.version = 1;
|
|
1780
|
+
const hostApprovals = options.hostApprovals === true;
|
|
1557
1781
|
settings.hooks = {
|
|
1558
1782
|
...hooks,
|
|
1559
1783
|
preToolUse: reconcileCursorHook(settings, 'preToolUse', 'pre-action-hook', {
|
|
1560
1784
|
command: CURSOR_PRE_ACTION_HOOK_COMMAND,
|
|
1561
|
-
matcher: CURSOR_NATIVE_HOOK_MATCHER,
|
|
1562
|
-
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1785
|
+
matcher: hostApprovals ? CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER : CURSOR_NATIVE_HOOK_MATCHER,
|
|
1786
|
+
timeout: hostApprovals ? CURSOR_GATE_HOOK_TIMEOUT_SECONDS : CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1563
1787
|
failClosed: true,
|
|
1564
1788
|
async: false,
|
|
1565
1789
|
}),
|
|
@@ -1578,6 +1802,36 @@ function upsertCursorHooks(hooksPath) {
|
|
|
1578
1802
|
timeout: CODEX_SESSION_TIMEOUT_SECONDS,
|
|
1579
1803
|
}),
|
|
1580
1804
|
};
|
|
1805
|
+
if (hostApprovals) {
|
|
1806
|
+
// Cursor enforces "ask" only here. failClosed: a crash or timeout blocks the call instead
|
|
1807
|
+
// of letting it through (Cursor fails open by default).
|
|
1808
|
+
for (const eventName of CURSOR_EXECUTION_PRE_EVENTS) {
|
|
1809
|
+
settings.hooks[eventName] = reconcileCursorHook(settings, eventName, 'pre-action-hook', {
|
|
1810
|
+
command: eventName === 'beforeMCPExecution' ? CURSOR_MCP_PRE_ACTION_GUARD_COMMAND : CURSOR_PRE_ACTION_HOOK_COMMAND,
|
|
1811
|
+
timeout: CURSOR_GATE_HOOK_TIMEOUT_SECONDS,
|
|
1812
|
+
failClosed: true,
|
|
1813
|
+
async: false,
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
for (const eventName of CURSOR_EXECUTION_POST_EVENTS) {
|
|
1817
|
+
settings.hooks[eventName] = reconcileCursorHook(settings, eventName, 'hook', {
|
|
1818
|
+
command: CURSOR_ACTION_RESULT_HOOK_COMMAND,
|
|
1819
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
// sessionStart tells the hooks whether the session is local and interactive (not a
|
|
1823
|
+
// background agent); without it Cursor holds are denied instead of asked.
|
|
1824
|
+
settings.hooks.sessionStart = reconcileCursorHook(settings, 'sessionStart', 'session-hook', {
|
|
1825
|
+
command: CURSOR_SESSION_END_HOOK_COMMAND,
|
|
1826
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1827
|
+
});
|
|
1828
|
+
// The typed reply ("marrow approve CODE") in a local interactive session. Never failClosed:
|
|
1829
|
+
// a failure must not block the operator's own prompts.
|
|
1830
|
+
settings.hooks.beforeSubmitPrompt = reconcileCursorHook(settings, 'beforeSubmitPrompt', 'context-hook', {
|
|
1831
|
+
command: CURSOR_CONTEXT_HOOK_COMMAND,
|
|
1832
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1581
1835
|
return JSON.stringify(settings, null, 2) + '\n';
|
|
1582
1836
|
}
|
|
1583
1837
|
|
|
@@ -1647,7 +1901,9 @@ function upsertWindsurfHooks(hooksPath) {
|
|
|
1647
1901
|
|
|
1648
1902
|
function geminiMarrowHookEntrypoint(command) {
|
|
1649
1903
|
if (typeof command !== 'string') return null;
|
|
1650
|
-
const match = command.match(/@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp\s+gemini-(pre-action-hook|hook|session-hook)(?:\s|['"]|$)/)
|
|
1904
|
+
const match = command.match(/@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp\s+gemini-(pre-action-hook|context-hook|hook|session-hook)(?:\s|['"]|$)/)
|
|
1905
|
+
// The node guards name the entrypoint as JSON array items.
|
|
1906
|
+
|| command.match(/@getmarrow\/mcp@[^\s"',]+["'],["']marrow-mcp["'],["']gemini-(pre-action-hook|context-hook|hook|session-hook)["']/);
|
|
1651
1907
|
return match?.[1] || null;
|
|
1652
1908
|
}
|
|
1653
1909
|
|
|
@@ -1689,7 +1945,14 @@ function geminiHooksExplicitlyDisabled(settings) {
|
|
|
1689
1945
|
return settings?.hooksConfig?.enabled === false;
|
|
1690
1946
|
}
|
|
1691
1947
|
|
|
1692
|
-
|
|
1948
|
+
// The Gemini BeforeTool command: the JSON-checking guard with host approvals, otherwise the
|
|
1949
|
+
// earlier fixed-string wrapper.
|
|
1950
|
+
function geminiPreActionCommand(hostApprovals) {
|
|
1951
|
+
return hostApprovals ? GEMINI_PRE_ACTION_GUARD_COMMAND : GEMINI_PRE_ACTION_HOOK_COMMAND;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function geminiNativeHookFingerprint(settings, options = {}) {
|
|
1955
|
+
const hostApprovals = options.hostApprovals ?? hostApprovalHooksSupported();
|
|
1693
1956
|
return crypto.createHash('sha256').update(JSON.stringify({
|
|
1694
1957
|
schema: 'marrow-gemini-native-hooks.v1',
|
|
1695
1958
|
adapter_version: MCP_ADAPTER_VERSION,
|
|
@@ -1698,7 +1961,7 @@ function geminiNativeHookFingerprint(settings) {
|
|
|
1698
1961
|
explicitly_disabled: geminiHooksExplicitlyDisabled(settings),
|
|
1699
1962
|
configured: {
|
|
1700
1963
|
pre_action: exactGeminiHookConfigured(
|
|
1701
|
-
settings, 'BeforeTool', 'marrow-before-tool',
|
|
1964
|
+
settings, 'BeforeTool', 'marrow-before-tool', geminiPreActionCommand(hostApprovals),
|
|
1702
1965
|
GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
|
|
1703
1966
|
),
|
|
1704
1967
|
action_result: exactGeminiHookConfigured(
|
|
@@ -1709,6 +1972,12 @@ function geminiNativeHookFingerprint(settings) {
|
|
|
1709
1972
|
settings, 'AfterAgent', 'marrow-after-agent', GEMINI_SESSION_END_HOOK_COMMAND,
|
|
1710
1973
|
undefined, GEMINI_CLOSEOUT_TIMEOUT_MS,
|
|
1711
1974
|
),
|
|
1975
|
+
...(hostApprovals ? {
|
|
1976
|
+
typed_reply: exactGeminiHookConfigured(
|
|
1977
|
+
settings, 'BeforeAgent', 'marrow-before-agent', GEMINI_CONTEXT_HOOK_COMMAND,
|
|
1978
|
+
undefined, GEMINI_HOOK_TIMEOUT_MS,
|
|
1979
|
+
),
|
|
1980
|
+
} : {}),
|
|
1712
1981
|
},
|
|
1713
1982
|
session_end_claimed: false,
|
|
1714
1983
|
})).digest('hex');
|
|
@@ -1825,11 +2094,12 @@ function managedGrokHooksFile(filePath) {
|
|
|
1825
2094
|
}
|
|
1826
2095
|
}
|
|
1827
2096
|
|
|
1828
|
-
function upsertGeminiHooks(settingsPath) {
|
|
2097
|
+
function upsertGeminiHooks(settingsPath, options = {}) {
|
|
1829
2098
|
const settings = parseJsonObject(settingsPath);
|
|
1830
2099
|
const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
|
|
1831
2100
|
? settings.hooks
|
|
1832
2101
|
: {};
|
|
2102
|
+
const hostApprovals = options.hostApprovals === true;
|
|
1833
2103
|
settings.hooks = {
|
|
1834
2104
|
...hooks,
|
|
1835
2105
|
BeforeTool: reconcileGeminiHook(settings, 'BeforeTool', {
|
|
@@ -1837,7 +2107,7 @@ function upsertGeminiHooks(settingsPath) {
|
|
|
1837
2107
|
hooks: [{
|
|
1838
2108
|
name: 'marrow-before-tool',
|
|
1839
2109
|
type: 'command',
|
|
1840
|
-
command:
|
|
2110
|
+
command: geminiPreActionCommand(hostApprovals),
|
|
1841
2111
|
timeout: GEMINI_HOOK_TIMEOUT_MS,
|
|
1842
2112
|
}],
|
|
1843
2113
|
}),
|
|
@@ -1859,6 +2129,17 @@ function upsertGeminiHooks(settingsPath) {
|
|
|
1859
2129
|
}],
|
|
1860
2130
|
}),
|
|
1861
2131
|
};
|
|
2132
|
+
if (hostApprovals) {
|
|
2133
|
+
// The typed reply in a local interactive session (`gemini`, never `gemini -p`).
|
|
2134
|
+
settings.hooks.BeforeAgent = reconcileGeminiHook(settings, 'BeforeAgent', {
|
|
2135
|
+
hooks: [{
|
|
2136
|
+
name: 'marrow-before-agent',
|
|
2137
|
+
type: 'command',
|
|
2138
|
+
command: GEMINI_CONTEXT_HOOK_COMMAND,
|
|
2139
|
+
timeout: GEMINI_HOOK_TIMEOUT_MS,
|
|
2140
|
+
}],
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
1862
2143
|
return JSON.stringify(settings, null, 2) + '\n';
|
|
1863
2144
|
}
|
|
1864
2145
|
|
|
@@ -1935,7 +2216,7 @@ function clineNativeHookFingerprint(detection) {
|
|
|
1935
2216
|
function activationProfile(detection, plan, changes, client) {
|
|
1936
2217
|
const registry = HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === client)
|
|
1937
2218
|
|| HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === 'custom');
|
|
1938
|
-
const sdkDependency = inspectSdkDependency(detection);
|
|
2219
|
+
const sdkDependency = inspectSdkDependency(detection, { sdkMode: plan.mode === 'sdk' || plan.mode === 'both' });
|
|
1939
2220
|
const capabilityLevel = client === 'custom' && (plan.mode === 'sdk' || plan.mode === 'both')
|
|
1940
2221
|
? 'sdk_passive_runtime'
|
|
1941
2222
|
: registry.capability_level;
|
|
@@ -1946,6 +2227,7 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
1946
2227
|
? plan.mcp_target_version
|
|
1947
2228
|
: MCP_ADAPTER_VERSION;
|
|
1948
2229
|
const targetCommand = (command) => retargetMcpPackageSpec(command, mcpTargetVersion);
|
|
2230
|
+
const hostApprovals = hostApprovalHooksSupported(mcpTargetVersion);
|
|
1949
2231
|
const adapterVersion = capabilityLevel === 'native_hooks' || capabilityLevel === 'mcp'
|
|
1950
2232
|
? mcpTargetVersion
|
|
1951
2233
|
: capabilityLevel === 'sdk_passive_runtime'
|
|
@@ -1964,14 +2246,18 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
1964
2246
|
if (exactHookConfigured(codexSettings, 'PostToolUse', targetCommand(CODEX_ACTION_RESULT_HOOK_COMMAND), CODEX_NATIVE_HOOK_MATCHER)) observedHooks.push('action_result');
|
|
1965
2247
|
if (exactHookConfigured(codexSettings, 'SessionEnd', targetCommand(CODEX_SESSION_END_HOOK_COMMAND))) observedHooks.push('session_end');
|
|
1966
2248
|
} else if (client === 'cursor' || client === 'composer') {
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
2249
|
+
const approvalHooks = hostApprovals ? cursorHostApprovalHooksConfigured(cursorSettings, targetCommand) : null;
|
|
2250
|
+
if ((hostApprovals
|
|
2251
|
+
? approvalHooks.pre_tool_use && approvalHooks.beforeShellExecution && approvalHooks.beforeMCPExecution
|
|
2252
|
+
&& approvalHooks.sessionStart && approvalHooks.beforeSubmitPrompt
|
|
2253
|
+
: exactCursorHookConfigured(cursorSettings, 'preToolUse', targetCommand(CURSOR_PRE_ACTION_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
|
|
2254
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false,
|
|
2255
|
+
}))) observedHooks.push('pre_action');
|
|
1970
2256
|
if (exactCursorHookConfigured(cursorSettings, 'postToolUse', targetCommand(CURSOR_ACTION_RESULT_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
|
|
1971
2257
|
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1972
2258
|
}) && exactCursorHookConfigured(cursorSettings, 'postToolUseFailure', targetCommand(CURSOR_ACTION_RESULT_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
|
|
1973
2259
|
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1974
|
-
})) observedHooks.push('action_result');
|
|
2260
|
+
}) && (!hostApprovals || (approvalHooks.afterShellExecution && approvalHooks.afterMCPExecution))) observedHooks.push('action_result');
|
|
1975
2261
|
if (exactCursorHookConfigured(cursorSettings, 'stop', targetCommand(CURSOR_SESSION_END_HOOK_COMMAND), undefined, {
|
|
1976
2262
|
timeout: CODEX_SESSION_TIMEOUT_SECONDS,
|
|
1977
2263
|
})) observedHooks.push('outcome_closure');
|
|
@@ -1991,9 +2277,12 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
1991
2277
|
)) observedHooks.push('response_closeout');
|
|
1992
2278
|
} else if (client === 'gemini' && !geminiHooksExplicitlyDisabled(geminiSettings)) {
|
|
1993
2279
|
if (exactGeminiHookConfigured(
|
|
1994
|
-
geminiSettings, 'BeforeTool', 'marrow-before-tool', targetCommand(
|
|
2280
|
+
geminiSettings, 'BeforeTool', 'marrow-before-tool', targetCommand(geminiPreActionCommand(hostApprovals)),
|
|
1995
2281
|
GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
|
|
1996
|
-
)
|
|
2282
|
+
) && (!hostApprovals || exactGeminiHookConfigured(
|
|
2283
|
+
geminiSettings, 'BeforeAgent', 'marrow-before-agent', targetCommand(GEMINI_CONTEXT_HOOK_COMMAND),
|
|
2284
|
+
undefined, GEMINI_HOOK_TIMEOUT_MS,
|
|
2285
|
+
))) observedHooks.push('pre_action');
|
|
1997
2286
|
if (exactGeminiHookConfigured(
|
|
1998
2287
|
geminiSettings, 'AfterTool', 'marrow-after-tool', targetCommand(GEMINI_ACTION_RESULT_HOOK_COMMAND),
|
|
1999
2288
|
GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
|
|
@@ -2017,11 +2306,14 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
2017
2306
|
} else {
|
|
2018
2307
|
if (capabilityLevel === 'native_hooks'
|
|
2019
2308
|
&& exactHookConfigured(claudeSettings, 'UserPromptSubmit', targetCommand(MCP_CONTEXT_HOOK_COMMAND))) observedHooks.push('prompt');
|
|
2309
|
+
const claudeApprovalHooks = hostApprovals ? claudeHostApprovalHooksConfigured(claudeSettings, targetCommand) : null;
|
|
2020
2310
|
if (capabilityLevel === 'native_hooks'
|
|
2021
|
-
&& exactHookConfigured(claudeSettings, 'PreToolUse', targetCommand(MCP_PRE_ACTION_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
|
|
2311
|
+
&& exactHookConfigured(claudeSettings, 'PreToolUse', targetCommand(MCP_PRE_ACTION_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
|
|
2312
|
+
&& (!hostApprovals || claudeApprovalHooks.permission_request)) observedHooks.push('pre_action');
|
|
2022
2313
|
if (capabilityLevel === 'native_hooks'
|
|
2023
2314
|
&& exactHookConfigured(claudeSettings, 'PostToolUse', targetCommand(MCP_ACTION_RESULT_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
|
|
2024
|
-
&& exactHookConfigured(claudeSettings, 'PostToolUseFailure', targetCommand(MCP_ACTION_RESULT_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
|
|
2315
|
+
&& exactHookConfigured(claudeSettings, 'PostToolUseFailure', targetCommand(MCP_ACTION_RESULT_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
|
|
2316
|
+
&& (!hostApprovals || claudeApprovalHooks.post_tool_batch)) observedHooks.push('action_result');
|
|
2025
2317
|
if (capabilityLevel === 'native_hooks'
|
|
2026
2318
|
&& exactHookConfigured(claudeSettings, 'Stop', targetCommand(MCP_SESSION_END_HOOK_COMMAND))) observedHooks.push('session_end');
|
|
2027
2319
|
}
|
|
@@ -2048,12 +2340,12 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
2048
2340
|
.join('|');
|
|
2049
2341
|
const configFingerprint = capabilityLevel === 'native_hooks'
|
|
2050
2342
|
? client === 'codex' ? codexNativeHookFingerprint(codexSettings)
|
|
2051
|
-
: client === 'cursor' || client === 'composer' ? cursorNativeHookFingerprint(cursorSettings)
|
|
2343
|
+
: client === 'cursor' || client === 'composer' ? cursorNativeHookFingerprint(cursorSettings, { hostApprovals })
|
|
2052
2344
|
: client === 'cline' ? clineNativeHookFingerprint(detection)
|
|
2053
2345
|
: client === 'windsurf' ? windsurfNativeHookFingerprint(windsurfSettings)
|
|
2054
|
-
: client === 'gemini' ? geminiNativeHookFingerprint(geminiSettings)
|
|
2346
|
+
: client === 'gemini' ? geminiNativeHookFingerprint(geminiSettings, { hostApprovals })
|
|
2055
2347
|
: client === 'grok' ? grokNativeHookFingerprint(grokSettings)
|
|
2056
|
-
: claudeNativeHookFingerprint(claudeSettings)
|
|
2348
|
+
: claudeNativeHookFingerprint(claudeSettings, { hostApprovals })
|
|
2057
2349
|
: crypto.createHash('sha256')
|
|
2058
2350
|
.update(`${client}:${capabilityLevel}:${expectedHooks.join(',')}:${fingerprintMaterial}`)
|
|
2059
2351
|
.digest('hex');
|
|
@@ -2220,8 +2512,9 @@ function upsertMcpServerConfig(filePath, options = {}) {
|
|
|
2220
2512
|
return JSON.stringify(config, null, 2) + '\n';
|
|
2221
2513
|
}
|
|
2222
2514
|
|
|
2223
|
-
function inspectSdkDependency(detection) {
|
|
2224
|
-
|
|
2515
|
+
function inspectSdkDependency(detection, { sdkMode = false } = {}) {
|
|
2516
|
+
// No SDK advice unless this project uses the SDK or the owner asked for the SDK runtime.
|
|
2517
|
+
if (!sdkMode && !sdkUseDetected(detection)) {
|
|
2225
2518
|
return { required: false, present: false, install_command: null };
|
|
2226
2519
|
}
|
|
2227
2520
|
|
|
@@ -2346,11 +2639,14 @@ function defaultHarnessInstallMatrix(detection = detectEnvironment(process.cwd()
|
|
|
2346
2639
|
&& exactHookConfigured(codexSettings, 'PreToolUse', CODEX_PRE_ACTION_HOOK_COMMAND, CODEX_NATIVE_HOOK_MATCHER)
|
|
2347
2640
|
&& exactHookConfigured(codexSettings, 'PostToolUse', CODEX_ACTION_RESULT_HOOK_COMMAND, CODEX_NATIVE_HOOK_MATCHER)
|
|
2348
2641
|
&& exactHookConfigured(codexSettings, 'SessionEnd', CODEX_SESSION_END_HOOK_COMMAND));
|
|
2642
|
+
const hostApprovals = hostApprovalHooksSupported();
|
|
2349
2643
|
const cursorSettings = ['cursor', 'composer'].includes(entry.client) ? safeJsonObject(detection.paths.cursorHooks) : null;
|
|
2644
|
+
const cursorApprovalHooks = cursorSettings && hostApprovals ? cursorHostApprovalHooksConfigured(cursorSettings) : null;
|
|
2350
2645
|
const cursorConfigured = Boolean(cursorSettings
|
|
2351
|
-
&& exactCursorHookConfigured(cursorSettings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, CURSOR_NATIVE_HOOK_MATCHER, {
|
|
2352
|
-
timeout: CODEX_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false,
|
|
2646
|
+
&& exactCursorHookConfigured(cursorSettings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, hostApprovals ? CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER : CURSOR_NATIVE_HOOK_MATCHER, {
|
|
2647
|
+
timeout: hostApprovals ? CURSOR_GATE_HOOK_TIMEOUT_SECONDS : CODEX_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false,
|
|
2353
2648
|
})
|
|
2649
|
+
&& (!cursorApprovalHooks || Object.values(cursorApprovalHooks).every(Boolean))
|
|
2354
2650
|
&& exactCursorHookConfigured(cursorSettings, 'postToolUse', CURSOR_ACTION_RESULT_HOOK_COMMAND, CURSOR_NATIVE_HOOK_MATCHER, {
|
|
2355
2651
|
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
2356
2652
|
})
|
|
@@ -2377,9 +2673,13 @@ function defaultHarnessInstallMatrix(detection = detectEnvironment(process.cwd()
|
|
|
2377
2673
|
const geminiConfigured = Boolean(geminiSettings
|
|
2378
2674
|
&& !geminiHooksExplicitlyDisabled(geminiSettings)
|
|
2379
2675
|
&& exactGeminiHookConfigured(
|
|
2380
|
-
geminiSettings, 'BeforeTool', 'marrow-before-tool',
|
|
2676
|
+
geminiSettings, 'BeforeTool', 'marrow-before-tool', geminiPreActionCommand(hostApprovals),
|
|
2381
2677
|
GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
|
|
2382
2678
|
)
|
|
2679
|
+
&& (!hostApprovals || exactGeminiHookConfigured(
|
|
2680
|
+
geminiSettings, 'BeforeAgent', 'marrow-before-agent', GEMINI_CONTEXT_HOOK_COMMAND,
|
|
2681
|
+
undefined, GEMINI_HOOK_TIMEOUT_MS,
|
|
2682
|
+
))
|
|
2383
2683
|
&& exactGeminiHookConfigured(
|
|
2384
2684
|
geminiSettings, 'AfterTool', 'marrow-after-tool', GEMINI_ACTION_RESULT_HOOK_COMMAND,
|
|
2385
2685
|
GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
|
|
@@ -2462,6 +2762,32 @@ function derivedAgentIdsFor(root) {
|
|
|
2462
2762
|
return [...SOURCE_CLIENTS].map((candidate) => stableAgentId(root, candidate));
|
|
2463
2763
|
}
|
|
2464
2764
|
|
|
2765
|
+
// SDK use in this project: the SDK is a declared or installed dependency, or an earlier install
|
|
2766
|
+
// already wrote Marrow's passive runtime here. Without it, no SDK file or SDK advice is written.
|
|
2767
|
+
function sdkUseDetected(detection) {
|
|
2768
|
+
if (exists(detection.paths.passiveRuntime)) return true;
|
|
2769
|
+
if (!detection.node) return false;
|
|
2770
|
+
if (findUp(detection.root, [path.join('node_modules', '@getmarrow', 'sdk', 'package.json')])) return true;
|
|
2771
|
+
const raw = safeRead(detection.paths.packageJson);
|
|
2772
|
+
if (!raw) return false;
|
|
2773
|
+
// A package.json Marrow cannot parse is checked by hand (the SDK check says so).
|
|
2774
|
+
try {
|
|
2775
|
+
JSON.parse(raw);
|
|
2776
|
+
} catch {
|
|
2777
|
+
return /@getmarrow\/sdk/.test(raw);
|
|
2778
|
+
}
|
|
2779
|
+
return /"@getmarrow\/sdk"/.test(raw) || /@getmarrow\/sdk@/.test(raw);
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
// Hosts that read AGENTS.md. Claude Code reads CLAUDE.md and Cursor gets its own rule file, so
|
|
2783
|
+
// a project with only those gets no AGENTS.md unless it already holds Marrow's block.
|
|
2784
|
+
const AGENTS_MD_READERS = Object.freeze(['codex', 'windsurf', 'gemini', 'cline', 'grok', 'hermes', 'openclaw']);
|
|
2785
|
+
|
|
2786
|
+
function agentsMdWanted(detection) {
|
|
2787
|
+
if (AGENTS_MD_READERS.some((name) => detection[name])) return true;
|
|
2788
|
+
return Boolean(marrowManagedBlockInText(safeRead(detection.paths.agentsMd)));
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2465
2791
|
function buildPlan(detection, options) {
|
|
2466
2792
|
const client = options.client || detectedClient(detection);
|
|
2467
2793
|
// Server-facing configuration carries only a configured agent id. Without one, the MCP
|
|
@@ -2488,9 +2814,14 @@ function buildPlan(detection, options) {
|
|
|
2488
2814
|
return write;
|
|
2489
2815
|
};
|
|
2490
2816
|
const mcpTargetVersion = executableMcpTarget(options).version;
|
|
2817
|
+
// Hooks start the verified local MCP runtime for this exact version when there is one.
|
|
2818
|
+
const runtimeVersion = options.mcpRuntime && options.mcpRuntime.version === mcpTargetVersion ? mcpTargetVersion : null;
|
|
2819
|
+
const localize = (text) => (runtimeVersion ? localizeHookSettingsText(text, runtimeVersion) : text);
|
|
2491
2820
|
const retarget = (value) => retargetMcpPackageSpec(value, mcpTargetVersion);
|
|
2821
|
+
// The host-approval hook layout only for an MCP that answers those hooks.
|
|
2822
|
+
const hostApprovals = hostApprovalHooksSupported(mcpTargetVersion);
|
|
2492
2823
|
const mode = options.mode === 'auto'
|
|
2493
|
-
? detection
|
|
2824
|
+
? sdkUseDetected(detection) ? 'both' : 'mcp'
|
|
2494
2825
|
: options.mode;
|
|
2495
2826
|
const writes = [];
|
|
2496
2827
|
|
|
@@ -2516,7 +2847,7 @@ function buildPlan(detection, options) {
|
|
|
2516
2847
|
type: 'json-transform',
|
|
2517
2848
|
path: detection.paths.claudeSettings,
|
|
2518
2849
|
label: 'Claude Code MCP passive hooks',
|
|
2519
|
-
transform: (filePath) => retarget(upsertClaudeHooks(filePath)),
|
|
2850
|
+
transform: (filePath) => localize(retarget(upsertClaudeHooks(filePath, { hostApprovals }))),
|
|
2520
2851
|
});
|
|
2521
2852
|
}
|
|
2522
2853
|
if (detection.codex) {
|
|
@@ -2524,7 +2855,7 @@ function buildPlan(detection, options) {
|
|
|
2524
2855
|
type: 'json-transform',
|
|
2525
2856
|
path: detection.paths.codexHooks,
|
|
2526
2857
|
label: 'Codex native hooks',
|
|
2527
|
-
transform: (filePath) => retarget(upsertCodexHooks(filePath)),
|
|
2858
|
+
transform: (filePath) => localize(retarget(upsertCodexHooks(filePath))),
|
|
2528
2859
|
});
|
|
2529
2860
|
}
|
|
2530
2861
|
if (detection.cline) {
|
|
@@ -2544,7 +2875,7 @@ function buildPlan(detection, options) {
|
|
|
2544
2875
|
type: 'json-transform',
|
|
2545
2876
|
path: detection.paths.windsurfHooks,
|
|
2546
2877
|
label: 'Windsurf native hooks',
|
|
2547
|
-
transform: (filePath) => retarget(upsertWindsurfHooks(filePath)),
|
|
2878
|
+
transform: (filePath) => localize(retarget(upsertWindsurfHooks(filePath))),
|
|
2548
2879
|
});
|
|
2549
2880
|
}
|
|
2550
2881
|
if (detection.gemini) {
|
|
@@ -2552,7 +2883,7 @@ function buildPlan(detection, options) {
|
|
|
2552
2883
|
type: 'json-transform',
|
|
2553
2884
|
path: detection.paths.geminiSettings,
|
|
2554
2885
|
label: 'Gemini CLI native hooks',
|
|
2555
|
-
transform: (filePath) => retarget(upsertGeminiHooks(filePath)),
|
|
2886
|
+
transform: (filePath) => localize(retarget(upsertGeminiHooks(filePath, { hostApprovals }))),
|
|
2556
2887
|
});
|
|
2557
2888
|
}
|
|
2558
2889
|
if (detection.grok) {
|
|
@@ -2563,7 +2894,7 @@ function buildPlan(detection, options) {
|
|
|
2563
2894
|
label: 'Grok native hooks',
|
|
2564
2895
|
isManaged: managedGrokHooksFile,
|
|
2565
2896
|
conflict_fix: 'Preserve or move the unmanaged owner Grok hook file after owner review, then rerun npx @getmarrow/install update.',
|
|
2566
|
-
transform: (filePath) => retarget(upsertGrokHooks(filePath)),
|
|
2897
|
+
transform: (filePath) => localize(retarget(upsertGrokHooks(filePath))),
|
|
2567
2898
|
});
|
|
2568
2899
|
}
|
|
2569
2900
|
writes.push(mcpConfigWrite(detection.paths.mcpJson, 'Project MCP server config'));
|
|
@@ -2587,18 +2918,20 @@ function buildPlan(detection, options) {
|
|
|
2587
2918
|
type: 'json-transform',
|
|
2588
2919
|
path: detection.paths.cursorHooks,
|
|
2589
2920
|
label: 'Cursor native hooks',
|
|
2590
|
-
transform: (filePath) => retarget(upsertCursorHooks(filePath)),
|
|
2921
|
+
transform: (filePath) => localize(retarget(upsertCursorHooks(filePath, { hostApprovals }))),
|
|
2591
2922
|
});
|
|
2592
2923
|
writes.push(mcpConfigWrite(detection.paths.cursorMcp, 'Cursor MCP server config'));
|
|
2593
2924
|
}
|
|
2594
2925
|
}
|
|
2595
2926
|
|
|
2596
|
-
|
|
2927
|
+
// AGENTS.md only for a host that reads it, an explicit --md or --both, or a file that already
|
|
2928
|
+
// carries Marrow's block (so its pins stay current).
|
|
2929
|
+
if (mode === 'md' || (options.mode === 'both') || ((mode === 'both' || mode === 'mcp') && agentsMdWanted(detection))) {
|
|
2597
2930
|
writes.push({
|
|
2598
2931
|
type: 'md-block',
|
|
2599
2932
|
path: detection.paths.agentsMd,
|
|
2600
2933
|
label: 'Agent instructions',
|
|
2601
|
-
block: retarget(passiveInstructions()),
|
|
2934
|
+
block: retarget(passiveInstructions({ hostApprovals })),
|
|
2602
2935
|
});
|
|
2603
2936
|
}
|
|
2604
2937
|
|
|
@@ -2607,11 +2940,11 @@ function buildPlan(detection, options) {
|
|
|
2607
2940
|
type: 'file',
|
|
2608
2941
|
path: detection.paths.cursorRules,
|
|
2609
2942
|
label: 'Cursor Marrow rule',
|
|
2610
|
-
content: retarget(passiveInstructions()).replace(/<!--[^>]+-->/g, '').trim() + '\n',
|
|
2943
|
+
content: retarget(passiveInstructions({ hostApprovals })).replace(/<!--[^>]+-->/g, '').trim() + '\n',
|
|
2611
2944
|
});
|
|
2612
2945
|
}
|
|
2613
2946
|
|
|
2614
|
-
return { mode, root: detection.root, writes, mcp_target_version: mcpTargetVersion };
|
|
2947
|
+
return { mode, root: detection.root, writes, mcp_target_version: mcpTargetVersion, host_approval_hooks: hostApprovals };
|
|
2615
2948
|
}
|
|
2616
2949
|
|
|
2617
2950
|
function assertContainedManagedTarget(root, targetPath) {
|
|
@@ -2728,7 +3061,9 @@ function applyPlan(plan, options) {
|
|
|
2728
3061
|
undo = planned.action === 'update' ? planned.undo || [] : null;
|
|
2729
3062
|
}
|
|
2730
3063
|
} else if (write.type === 'owned-executable') {
|
|
2731
|
-
|
|
3064
|
+
// A file Marrow wrote for another pinned MCP version is still Marrow's and moves to this
|
|
3065
|
+
// pin; any other difference is the owner's edit and is never overwritten.
|
|
3066
|
+
if (fileExists && before !== write.content && !sameMarrowFile(before, [write.content])) {
|
|
2732
3067
|
after = before;
|
|
2733
3068
|
hookConflict = true;
|
|
2734
3069
|
} else {
|
|
@@ -2803,6 +3138,294 @@ function applyPlan(plan, options) {
|
|
|
2803
3138
|
return changes;
|
|
2804
3139
|
}
|
|
2805
3140
|
|
|
3141
|
+
// ---------------------------------------------------------------------------
|
|
3142
|
+
// Uninstall: removes only what Marrow wrote. Every other hook, server, setting and file is kept.
|
|
3143
|
+
// Files are read in-process and never printed; the report names paths and counts only.
|
|
3144
|
+
// ---------------------------------------------------------------------------
|
|
3145
|
+
|
|
3146
|
+
// Hook groups of the Claude Code, Codex, Grok and Gemini shape:
|
|
3147
|
+
// hooks[event] = [{ matcher?, hooks: [handler, ...] }].
|
|
3148
|
+
function withoutMarrowGroupedHandlers(settings, isMarrowHandler) {
|
|
3149
|
+
const hooks = settings?.hooks;
|
|
3150
|
+
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return { settings, removed: 0, kept: 0 };
|
|
3151
|
+
let removed = 0;
|
|
3152
|
+
let kept = 0;
|
|
3153
|
+
const next = {};
|
|
3154
|
+
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
3155
|
+
if (!Array.isArray(entries)) {
|
|
3156
|
+
next[eventName] = entries;
|
|
3157
|
+
continue;
|
|
3158
|
+
}
|
|
3159
|
+
const retained = [];
|
|
3160
|
+
let touched = false;
|
|
3161
|
+
for (const entry of entries) {
|
|
3162
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !Array.isArray(entry.hooks)) {
|
|
3163
|
+
retained.push(entry);
|
|
3164
|
+
kept += 1;
|
|
3165
|
+
continue;
|
|
3166
|
+
}
|
|
3167
|
+
const handlers = entry.hooks.filter((handler) => !isMarrowHandler(handler));
|
|
3168
|
+
kept += handlers.length;
|
|
3169
|
+
if (handlers.length === entry.hooks.length) {
|
|
3170
|
+
retained.push(entry);
|
|
3171
|
+
continue;
|
|
3172
|
+
}
|
|
3173
|
+
touched = true;
|
|
3174
|
+
removed += entry.hooks.length - handlers.length;
|
|
3175
|
+
if (handlers.length > 0) retained.push({ ...entry, hooks: handlers });
|
|
3176
|
+
}
|
|
3177
|
+
// An event that held only Marrow's entries goes away with them.
|
|
3178
|
+
if (touched && retained.length === 0) continue;
|
|
3179
|
+
next[eventName] = retained;
|
|
3180
|
+
}
|
|
3181
|
+
const result = { ...settings, hooks: next };
|
|
3182
|
+
if (removed > 0 && Object.keys(next).length === 0) delete result.hooks;
|
|
3183
|
+
return { settings: result, removed, kept };
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
// Flat hook lists of the Cursor and Windsurf shape: hooks[event] = [{ command, ... }].
|
|
3187
|
+
function withoutMarrowFlatEntries(settings, isMarrowEntry) {
|
|
3188
|
+
const hooks = settings?.hooks;
|
|
3189
|
+
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return { settings, removed: 0, kept: 0 };
|
|
3190
|
+
let removed = 0;
|
|
3191
|
+
let kept = 0;
|
|
3192
|
+
const next = {};
|
|
3193
|
+
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
3194
|
+
if (!Array.isArray(entries)) {
|
|
3195
|
+
next[eventName] = entries;
|
|
3196
|
+
continue;
|
|
3197
|
+
}
|
|
3198
|
+
const retained = entries.filter((entry) => !(entry && typeof entry === 'object' && !Array.isArray(entry) && isMarrowEntry(entry)));
|
|
3199
|
+
kept += retained.length;
|
|
3200
|
+
removed += entries.length - retained.length;
|
|
3201
|
+
if (retained.length === 0 && entries.length > 0) continue;
|
|
3202
|
+
next[eventName] = retained;
|
|
3203
|
+
}
|
|
3204
|
+
const result = { ...settings, hooks: next };
|
|
3205
|
+
if (removed > 0 && Object.keys(next).length === 0) delete result.hooks;
|
|
3206
|
+
return { settings: result, removed, kept };
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
const isMarrowCommandHandler = (handler) => Boolean(handler && typeof handler === 'object' && !Array.isArray(handler)
|
|
3210
|
+
&& handler.type === 'command' && marrowHookSubcommand(handler.command));
|
|
3211
|
+
const isMarrowGeminiHandler = (handler) => Boolean(handler && typeof handler === 'object' && !Array.isArray(handler)
|
|
3212
|
+
&& (String(handler.name || '').startsWith('marrow-') || geminiMarrowHookEntrypoint(handler.command)));
|
|
3213
|
+
const isMarrowGrokHandler = (handler) => Boolean(handler && typeof handler === 'object' && !Array.isArray(handler)
|
|
3214
|
+
&& handler.type === 'command' && grokMarrowHookSubcommand(handler.command));
|
|
3215
|
+
|
|
3216
|
+
// The MCP server entry only when Marrow wrote it: npx running the published package.
|
|
3217
|
+
function withoutMarrowMcpServer(config) {
|
|
3218
|
+
const servers = config?.mcpServers;
|
|
3219
|
+
const entry = servers && typeof servers === 'object' && !Array.isArray(servers) ? servers.marrow : null;
|
|
3220
|
+
if (!entry) return { config, removed: 0, kept: 0, custom: false };
|
|
3221
|
+
const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
|
|
3222
|
+
const ours = entry.command === 'npx' && args.includes('marrow-mcp')
|
|
3223
|
+
&& args.some((arg) => /^--package=@getmarrow\/mcp(?:@[^\s]+)?$/.test(arg));
|
|
3224
|
+
if (!ours) return { config, removed: 0, kept: 1, custom: true };
|
|
3225
|
+
const nextServers = { ...servers };
|
|
3226
|
+
delete nextServers.marrow;
|
|
3227
|
+
const result = { ...config, mcpServers: nextServers };
|
|
3228
|
+
if (Object.keys(nextServers).length === 0) delete result.mcpServers;
|
|
3229
|
+
return { config: result, removed: 1, kept: Object.keys(nextServers).length, custom: false };
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
function withoutMarrowInstructionBlock(content) {
|
|
3233
|
+
const block = marrowManagedBlockInText(content);
|
|
3234
|
+
if (!block) return { content, removed: 0 };
|
|
3235
|
+
const start = content.indexOf(block);
|
|
3236
|
+
const before = content.slice(0, start);
|
|
3237
|
+
const after = content.slice(start + block.length).replace(/^\n/, '');
|
|
3238
|
+
const head = before.trim() ? before.replace(/\n*$/, after.trim() ? '\n\n' : '\n') : '';
|
|
3239
|
+
return { content: `${head}${after}`, removed: 1 };
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
// Marrow-written files compare equal once the pinned MCP version is normalized.
|
|
3243
|
+
function sameMarrowFile(actual, candidates) {
|
|
3244
|
+
const normalize = (value) => String(value).replace(/@getmarrow\/mcp@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g, '@getmarrow/mcp@<pinned>');
|
|
3245
|
+
const normalized = normalize(actual);
|
|
3246
|
+
return candidates.some((candidate) => normalize(candidate) === normalized);
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
function buildUninstallPlan(detection) {
|
|
3250
|
+
const steps = [];
|
|
3251
|
+
const json = (filePath, label, transform, extra = {}) => steps.push({ kind: 'json', path: filePath, label, transform, ...extra });
|
|
3252
|
+
const mcpServer = (value) => {
|
|
3253
|
+
const result = withoutMarrowMcpServer(value);
|
|
3254
|
+
return { settings: result.config, removed: result.removed, kept: result.kept, custom: result.custom };
|
|
3255
|
+
};
|
|
3256
|
+
json(detection.paths.claudeSettings, 'Claude Code hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowCommandHandler));
|
|
3257
|
+
json(detection.paths.codexHooks, 'Codex hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowCommandHandler));
|
|
3258
|
+
json(detection.paths.cursorHooks, 'Cursor hooks', (value) => withoutMarrowFlatEntries(value, (entry) => Boolean(cursorMarrowHookSubcommand(entry.command))));
|
|
3259
|
+
json(detection.paths.windsurfHooks, 'Windsurf hooks', (value) => withoutMarrowFlatEntries(value, (entry) => Boolean(windsurfMarrowHookEntrypoint(entry.command))));
|
|
3260
|
+
json(detection.paths.geminiSettings, 'Gemini CLI hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowGeminiHandler));
|
|
3261
|
+
json(detection.paths.grokHooks, 'Grok hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowGrokHandler), {
|
|
3262
|
+
root: detection.home,
|
|
3263
|
+
grok: true,
|
|
3264
|
+
// Marrow's own global file: deleted when nothing but Marrow's hooks was in it.
|
|
3265
|
+
removeWhenEmpty: true,
|
|
3266
|
+
});
|
|
3267
|
+
json(detection.paths.mcpJson, 'Project MCP server config', mcpServer);
|
|
3268
|
+
json(detection.paths.cursorMcp, 'Cursor MCP server config', mcpServer);
|
|
3269
|
+
steps.push({ kind: 'md-block', path: detection.paths.agentsMd, label: 'Agent instructions' });
|
|
3270
|
+
const instructionVariants = [false, true].map((hostApprovals) => passiveInstructions({ hostApprovals }).replace(/<!--[^>]+-->/g, '').trim() + '\n');
|
|
3271
|
+
steps.push({ kind: 'owned-file', path: detection.paths.cursorRules, label: 'Cursor Marrow rule', candidates: instructionVariants });
|
|
3272
|
+
for (const hook of clineHookContract(detection)) {
|
|
3273
|
+
steps.push({ kind: 'owned-file', path: hook.path, label: hook.label, candidates: [hook.content] });
|
|
3274
|
+
}
|
|
3275
|
+
return steps;
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
function uninstallFileState(filePath) {
|
|
3279
|
+
try {
|
|
3280
|
+
const stat = fs.lstatSync(filePath);
|
|
3281
|
+
if (stat.isSymbolicLink() || !stat.isFile()) return 'unsafe';
|
|
3282
|
+
if (stat.size > 4 * 1024 * 1024) return 'too_large';
|
|
3283
|
+
return 'file';
|
|
3284
|
+
} catch {
|
|
3285
|
+
return 'missing';
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
async function uninstall(options = {}) {
|
|
3290
|
+
const home = options.home || process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
3291
|
+
const detection = detectEnvironment(options.cwd || process.cwd(), { ...process.env, HOME: home, USERPROFILE: home });
|
|
3292
|
+
const apply = Boolean(options.yes && !options.dryRun);
|
|
3293
|
+
const root = path.resolve(detection.root);
|
|
3294
|
+
const changes = [];
|
|
3295
|
+
let controller = null;
|
|
3296
|
+
if (apply && options.controller !== false) {
|
|
3297
|
+
// A running controller restores missing managed hooks every five minutes; stop it first.
|
|
3298
|
+
try {
|
|
3299
|
+
const stopped = await stopProjectControllers({ root, client: detectedClient(detection), mode: 'auto' });
|
|
3300
|
+
controller = { stopped: stopped.stopped || 0, changed: Boolean(stopped.changed) };
|
|
3301
|
+
} catch {
|
|
3302
|
+
controller = { stopped: 0, changed: false, error: 'controller_stop_failed' };
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
for (const step of buildUninstallPlan(detection)) {
|
|
3306
|
+
const state = uninstallFileState(step.path);
|
|
3307
|
+
if (state === 'missing') continue;
|
|
3308
|
+
const change = { path: step.path, label: step.label, removed_entries: 0, kept_entries: 0, action: 'unchanged', applied: false };
|
|
3309
|
+
if (state !== 'file') {
|
|
3310
|
+
changes.push({ ...change, action: 'skipped', reason: state === 'unsafe' ? 'not a regular file' : 'larger than 4 MB' });
|
|
3311
|
+
continue;
|
|
3312
|
+
}
|
|
3313
|
+
try {
|
|
3314
|
+
const writeRoot = step.root ? path.resolve(step.root) : root;
|
|
3315
|
+
if (step.grok) {
|
|
3316
|
+
if (!managedGrokHooksFile(step.path)) {
|
|
3317
|
+
changes.push({ ...change, action: 'skipped', reason: 'not managed by Marrow' });
|
|
3318
|
+
continue;
|
|
3319
|
+
}
|
|
3320
|
+
assertSafeGrokHookTarget(writeRoot, step.path);
|
|
3321
|
+
}
|
|
3322
|
+
assertContainedManagedTarget(writeRoot, step.path);
|
|
3323
|
+
const before = fs.readFileSync(step.path, 'utf8');
|
|
3324
|
+
if (step.kind === 'owned-file') {
|
|
3325
|
+
if (!sameMarrowFile(before, step.candidates)) {
|
|
3326
|
+
if (MARROW_MANAGED_TEXT_RE.test(before)) changes.push({ ...change, action: 'kept', reason: 'edited after Marrow wrote it' });
|
|
3327
|
+
continue;
|
|
3328
|
+
}
|
|
3329
|
+
change.removed_entries = 1;
|
|
3330
|
+
change.action = 'delete_file';
|
|
3331
|
+
if (apply) fs.unlinkSync(step.path);
|
|
3332
|
+
} else if (step.kind === 'md-block') {
|
|
3333
|
+
const result = withoutMarrowInstructionBlock(before);
|
|
3334
|
+
if (!result.removed) continue;
|
|
3335
|
+
change.removed_entries = 1;
|
|
3336
|
+
change.action = result.content.trim() ? 'update' : 'delete_file';
|
|
3337
|
+
if (change.action === 'delete_file') change.note = 'it held only the Marrow block';
|
|
3338
|
+
else change.note = 'block_only';
|
|
3339
|
+
if (apply) {
|
|
3340
|
+
if (change.action === 'delete_file') fs.unlinkSync(step.path);
|
|
3341
|
+
else atomicWriteManagedFile(writeRoot, step.path, result.content);
|
|
3342
|
+
}
|
|
3343
|
+
} else {
|
|
3344
|
+
let parsed;
|
|
3345
|
+
try {
|
|
3346
|
+
// Marrow's hook commands in their canonical form, whether they start through npx or
|
|
3347
|
+
// the local runtime, so both are recognized and removed.
|
|
3348
|
+
parsed = before.trim() ? mapHookCommands(JSON.parse(before), delocalizeHookCommand) : {};
|
|
3349
|
+
} catch {
|
|
3350
|
+
changes.push({ ...change, action: 'skipped', reason: 'invalid JSON' });
|
|
3351
|
+
continue;
|
|
3352
|
+
}
|
|
3353
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
3354
|
+
changes.push({ ...change, action: 'skipped', reason: 'root is not a JSON object' });
|
|
3355
|
+
continue;
|
|
3356
|
+
}
|
|
3357
|
+
const result = step.transform(parsed);
|
|
3358
|
+
change.kept_entries = result.kept;
|
|
3359
|
+
if (result.custom) {
|
|
3360
|
+
changes.push({ ...change, action: 'kept', reason: 'custom marrow server entry' });
|
|
3361
|
+
continue;
|
|
3362
|
+
}
|
|
3363
|
+
if (!result.removed) continue;
|
|
3364
|
+
change.removed_entries = result.removed;
|
|
3365
|
+
const emptied = step.removeWhenEmpty && result.kept === 0
|
|
3366
|
+
&& Object.keys(result.settings).every((key) => key === 'hooks');
|
|
3367
|
+
change.action = emptied ? 'delete_file' : 'update';
|
|
3368
|
+
if (apply) {
|
|
3369
|
+
if (emptied) fs.unlinkSync(step.path);
|
|
3370
|
+
else atomicWriteManagedFile(writeRoot, step.path, JSON.stringify(result.settings, null, 2) + '\n');
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
change.applied = apply;
|
|
3374
|
+
changes.push(change);
|
|
3375
|
+
} catch (error) {
|
|
3376
|
+
changes.push({ ...change, action: 'skipped', reason: error instanceof Error ? error.message.slice(0, 200) : 'unsafe target' });
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
const kept = [];
|
|
3380
|
+
if (exists(detection.paths.passiveRuntime)) kept.push({ path: detection.paths.passiveRuntime, reason: 'your start command may import it; remove that import first, then delete the file' });
|
|
3381
|
+
if (exists(detection.paths.passiveEnv)) kept.push({ path: detection.paths.passiveEnv, reason: 'it may hold your own edits; delete it when no longer needed' });
|
|
3382
|
+
let hermesEntry = false;
|
|
3383
|
+
try {
|
|
3384
|
+
hermesEntry = Boolean(detection.hermesConfig)
|
|
3385
|
+
&& /(?:^|\n)mcp_servers:[\s\S]*\n[ \t]+marrow:/.test(fs.readFileSync(detection.paths.hermesConfig, 'utf8'));
|
|
3386
|
+
} catch {
|
|
3387
|
+
hermesEntry = false;
|
|
3388
|
+
}
|
|
3389
|
+
if (hermesEntry) kept.push({ path: detection.paths.hermesConfig, reason: 'remove mcp_servers.marrow by hand; uninstall never rewrites the Hermes config, which holds other servers\' settings' });
|
|
3390
|
+
// The local MCP runtime (~/.marrow/runtime) goes too; hooks elsewhere then start through npx.
|
|
3391
|
+
const runtime = removeMcpRuntime(options.home || detection.home, { dryRun: !apply });
|
|
3392
|
+
const removedEntries = changes.reduce((sum, change) => sum + change.removed_entries, 0);
|
|
3393
|
+
return {
|
|
3394
|
+
uninstall: {
|
|
3395
|
+
applied: apply,
|
|
3396
|
+
dry_run: !apply,
|
|
3397
|
+
root,
|
|
3398
|
+
controller,
|
|
3399
|
+
changes,
|
|
3400
|
+
kept,
|
|
3401
|
+
runtime: runtime.removed || runtime.would_remove ? runtime : null,
|
|
3402
|
+
removed_entries: removedEntries,
|
|
3403
|
+
next_step: apply
|
|
3404
|
+
? 'Restart the agent hosts so they stop loading the removed hooks and MCP server.'
|
|
3405
|
+
: removedEntries > 0 || runtime.would_remove
|
|
3406
|
+
? 'Nothing was changed. Run npx @getmarrow/install uninstall --yes to remove these Marrow entries.'
|
|
3407
|
+
: 'Nothing to remove.',
|
|
3408
|
+
},
|
|
3409
|
+
};
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
function printUninstallReport(report, sink = (text) => process.stdout.write(text)) {
|
|
3413
|
+
const result = report.uninstall;
|
|
3414
|
+
const where = (filePath) => (filePath.startsWith(`${result.root}${path.sep}`) ? path.relative(result.root, filePath) : filePath);
|
|
3415
|
+
const entries = (count) => `${count} Marrow entr${count === 1 ? 'y' : 'ies'}`;
|
|
3416
|
+
sink(`Marrow uninstall${result.applied ? '' : ' (preview)'}: ${entries(result.removed_entries)} ${result.applied ? 'removed' : 'to remove'}.\n`);
|
|
3417
|
+
for (const change of result.changes) {
|
|
3418
|
+
if (change.action === 'update' && change.note) sink(`- ${where(change.path)}: ${result.applied ? 'removed' : 'would remove'} the Marrow block; your text is kept.\n`);
|
|
3419
|
+
else if (change.action === 'update') sink(`- ${where(change.path)}: ${result.applied ? 'removed' : 'would remove'} ${entries(change.removed_entries)}; kept ${change.kept_entries} of yours.\n`);
|
|
3420
|
+
else if (change.action === 'delete_file') sink(`- ${where(change.path)}: ${result.applied ? 'deleted' : 'would delete'} (${change.note || 'Marrow\'s own file'}).\n`);
|
|
3421
|
+
else if (change.action === 'kept' || change.action === 'skipped') sink(`- ${where(change.path)}: left unchanged (${change.reason}).\n`);
|
|
3422
|
+
}
|
|
3423
|
+
for (const entry of result.kept) sink(`- ${where(entry.path)}: left in place; ${entry.reason}.\n`);
|
|
3424
|
+
if (result.runtime) sink(`- ${result.runtime.path}: ${result.applied ? 'removed' : 'would remove'} Marrow's local MCP runtime (other projects' hooks start through npx until the next install there).\n`);
|
|
3425
|
+
if (result.controller?.stopped) sink(`Stopped ${result.controller.stopped} Marrow controller${result.controller.stopped === 1 ? '' : 's'} for this project.\n`);
|
|
3426
|
+
sink(`${result.next_step}\n`);
|
|
3427
|
+
}
|
|
3428
|
+
|
|
2806
3429
|
const SELF_TEST_READ_TIMEOUT_MS = 15_000;
|
|
2807
3430
|
|
|
2808
3431
|
async function requestJson(url, options) {
|
|
@@ -2976,7 +3599,15 @@ function runtimeGateVerified(runtime) {
|
|
|
2976
3599
|
return ['allow', 'warn', 'review_required', 'block'].includes(decision);
|
|
2977
3600
|
}
|
|
2978
3601
|
|
|
2979
|
-
|
|
3602
|
+
// When Marrow refuses this machine's agent identity, the one command that fixes it. A key that
|
|
3603
|
+
// is simply not bound to one agent needs nothing: Marrow resolves the agent on each call.
|
|
3604
|
+
const IDENTITY_REFUSED_RE = /AGENT_NOT_REGISTERED|wrong_agent_id|MARROW_AGENT_SCOPE_MISMATCH|Agent-bound key cannot access another agent/i;
|
|
3605
|
+
function identityRefusalFix(message, configuredAgentId = '') {
|
|
3606
|
+
if (!IDENTITY_REFUSED_RE.test(String(message || ''))) return null;
|
|
3607
|
+
return configuredAgentId
|
|
3608
|
+
? 'unset MARROW_AGENT_ID MARROW_FLEET_AGENT_ID && npx -y @getmarrow/install@latest'
|
|
3609
|
+
: 'MARROW_API_KEY=your_agent_key npx -y @getmarrow/install@latest (replace your_agent_key with a key for one agent from your Marrow account)';
|
|
3610
|
+
}
|
|
2980
3611
|
|
|
2981
3612
|
// The agent Marrow resolved for this key: its single bound agent or the Free plan seat. An
|
|
2982
3613
|
// unbound key has no single agent, and a locally derived id is never substituted for it.
|
|
@@ -3224,7 +3855,11 @@ async function runSelfTest(options) {
|
|
|
3224
3855
|
method: 'POST',
|
|
3225
3856
|
headers,
|
|
3226
3857
|
body: JSON.stringify({
|
|
3227
|
-
|
|
3858
|
+
// One registration per agent: two agents with the same hook configuration (a second
|
|
3859
|
+
// agent installed on the same project) are two events, so the second is not refused
|
|
3860
|
+
// as a conflicting reuse of the first agent's event id. Reruns by one agent repeat
|
|
3861
|
+
// the same id.
|
|
3862
|
+
event_id: `activation-${crypto.createHash('sha256').update(`${activationConfigFingerprint}\0${activationAgentId}`).digest('hex').slice(0, 32)}`,
|
|
3228
3863
|
event_type: 'activation_profile_registered',
|
|
3229
3864
|
harness: options.activation.harness,
|
|
3230
3865
|
agent_id: activationAgentId,
|
|
@@ -3291,7 +3926,8 @@ async function runSelfTest(options) {
|
|
|
3291
3926
|
bound_agent_ids: serverIdentity.bound_agent_ids,
|
|
3292
3927
|
activation_verified: activationVerified,
|
|
3293
3928
|
activation_identity_unresolved: activationIdentityUnresolved,
|
|
3294
|
-
|
|
3929
|
+
// An unbound key is fine: Marrow resolves the agent on each call, so there is nothing to fix.
|
|
3930
|
+
activation_exact_fix: null,
|
|
3295
3931
|
activation_scope: options.activation ? 'server_self_test_only' : null,
|
|
3296
3932
|
coverage_verified: false,
|
|
3297
3933
|
passive_live: false,
|
|
@@ -3486,6 +4122,16 @@ function printReport(report, sink) {
|
|
|
3486
4122
|
}
|
|
3487
4123
|
}
|
|
3488
4124
|
|
|
4125
|
+
if (report.mcp_runtime) {
|
|
4126
|
+
const runtime = report.mcp_runtime;
|
|
4127
|
+
out('\nMCP local runtime:\n');
|
|
4128
|
+
out(`- hooks start: ${runtime.hooks_start === 'local_runtime' ? `the verified local copy of @getmarrow/mcp@${runtime.version} (${runtime.path})` : 'through npx'}\n`);
|
|
4129
|
+
out(`- state: ${runtime.state}${runtime.reason ? ` (${runtime.reason})` : ''}\n`);
|
|
4130
|
+
if (runtime.removed_versions?.length) out(`- removed older versions: ${runtime.removed_versions.join(', ')}\n`);
|
|
4131
|
+
}
|
|
4132
|
+
if (report.owner_key_storage) {
|
|
4133
|
+
out(`\nAPI key for hosts opened outside this terminal: ${report.owner_key_storage.state}${report.owner_key_storage.path ? ` (${report.owner_key_storage.path})` : ''}\n`);
|
|
4134
|
+
}
|
|
3489
4135
|
out('\nLocal session loop guard:\n');
|
|
3490
4136
|
out(`- configured: ${report.loop_guard_configured ? 'yes' : 'no'}\n`);
|
|
3491
4137
|
out(`- isolated self-test passed: ${report.loop_guard_self_tested ? 'yes' : 'no'}\n`);
|
|
@@ -3744,6 +4390,62 @@ function hermesWiringReport(detection, changes, options, planMode) {
|
|
|
3744
4390
|
};
|
|
3745
4391
|
}
|
|
3746
4392
|
|
|
4393
|
+
// The local MCP runtime for the target version: installed (or kept) on a write run of the
|
|
4394
|
+
// command, otherwise only an existing verified copy is used. `mcpLocalRuntime: false` (or
|
|
4395
|
+
// MARROW_LOCAL_RUNTIME=0, --no-local-runtime) leaves every hook of this project on npx.
|
|
4396
|
+
function resolveMcpRuntime(detection, options, mcpTarget) {
|
|
4397
|
+
const home = options.home || detection.home;
|
|
4398
|
+
if (options.mcpLocalRuntime === false) return { state: 'disabled' };
|
|
4399
|
+
const version = mcpTarget.version;
|
|
4400
|
+
const integrity = mcpTarget.integrity || (version === MCP_ADAPTER_VERSION ? MCP_ADAPTER_INTEGRITY : null);
|
|
4401
|
+
if (!integrity) return { state: 'skipped', reason: 'no_verified_integrity', version };
|
|
4402
|
+
const install = options.mcpLocalRuntime === true && options.yes && !options.dryRun && !options.doctor && options.maintenance !== true;
|
|
4403
|
+
if (install) {
|
|
4404
|
+
return ensureMcpRuntime({
|
|
4405
|
+
home,
|
|
4406
|
+
version,
|
|
4407
|
+
integrity,
|
|
4408
|
+
sdk: { version: SDK_ADAPTER_VERSION, integrity: SDK_ADAPTER_INTEGRITY },
|
|
4409
|
+
...(typeof options.mcpRuntimeInstall === 'function' ? { npmInstall: options.mcpRuntimeInstall } : {}),
|
|
4410
|
+
...(options.mcpRuntimeNode ? { nodePath: options.mcpRuntimeNode } : {}),
|
|
4411
|
+
// One line on stderr before npm runs, so the wait is never silent (stdout stays clean
|
|
4412
|
+
// for --json).
|
|
4413
|
+
onProgress: typeof options.mcpRuntimeProgress === 'function' ? options.mcpRuntimeProgress : (text) => process.stderr.write(text),
|
|
4414
|
+
});
|
|
4415
|
+
}
|
|
4416
|
+
return verifyMcpRuntime(home, version, integrity) || { state: 'absent', version };
|
|
4417
|
+
}
|
|
4418
|
+
|
|
4419
|
+
// Whether this project's managed hooks start the local runtime now, as its install chose.
|
|
4420
|
+
function projectHooksUseLocalRuntime(detection) {
|
|
4421
|
+
const paths = detection.paths || {};
|
|
4422
|
+
return [paths.claudeSettings, paths.codexHooks, paths.cursorHooks, paths.windsurfHooks, paths.geminiSettings]
|
|
4423
|
+
.some((filePath) => filePath && safeRead(filePath).includes('/.marrow/runtime/mcp/'));
|
|
4424
|
+
}
|
|
4425
|
+
|
|
4426
|
+
// The controller's maintenance rewrites managed hooks for the pinned MCP and keeps each project
|
|
4427
|
+
// in the form its install chose: on the verified local runtime for that version when the
|
|
4428
|
+
// project's hooks start it (verify only: maintenance never installs), on npx otherwise. A
|
|
4429
|
+
// project installed with --no-local-runtime stays on npx even when another project's install
|
|
4430
|
+
// put a runtime on this machine.
|
|
4431
|
+
function maintenanceMcpRuntime(detection, options = {}) {
|
|
4432
|
+
if (options.mcpLocalRuntime === false || !projectHooksUseLocalRuntime(detection)) return null;
|
|
4433
|
+
const runtime = resolveMcpRuntime(detection, { ...options, maintenance: true, yes: false }, executableMcpTarget(options));
|
|
4434
|
+
return runtime.version && ['present', 'installed', 'verified'].includes(runtime.state) ? runtime : null;
|
|
4435
|
+
}
|
|
4436
|
+
|
|
4437
|
+
function mcpRuntimeReport(runtime) {
|
|
4438
|
+
if (!runtime) return null;
|
|
4439
|
+
return {
|
|
4440
|
+
state: runtime.state,
|
|
4441
|
+
version: runtime.version || null,
|
|
4442
|
+
path: runtime.directory || null,
|
|
4443
|
+
...(runtime.reason ? { reason: runtime.reason } : {}),
|
|
4444
|
+
...(runtime.removed_versions?.length ? { removed_versions: runtime.removed_versions } : {}),
|
|
4445
|
+
hooks_start: runtime.directory && ['present', 'installed', 'verified'].includes(runtime.state) ? 'local_runtime' : 'npx',
|
|
4446
|
+
};
|
|
4447
|
+
}
|
|
4448
|
+
|
|
3747
4449
|
function localControllerAgentId(root, client, configuredAgentId = '') {
|
|
3748
4450
|
return String(configuredAgentId || '').trim() || stableAgentId(root, client);
|
|
3749
4451
|
}
|
|
@@ -3790,6 +4492,11 @@ async function install(options) {
|
|
|
3790
4492
|
});
|
|
3791
4493
|
options.mcpTarget = mcpTarget;
|
|
3792
4494
|
options.mcpTargetVersion = mcpTarget.version;
|
|
4495
|
+
// A maintenance pass keeps the project's hooks in the form its install chose.
|
|
4496
|
+
const mcpRuntime = options.maintenance === true
|
|
4497
|
+
? maintenanceMcpRuntime(detection, options) || { state: options.mcpLocalRuntime === false ? 'disabled' : 'absent', version: mcpTarget.version }
|
|
4498
|
+
: resolveMcpRuntime(detection, options, mcpTarget);
|
|
4499
|
+
options.mcpRuntime = mcpRuntime.version && ['present', 'installed', 'verified'].includes(mcpRuntime.state) ? mcpRuntime : null;
|
|
3793
4500
|
const plan = buildPlan(detection, options);
|
|
3794
4501
|
const writeMode = options.doctor ? 'doctor' : options.dryRun ? 'dry-run' : options.repair ? 'repair' : options.yes ? 'write' : 'dry-run';
|
|
3795
4502
|
const changes = applyPlan(plan, options);
|
|
@@ -3839,7 +4546,7 @@ async function install(options) {
|
|
|
3839
4546
|
intervention_verified: false,
|
|
3840
4547
|
closure_verified: false,
|
|
3841
4548
|
} : null;
|
|
3842
|
-
const sdkDependency = inspectSdkDependency(detection);
|
|
4549
|
+
const sdkDependency = inspectSdkDependency(detection, { sdkMode: plan.mode === 'sdk' || plan.mode === 'both' });
|
|
3843
4550
|
const envHints = options.apiKey ? [] : findLikelyEnvFiles(detection);
|
|
3844
4551
|
const mcpProcesses = inspectMcpProcesses({
|
|
3845
4552
|
commands: options.processCommands,
|
|
@@ -4049,6 +4756,7 @@ async function install(options) {
|
|
|
4049
4756
|
harnessReload,
|
|
4050
4757
|
firstCapture: firstCapturePath(detection),
|
|
4051
4758
|
hermes,
|
|
4759
|
+
mcp_runtime: mcpRuntimeReport(mcpRuntime),
|
|
4052
4760
|
changes,
|
|
4053
4761
|
doctor: {
|
|
4054
4762
|
active: Boolean(!selfTest.skipped && selfTest.active),
|
|
@@ -4099,11 +4807,8 @@ async function install(options) {
|
|
|
4099
4807
|
};
|
|
4100
4808
|
}
|
|
4101
4809
|
|
|
4102
|
-
function activationFailureFix(message) {
|
|
4103
|
-
|
|
4104
|
-
return `unset MARROW_AGENT_ID and MARROW_FLEET_AGENT_ID so Marrow uses the key's own agent, then rerun ${INSTALLER_UPDATE_COMMAND}`;
|
|
4105
|
-
}
|
|
4106
|
-
return INSTALLER_DOCTOR_COMMAND;
|
|
4810
|
+
function activationFailureFix(message, configuredAgentId = '') {
|
|
4811
|
+
return identityRefusalFix(message, configuredAgentId) || INSTALLER_DOCTOR_COMMAND;
|
|
4107
4812
|
}
|
|
4108
4813
|
|
|
4109
4814
|
function installCommandFor(options) {
|
|
@@ -4112,23 +4817,31 @@ function installCommandFor(options) {
|
|
|
4112
4817
|
|
|
4113
4818
|
// One summary for the one-command paths (first install and update). The full report goes to a
|
|
4114
4819
|
// private log file. Nothing here prints the API key.
|
|
4820
|
+
// One verdict for the self-test, used by the summary and by key storage: it passed only when it
|
|
4821
|
+
// ran, raised no error, found the account active and Marrow committed the closure.
|
|
4822
|
+
function selfTestVerdict(selfTest = {}) {
|
|
4823
|
+
const untrusted = !selfTest.skipped && !selfTest.error && Boolean(selfTest.active) && selfTest.decision_committed !== true;
|
|
4824
|
+
const failed = !selfTest.skipped && (Boolean(selfTest.error) || !selfTest.active || untrusted);
|
|
4825
|
+
return { untrusted, failed, passed: !selfTest.skipped && !failed };
|
|
4826
|
+
}
|
|
4827
|
+
|
|
4115
4828
|
function installSummaryLines(report, options) {
|
|
4116
4829
|
const selfTest = report.selfTest || {};
|
|
4117
4830
|
const activation = report.activation || {};
|
|
4118
4831
|
const lines = [];
|
|
4119
|
-
const untrusted
|
|
4120
|
-
const selfTestFailed = !selfTest.skipped && (Boolean(selfTest.error) || !selfTest.active || untrusted);
|
|
4832
|
+
const { untrusted, failed: selfTestFailed } = selfTestVerdict(selfTest);
|
|
4121
4833
|
if (selfTest.skipped) {
|
|
4122
4834
|
lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: configuration updated; self-test skipped (${selfTest.reason}).${selfTest.exact_fix ? ` Fix: ${selfTest.exact_fix}` : ''}`);
|
|
4123
4835
|
} else if (untrusted) {
|
|
4124
4836
|
lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: self-test decision ${selfTest.decision_id} was recorded without trusted closure (committed was not true). Fix: ${INSTALLER_DOCTOR_COMMAND}`);
|
|
4125
4837
|
} else if (selfTestFailed) {
|
|
4126
|
-
const fix =
|
|
4838
|
+
const fix = identityRefusalFix(selfTest.error, activation.agent_id_source === 'configured' ? activation.agent_id : '')
|
|
4839
|
+
|| report.doctor?.recommendedFix || INSTALLER_DOCTOR_COMMAND;
|
|
4127
4840
|
lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: self-test failed: ${selfTest.error || 'Marrow reported this account as inactive'}. Fix: ${fix}`);
|
|
4128
4841
|
} else {
|
|
4129
4842
|
const agent = activation.agent_id
|
|
4130
4843
|
? `; agent ${activation.agent_id} (${activation.agent_id_source === 'configured' ? 'configured' : 'resolved by Marrow'})`
|
|
4131
|
-
: '
|
|
4844
|
+
: '';
|
|
4132
4845
|
lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: healthy. Self-test decision ${selfTest.decision_id} committed${agent}.`);
|
|
4133
4846
|
}
|
|
4134
4847
|
if (activation.exact_fix) lines.push(`Activation: ${activation.exact_fix}`);
|
|
@@ -4155,6 +4868,14 @@ function installSummaryLines(report, options) {
|
|
|
4155
4868
|
} else if (hermes.state === 'refused' || hermes.state === 'config_not_found' || hermes.state === 'preserved_unverified_ahead') {
|
|
4156
4869
|
lines.push(`Hermes: ${hermes.config_path || 'config.yaml'} was not changed. The exact block to add is in the full report.`);
|
|
4157
4870
|
}
|
|
4871
|
+
const ownerKey = report.owner_key_storage || {};
|
|
4872
|
+
if (ownerKey.state === 'written') {
|
|
4873
|
+
lines.push(`Saved your API key in ${ownerKey.path} (only you can read it), so Claude Code finds it when opened from the desktop app, an IDE or a new terminal.`);
|
|
4874
|
+
} else if (ownerKey.state === 'different_key_present') {
|
|
4875
|
+
lines.push(`Note: ${ownerKey.path} holds a different Marrow key, and Claude Code uses that one when opened outside this terminal. To use this key there, remove that key from the file and run npx @getmarrow/install again.`);
|
|
4876
|
+
} else if (ownerKey.state && !['present', 'no_key'].includes(ownerKey.state)) {
|
|
4877
|
+
lines.push(`Note: your API key was not saved for Claude Code opened outside this terminal (${ownerKey.path || '~/.marrow/env'} is not a private owner-only file).`);
|
|
4878
|
+
}
|
|
4158
4879
|
if (hermes.owner_key_storage?.state === 'written') {
|
|
4159
4880
|
lines.push('Stored your API key in ~/.marrow/env (mode 600) so the Hermes MCP server can read it; Hermes passes no other environment to MCP servers.');
|
|
4160
4881
|
} else if (hermes.owner_key_storage?.state === 'different_key_present') {
|
|
@@ -4190,6 +4911,12 @@ async function runCli(argv) {
|
|
|
4190
4911
|
process.stdout.write(usage());
|
|
4191
4912
|
return;
|
|
4192
4913
|
}
|
|
4914
|
+
if (options.uninstall) {
|
|
4915
|
+
const report = await uninstall(options);
|
|
4916
|
+
if (options.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
4917
|
+
else printUninstallReport(report);
|
|
4918
|
+
return;
|
|
4919
|
+
}
|
|
4193
4920
|
const stored = readOwnerApiKey(options.home);
|
|
4194
4921
|
const keyInfo = { source: options.apiKey ? 'environment' : null, path: null, owner_files_disagree: stored.conflict };
|
|
4195
4922
|
if (!options.apiKey && stored.apiKey) {
|
|
@@ -4207,7 +4934,7 @@ async function runCli(argv) {
|
|
|
4207
4934
|
USERPROFILE: options.home || options.cwd,
|
|
4208
4935
|
}));
|
|
4209
4936
|
}
|
|
4210
|
-
process.stdout.write(`Marrow needs your API key. Run: MARROW_API_KEY
|
|
4937
|
+
process.stdout.write(`Marrow needs your API key. Run: MARROW_API_KEY=your_key ${installCommandFor(options)} (replace your_key with your key; create one at https://getmarrow.ai)\n`);
|
|
4211
4938
|
process.exitCode = 2;
|
|
4212
4939
|
return;
|
|
4213
4940
|
}
|
|
@@ -4217,10 +4944,23 @@ async function runCli(argv) {
|
|
|
4217
4944
|
} catch (error) {
|
|
4218
4945
|
const message = error instanceof Error ? error.message : String(error);
|
|
4219
4946
|
if (!oneCommand || options.json || !/^Marrow activation failed/.test(message)) throw error;
|
|
4220
|
-
process.stderr.write(`Marrow ${INSTALLER_ADAPTER_VERSION}: ${message}. Fix: ${activationFailureFix(message)}\n`);
|
|
4947
|
+
process.stderr.write(`Marrow ${INSTALLER_ADAPTER_VERSION}: ${message}. Fix: ${activationFailureFix(message, String(options.agentId || '').trim())}\n`);
|
|
4221
4948
|
process.exitCode = 1;
|
|
4222
4949
|
return;
|
|
4223
4950
|
}
|
|
4951
|
+
// Claude Code opened from the desktop app, an IDE or a new terminal has no MARROW_API_KEY.
|
|
4952
|
+
// The MCP server and hooks read ~/.marrow/env, so a first install stores a key that came
|
|
4953
|
+
// from the environment there (owner-only), once the self-test passed by the same verdict the
|
|
4954
|
+
// summary prints (never on an untrusted closure), with --json and --verbose too.
|
|
4955
|
+
const keyWorked = report.selfTest ? selfTestVerdict(report.selfTest).passed : false;
|
|
4956
|
+
if (options.activate && !options.update && keyInfo.source === 'environment' && !options.keyFromArg && keyWorked) {
|
|
4957
|
+
try {
|
|
4958
|
+
const stored = ensureOwnerApiKey(options.home, options.apiKey);
|
|
4959
|
+
report.owner_key_storage = { state: stored.state, path: stored.path };
|
|
4960
|
+
} catch {
|
|
4961
|
+
report.owner_key_storage = { state: 'not_written', path: null };
|
|
4962
|
+
}
|
|
4963
|
+
}
|
|
4224
4964
|
if (options.json) {
|
|
4225
4965
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
4226
4966
|
return;
|
|
@@ -4246,6 +4986,8 @@ async function runCli(argv) {
|
|
|
4246
4986
|
}
|
|
4247
4987
|
|
|
4248
4988
|
module.exports = {
|
|
4989
|
+
argumentLabel,
|
|
4990
|
+
maintenanceMcpRuntime,
|
|
4249
4991
|
parseArgs,
|
|
4250
4992
|
detectEnvironment,
|
|
4251
4993
|
buildPlan,
|
|
@@ -4267,6 +5009,13 @@ module.exports = {
|
|
|
4267
5009
|
activationProfile,
|
|
4268
5010
|
claudeNativeHookFingerprint,
|
|
4269
5011
|
codexNativeHookFingerprint,
|
|
5012
|
+
hostApprovalHooksSupported,
|
|
5013
|
+
uninstall,
|
|
5014
|
+
printUninstallReport,
|
|
5015
|
+
CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER,
|
|
5016
|
+
GEMINI_PRE_ACTION_GUARD_COMMAND,
|
|
5017
|
+
GEMINI_CONTEXT_HOOK_COMMAND,
|
|
5018
|
+
MCP_PERMISSION_REQUEST_HOOK_COMMAND,
|
|
4270
5019
|
cursorNativeHookFingerprint,
|
|
4271
5020
|
clineNativeHookFingerprint,
|
|
4272
5021
|
windsurfNativeHookFingerprint,
|