@getmarrow/install 0.1.67 → 0.1.69
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 +238 -329
- package/package.json +1 -1
- package/src/controller-manager.js +3 -1
- package/src/first-hour.js +1 -1
- package/src/governed-runner.js +2015 -136
- package/src/installer.js +867 -85
- package/src/mcp-runtime.js +447 -0
- package/src/owner-env.js +2 -1
- 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,21 @@ 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
|
+
|
|
842
|
+
// Operation words parseArgs accepts. A --key value equal to one of these is a missing key.
|
|
843
|
+
const SUBCOMMAND_WORDS = new Set(['activate', 'update', 'repair', 'uninstall', 'doctor', 'check']);
|
|
844
|
+
|
|
726
845
|
function parseArgs(argv, env = process.env) {
|
|
727
846
|
const options = {
|
|
728
847
|
cwd: process.cwd(),
|
|
@@ -732,7 +851,8 @@ function parseArgs(argv, env = process.env) {
|
|
|
732
851
|
doctor: false,
|
|
733
852
|
repair: false,
|
|
734
853
|
mode: 'auto',
|
|
735
|
-
|
|
854
|
+
// MARROW_KEY is accepted when MARROW_API_KEY is absent or blank, as the runner and owner files do.
|
|
855
|
+
apiKey: String(env.MARROW_API_KEY || '').trim() || String(env.MARROW_KEY || '').trim(),
|
|
736
856
|
baseUrl: env.MARROW_BASE_URL || DEFAULT_BASE_URL,
|
|
737
857
|
agentId: env.MARROW_FLEET_AGENT_ID || env.MARROW_AGENT_ID || '',
|
|
738
858
|
toolProfile: resolveToolProfile(env.MARROW_TOOL_PROFILE),
|
|
@@ -742,10 +862,24 @@ function parseArgs(argv, env = process.env) {
|
|
|
742
862
|
json: false,
|
|
743
863
|
activate: false,
|
|
744
864
|
controller: true,
|
|
865
|
+
// Hooks start a verified local copy of the pinned MCP (installed on write runs) instead of
|
|
866
|
+
// npx. MARROW_LOCAL_RUNTIME=0 or --no-local-runtime keeps them on npx.
|
|
867
|
+
mcpLocalRuntime: env.MARROW_LOCAL_RUNTIME !== '0',
|
|
745
868
|
};
|
|
746
869
|
let explicitOperation = false;
|
|
747
870
|
|
|
748
|
-
|
|
871
|
+
// Value of the option at argv[i]. Only consumes the next argument when it exists and is not an
|
|
872
|
+
// option itself, so a missing value never swallows a following flag.
|
|
873
|
+
let i = 0;
|
|
874
|
+
const optionValue = (refuseWords = false) => {
|
|
875
|
+
const next = argv[i + 1];
|
|
876
|
+
if (next === undefined || String(next).startsWith('-')) return '';
|
|
877
|
+
// `--key $UNSET activate` must not take the subcommand word as the key.
|
|
878
|
+
if (refuseWords && SUBCOMMAND_WORDS.has(String(next))) return '';
|
|
879
|
+
i += 1;
|
|
880
|
+
return next;
|
|
881
|
+
};
|
|
882
|
+
for (; i < argv.length; i += 1) {
|
|
749
883
|
const arg = argv[i];
|
|
750
884
|
if (arg === 'activate' || arg === '--activate') {
|
|
751
885
|
explicitOperation = true;
|
|
@@ -767,6 +901,10 @@ function parseArgs(argv, env = process.env) {
|
|
|
767
901
|
explicitOperation = true;
|
|
768
902
|
options.dryRun = true;
|
|
769
903
|
}
|
|
904
|
+
else if (arg === 'uninstall' || arg === '--uninstall') {
|
|
905
|
+
explicitOperation = true;
|
|
906
|
+
options.uninstall = true;
|
|
907
|
+
}
|
|
770
908
|
else if (arg === '--doctor' || arg === 'doctor' || arg === 'check') {
|
|
771
909
|
explicitOperation = true;
|
|
772
910
|
options.doctor = true;
|
|
@@ -779,18 +917,20 @@ function parseArgs(argv, env = process.env) {
|
|
|
779
917
|
options.selfTestExplicitlyDisabled = true;
|
|
780
918
|
}
|
|
781
919
|
else if (arg === '--no-controller') options.controller = false;
|
|
920
|
+
else if (arg === '--no-local-runtime') options.mcpLocalRuntime = false;
|
|
782
921
|
else if (arg === '--self-test') options.selfTest = true;
|
|
783
|
-
else if (arg === '--cwd') options.cwd = path.resolve(
|
|
922
|
+
else if (arg === '--cwd') options.cwd = path.resolve(optionValue() || options.cwd);
|
|
784
923
|
else if (arg === '--mode') {
|
|
785
924
|
explicitOperation = true;
|
|
786
|
-
options.mode =
|
|
925
|
+
options.mode = optionValue() || options.mode;
|
|
787
926
|
}
|
|
788
927
|
else if (arg === '--key') {
|
|
789
|
-
|
|
928
|
+
// A missing value (end of argv, or another option next) is a missing key, never a key.
|
|
929
|
+
options.apiKey = optionValue(true) || options.apiKey;
|
|
790
930
|
options.keyFromArg = true;
|
|
791
931
|
}
|
|
792
|
-
else if (arg === '--base-url') options.baseUrl =
|
|
793
|
-
else if (arg === '--agent-id') options.agentId =
|
|
932
|
+
else if (arg === '--base-url') options.baseUrl = optionValue() || options.baseUrl;
|
|
933
|
+
else if (arg === '--agent-id') options.agentId = optionValue() || options.agentId;
|
|
794
934
|
else if (arg === '--mcp') {
|
|
795
935
|
explicitOperation = true;
|
|
796
936
|
options.mode = 'mcp';
|
|
@@ -810,10 +950,13 @@ function parseArgs(argv, env = process.env) {
|
|
|
810
950
|
else if (arg === '--help' || arg === '-h') {
|
|
811
951
|
options.help = true;
|
|
812
952
|
} else {
|
|
813
|
-
throw new Error(`Unknown argument: ${arg}`);
|
|
953
|
+
throw new Error(`Unknown argument: ${argumentLabel(arg)}`);
|
|
814
954
|
}
|
|
815
955
|
}
|
|
816
956
|
|
|
957
|
+
if (options.uninstall && (options.activate || options.repair || options.doctor)) {
|
|
958
|
+
throw new Error('uninstall cannot be combined with activate, update, repair, or doctor');
|
|
959
|
+
}
|
|
817
960
|
if (!['auto', 'mcp', 'sdk', 'both', 'md'].includes(options.mode)) {
|
|
818
961
|
throw new Error('--mode must be one of auto, mcp, sdk, both, md');
|
|
819
962
|
}
|
|
@@ -843,26 +986,34 @@ function usage() {
|
|
|
843
986
|
npx @getmarrow/install --repair
|
|
844
987
|
npx @getmarrow/install update
|
|
845
988
|
npx @getmarrow/install doctor
|
|
989
|
+
npx @getmarrow/install doctor --self-test
|
|
846
990
|
npx @getmarrow/install --mcp --yes
|
|
847
991
|
npx @getmarrow/install --sdk --yes
|
|
992
|
+
npx @getmarrow/install uninstall [--yes]
|
|
848
993
|
|
|
849
994
|
Options:
|
|
850
995
|
(no command) Detect, install, self-test, and start the supported persistent controller
|
|
851
996
|
activate Detect, install, self-test, and return a server-confirmed activation receipt
|
|
852
997
|
--dry-run Print planned changes without writing
|
|
853
998
|
--doctor Check install health without writing
|
|
999
|
+
doctor --self-test Same as doctor, which already runs the server self-test: run it after restarting the harness
|
|
854
1000
|
--repair Write missing hooks/config, then run self-test and status check
|
|
855
1001
|
update Same as --repair: refresh exact MCP/SDK/install package pins after owner approval
|
|
856
|
-
--yes, -y Write detected config files
|
|
1002
|
+
--yes, -y Write detected config files (needs your API key; stops before writing without one)
|
|
857
1003
|
--mode <mode> auto, mcp, sdk, both, or md
|
|
858
|
-
--key <key> Marrow API key for self-test. Prefer MARROW_API_KEY because CLI args can appear in process listings.
|
|
859
1004
|
--base-url <url> Marrow API base URL
|
|
860
1005
|
--agent-id <id> Agent/fleet id for self-test headers
|
|
861
1006
|
--no-controller Do not start the local background controller during install/repair
|
|
1007
|
+
--no-local-runtime Keep hooks on npx instead of the verified local MCP copy in ~/.marrow/runtime
|
|
862
1008
|
--no-self-test Skip API smoke/self-test
|
|
863
1009
|
--verbose Print the full report instead of the one-line summary and log file
|
|
1010
|
+
uninstall Preview removing only Marrow's own hooks, MCP server entries, instructions and
|
|
1011
|
+
the local MCP runtime; add --yes to remove them. Your own hooks and settings
|
|
1012
|
+
are kept.
|
|
864
1013
|
|
|
865
|
-
|
|
1014
|
+
Get a key at https://getmarrow.ai/signup. Put it in the owner-only ~/.marrow/env as
|
|
1015
|
+
MARROW_API_KEY=your_key, then run chmod 700 ~/.marrow && chmod 600 ~/.marrow/env, or export MARROW_API_KEY. Keep it off the command line: command-line
|
|
1016
|
+
arguments land in shell history and process listings.
|
|
866
1017
|
|
|
867
1018
|
Environment:
|
|
868
1019
|
MARROW_TOOL_PROFILE Leave unset for primary (17 tools), or explicitly set primary, core, or full.
|
|
@@ -878,13 +1029,14 @@ function stableAgentId(root, client = sourceClient()) {
|
|
|
878
1029
|
function detectedClient(detection) {
|
|
879
1030
|
if (sourceClient() !== 'custom') return sourceClient();
|
|
880
1031
|
if (detection.openclaw) return 'openclaw';
|
|
881
|
-
if (detection.claudeCode) return 'claude-code';
|
|
1032
|
+
if (detection.claudeCodeProject ?? detection.claudeCode) return 'claude-code';
|
|
882
1033
|
if (detection.cursor) return 'cursor';
|
|
883
1034
|
if (detection.cline) return 'cline';
|
|
884
1035
|
if (detection.windsurf) return 'windsurf';
|
|
885
1036
|
if (detection.gemini) return 'gemini';
|
|
886
1037
|
if (detection.codexProject) return 'codex';
|
|
887
1038
|
// Home-level harnesses rank below project markers; a shared AGENTS.md is a weaker signal.
|
|
1039
|
+
if (detection.claudeCode) return 'claude-code';
|
|
888
1040
|
if (detection.hermes) return 'hermes';
|
|
889
1041
|
if (detection.codex) return 'codex';
|
|
890
1042
|
return 'custom';
|
|
@@ -1037,6 +1189,14 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
|
|
|
1037
1189
|
};
|
|
1038
1190
|
const homeHarnesses = detectHomeHarnesses(home, env);
|
|
1039
1191
|
const codexProject = codexDirectoryIsOwnerConfigured(path.join(root, '.codex'));
|
|
1192
|
+
// Claude Code is detected from the project (CLAUDE.md, .claude/settings.json) or, for a repo
|
|
1193
|
+
// that has neither yet, from the user's installation: ~/.claude/, ~/.claude.json or `claude`
|
|
1194
|
+
// on PATH. Either way the project gets .claude/settings.json.
|
|
1195
|
+
const claudeCodeProject = exists(paths.claudeSettings) || exists(paths.claudeMd);
|
|
1196
|
+
const claudeCodeSource = claudeCodeProject ? 'project'
|
|
1197
|
+
: exists(path.join(home, '.claude')) || exists(path.join(home, '.claude.json')) ? 'home'
|
|
1198
|
+
: executableOnPath('claude', env) ? 'path'
|
|
1199
|
+
: null;
|
|
1040
1200
|
|
|
1041
1201
|
return {
|
|
1042
1202
|
root,
|
|
@@ -1044,7 +1204,9 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
|
|
|
1044
1204
|
paths,
|
|
1045
1205
|
node: exists(paths.packageJson),
|
|
1046
1206
|
python: exists(paths.pyproject) || exists(paths.requirements) || exists(paths.setupPy),
|
|
1047
|
-
claudeCode:
|
|
1207
|
+
claudeCode: Boolean(claudeCodeSource),
|
|
1208
|
+
claudeCodeProject,
|
|
1209
|
+
claudeCodeSource,
|
|
1048
1210
|
cursor: exists(path.join(root, '.cursor')),
|
|
1049
1211
|
cline: exists(path.join(root, '.clinerules')),
|
|
1050
1212
|
windsurf: exists(path.join(root, '.windsurf')),
|
|
@@ -1123,7 +1285,12 @@ function findLikelyEnvFiles(detection, env = process.env) {
|
|
|
1123
1285
|
});
|
|
1124
1286
|
}
|
|
1125
1287
|
|
|
1126
|
-
function passiveInstructions() {
|
|
1288
|
+
function passiveInstructions(options = {}) {
|
|
1289
|
+
const hostApprovals = options.hostApprovals === true;
|
|
1290
|
+
const geminiHooks = hostApprovals ? 'BeforeTool, AfterTool, AfterAgent, and BeforeAgent' : 'BeforeTool, AfterTool, and AfterAgent';
|
|
1291
|
+
const approvals = hostApprovals
|
|
1292
|
+
? '\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.'
|
|
1293
|
+
: '';
|
|
1127
1294
|
return `${MARROW_BLOCK_START}
|
|
1128
1295
|
## Marrow Passive Agent Memory
|
|
1129
1296
|
|
|
@@ -1137,10 +1304,10 @@ Marrow should run passively after install:
|
|
|
1137
1304
|
- Before risky work, use Marrow's decision brief or passive prompt hook.
|
|
1138
1305
|
- 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
1306
|
- 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
|
|
1307
|
+
- 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
1308
|
- 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
1309
|
- 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
|
|
1310
|
+
- 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
1311
|
- Check health with \`marrow_agent_status\` or \`GET /v1/agent/status\`.
|
|
1145
1312
|
- 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
1313
|
|
|
@@ -1208,6 +1375,9 @@ MARROW_PASSIVE_TOKEN_USAGE=true
|
|
|
1208
1375
|
`;
|
|
1209
1376
|
}
|
|
1210
1377
|
|
|
1378
|
+
// Hook files are read with Marrow's hook commands in their canonical (npx) form, whether they
|
|
1379
|
+
// start through npx or the local runtime, so every check and merge sees one spelling; writes
|
|
1380
|
+
// switch them to the local runtime again when it is verified.
|
|
1211
1381
|
function parseJsonObject(filePath) {
|
|
1212
1382
|
const raw = safeRead(filePath).trim();
|
|
1213
1383
|
if (!raw) return {};
|
|
@@ -1215,7 +1385,7 @@ function parseJsonObject(filePath) {
|
|
|
1215
1385
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1216
1386
|
throw new Error(`Expected JSON object in ${filePath}`);
|
|
1217
1387
|
}
|
|
1218
|
-
return parsed;
|
|
1388
|
+
return mapHookCommands(parsed, delocalizeHookCommand);
|
|
1219
1389
|
}
|
|
1220
1390
|
|
|
1221
1391
|
function canonicalJsonValue(value) {
|
|
@@ -1293,7 +1463,7 @@ function exactHookDescriptors(settings, eventName, command, matcher) {
|
|
|
1293
1463
|
function marrowHookSubcommand(command) {
|
|
1294
1464
|
if (typeof command !== 'string') return null;
|
|
1295
1465
|
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)$/,
|
|
1466
|
+
/^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
1467
|
);
|
|
1298
1468
|
return match?.[1] || null;
|
|
1299
1469
|
}
|
|
@@ -1367,11 +1537,33 @@ function safeJsonObject(filePath) {
|
|
|
1367
1537
|
}
|
|
1368
1538
|
}
|
|
1369
1539
|
|
|
1370
|
-
|
|
1540
|
+
// The host-approval hooks Claude Code needs besides the core five (see upsertClaudeHooks).
|
|
1541
|
+
function claudeHostApprovalHooksConfigured(settings, targetCommand = (command) => command) {
|
|
1542
|
+
return {
|
|
1543
|
+
permission_request: exactHookConfigured(settings, 'PermissionRequest', targetCommand(MCP_PERMISSION_REQUEST_HOOK_COMMAND), NATIVE_HOOK_MATCHER),
|
|
1544
|
+
post_tool_batch: exactHookConfigured(settings, 'PostToolBatch', targetCommand(MCP_ACTION_RESULT_HOOK_COMMAND)),
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function claudeNativeHookFingerprint(settings, options = {}) {
|
|
1549
|
+
const hostApprovals = options.hostApprovals ?? hostApprovalHooksSupported();
|
|
1371
1550
|
const contract = {
|
|
1372
1551
|
schema: 'marrow-claude-native-hooks.v3',
|
|
1373
1552
|
adapter_version: MCP_ADAPTER_VERSION,
|
|
1374
1553
|
expected_hooks: NATIVE_EXPECTED_HOOKS,
|
|
1554
|
+
...(hostApprovals ? {
|
|
1555
|
+
host_approvals: {
|
|
1556
|
+
configured: claudeHostApprovalHooksConfigured(settings),
|
|
1557
|
+
descriptors: {
|
|
1558
|
+
permission_request: exactHookDescriptors(settings, 'PermissionRequest', MCP_PERMISSION_REQUEST_HOOK_COMMAND, NATIVE_HOOK_MATCHER),
|
|
1559
|
+
post_tool_batch: exactHookDescriptors(settings, 'PostToolBatch', MCP_ACTION_RESULT_HOOK_COMMAND),
|
|
1560
|
+
},
|
|
1561
|
+
active_marrow_handlers: {
|
|
1562
|
+
permission_request: marrowHookDescriptors(settings, 'PermissionRequest'),
|
|
1563
|
+
post_tool_batch: marrowHookDescriptors(settings, 'PostToolBatch'),
|
|
1564
|
+
},
|
|
1565
|
+
},
|
|
1566
|
+
} : {}),
|
|
1375
1567
|
configured: {
|
|
1376
1568
|
prompt: exactHookConfigured(settings, 'UserPromptSubmit', MCP_CONTEXT_HOOK_COMMAND),
|
|
1377
1569
|
pre_action: exactHookConfigured(settings, 'PreToolUse', MCP_PRE_ACTION_HOOK_COMMAND, NATIVE_HOOK_MATCHER),
|
|
@@ -1397,7 +1589,7 @@ function claudeNativeHookFingerprint(settings) {
|
|
|
1397
1589
|
return crypto.createHash('sha256').update(JSON.stringify(contract)).digest('hex');
|
|
1398
1590
|
}
|
|
1399
1591
|
|
|
1400
|
-
function upsertClaudeHooks(settingsPath) {
|
|
1592
|
+
function upsertClaudeHooks(settingsPath, options = {}) {
|
|
1401
1593
|
const settings = parseJsonObject(settingsPath);
|
|
1402
1594
|
const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
|
|
1403
1595
|
? settings.hooks
|
|
@@ -1426,6 +1618,18 @@ function upsertClaudeHooks(settingsPath) {
|
|
|
1426
1618
|
UserPromptSubmit: userPromptSubmit,
|
|
1427
1619
|
Stop: stop,
|
|
1428
1620
|
};
|
|
1621
|
+
if (options.hostApprovals) {
|
|
1622
|
+
// Both async, exactly as `marrow-mcp setup` writes them: the marker never delays or answers
|
|
1623
|
+
// the dialog, and PostToolBatch settles a declined dialog in the background.
|
|
1624
|
+
settings.hooks.PermissionRequest = reconcileMarrowCommandHook(
|
|
1625
|
+
settings, 'PermissionRequest', 'permission-request-hook', MCP_PERMISSION_REQUEST_HOOK_COMMAND, NATIVE_HOOK_MATCHER,
|
|
1626
|
+
{ async: true },
|
|
1627
|
+
);
|
|
1628
|
+
settings.hooks.PostToolBatch = reconcileMarrowCommandHook(
|
|
1629
|
+
settings, 'PostToolBatch', 'hook', MCP_ACTION_RESULT_HOOK_COMMAND, undefined,
|
|
1630
|
+
{ async: true },
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1429
1633
|
|
|
1430
1634
|
return JSON.stringify(settings, null, 2) + '\n';
|
|
1431
1635
|
}
|
|
@@ -1484,11 +1688,20 @@ function upsertCodexHooks(hooksPath) {
|
|
|
1484
1688
|
return JSON.stringify(settings, null, 2) + '\n';
|
|
1485
1689
|
}
|
|
1486
1690
|
|
|
1691
|
+
// A Marrow Cursor entry: the npx entrypoint, or the node guard that names it as JSON items.
|
|
1692
|
+
function cursorMarrowHookSubcommand(command) {
|
|
1693
|
+
const direct = marrowHookSubcommand(command);
|
|
1694
|
+
if (direct) return direct;
|
|
1695
|
+
if (typeof command !== 'string') return null;
|
|
1696
|
+
const guarded = command.match(/@getmarrow\/mcp@[^\s"',]+["'],["']marrow-mcp["'],["']cursor-(pre-action-hook|context-hook|hook|session-hook)["']/);
|
|
1697
|
+
return guarded?.[1] || null;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1487
1700
|
function reconcileCursorHook(settings, eventName, subcommand, canonical) {
|
|
1488
1701
|
const original = Array.isArray(settings?.hooks?.[eventName]) ? settings.hooks[eventName] : [];
|
|
1489
1702
|
const retained = original.filter((entry) => !(
|
|
1490
1703
|
entry && typeof entry === 'object' && !Array.isArray(entry)
|
|
1491
|
-
&&
|
|
1704
|
+
&& cursorMarrowHookSubcommand(entry.command)
|
|
1492
1705
|
));
|
|
1493
1706
|
return [...retained, canonical];
|
|
1494
1707
|
}
|
|
@@ -1506,7 +1719,7 @@ function cursorHookDescriptors(settings, eventName) {
|
|
|
1506
1719
|
const entries = settings?.hooks?.[eventName];
|
|
1507
1720
|
if (!Array.isArray(entries)) return [];
|
|
1508
1721
|
return entries.flatMap((entry) => {
|
|
1509
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !
|
|
1722
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !cursorMarrowHookSubcommand(entry.command)) return [];
|
|
1510
1723
|
return [{
|
|
1511
1724
|
matcher: typeof entry.matcher === 'string' ? entry.matcher : null,
|
|
1512
1725
|
command: String(entry.command).trim(),
|
|
@@ -1517,14 +1730,43 @@ function cursorHookDescriptors(settings, eventName) {
|
|
|
1517
1730
|
});
|
|
1518
1731
|
}
|
|
1519
1732
|
|
|
1520
|
-
|
|
1733
|
+
// The Cursor hooks that host approvals add (see upsertCursorHooks), as configured checks.
|
|
1734
|
+
function cursorHostApprovalHooksConfigured(settings, targetCommand = (command) => command) {
|
|
1735
|
+
const pre = targetCommand(CURSOR_PRE_ACTION_HOOK_COMMAND);
|
|
1736
|
+
const post = targetCommand(CURSOR_ACTION_RESULT_HOOK_COMMAND);
|
|
1737
|
+
const gate = { timeout: CURSOR_GATE_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false };
|
|
1738
|
+
return {
|
|
1739
|
+
pre_tool_use: exactCursorHookConfigured(settings, 'preToolUse', pre, CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER, gate),
|
|
1740
|
+
beforeShellExecution: exactCursorHookConfigured(settings, 'beforeShellExecution', pre, undefined, gate),
|
|
1741
|
+
beforeMCPExecution: exactCursorHookConfigured(settings, 'beforeMCPExecution', targetCommand(CURSOR_MCP_PRE_ACTION_GUARD_COMMAND), undefined, gate),
|
|
1742
|
+
...Object.fromEntries(CURSOR_EXECUTION_POST_EVENTS.map((event) => [event, exactCursorHookConfigured(settings, event, post, undefined, {
|
|
1743
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1744
|
+
})])),
|
|
1745
|
+
sessionStart: exactCursorHookConfigured(settings, 'sessionStart', targetCommand(CURSOR_SESSION_END_HOOK_COMMAND), undefined, {
|
|
1746
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1747
|
+
}),
|
|
1748
|
+
beforeSubmitPrompt: exactCursorHookConfigured(settings, 'beforeSubmitPrompt', targetCommand(CURSOR_CONTEXT_HOOK_COMMAND), undefined, {
|
|
1749
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1750
|
+
}),
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
function cursorNativeHookFingerprint(settings, options = {}) {
|
|
1755
|
+
const hostApprovals = options.hostApprovals ?? hostApprovalHooksSupported();
|
|
1521
1756
|
const contract = {
|
|
1522
1757
|
schema: 'marrow-cursor-native-hooks.v1',
|
|
1523
1758
|
adapter_version: MCP_ADAPTER_VERSION,
|
|
1524
1759
|
expected_hooks: ['pre_action', 'action_result', 'outcome_closure'],
|
|
1760
|
+
...(hostApprovals ? {
|
|
1761
|
+
host_approvals: {
|
|
1762
|
+
configured: cursorHostApprovalHooksConfigured(settings),
|
|
1763
|
+
descriptors: Object.fromEntries([...CURSOR_EXECUTION_PRE_EVENTS, ...CURSOR_EXECUTION_POST_EVENTS, 'sessionStart', 'beforeSubmitPrompt']
|
|
1764
|
+
.map((event) => [event, cursorHookDescriptors(settings, event)])),
|
|
1765
|
+
},
|
|
1766
|
+
} : {}),
|
|
1525
1767
|
configured: {
|
|
1526
|
-
pre_action: exactCursorHookConfigured(settings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, CURSOR_NATIVE_HOOK_MATCHER, {
|
|
1527
|
-
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1768
|
+
pre_action: exactCursorHookConfigured(settings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, hostApprovals ? CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER : CURSOR_NATIVE_HOOK_MATCHER, {
|
|
1769
|
+
timeout: hostApprovals ? CURSOR_GATE_HOOK_TIMEOUT_SECONDS : CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1528
1770
|
failClosed: true,
|
|
1529
1771
|
async: false,
|
|
1530
1772
|
}),
|
|
@@ -1548,18 +1790,19 @@ function cursorNativeHookFingerprint(settings) {
|
|
|
1548
1790
|
return crypto.createHash('sha256').update(JSON.stringify(contract)).digest('hex');
|
|
1549
1791
|
}
|
|
1550
1792
|
|
|
1551
|
-
function upsertCursorHooks(hooksPath) {
|
|
1793
|
+
function upsertCursorHooks(hooksPath, options = {}) {
|
|
1552
1794
|
const settings = parseJsonObject(hooksPath);
|
|
1553
1795
|
const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
|
|
1554
1796
|
? settings.hooks
|
|
1555
1797
|
: {};
|
|
1556
1798
|
settings.version = 1;
|
|
1799
|
+
const hostApprovals = options.hostApprovals === true;
|
|
1557
1800
|
settings.hooks = {
|
|
1558
1801
|
...hooks,
|
|
1559
1802
|
preToolUse: reconcileCursorHook(settings, 'preToolUse', 'pre-action-hook', {
|
|
1560
1803
|
command: CURSOR_PRE_ACTION_HOOK_COMMAND,
|
|
1561
|
-
matcher: CURSOR_NATIVE_HOOK_MATCHER,
|
|
1562
|
-
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1804
|
+
matcher: hostApprovals ? CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER : CURSOR_NATIVE_HOOK_MATCHER,
|
|
1805
|
+
timeout: hostApprovals ? CURSOR_GATE_HOOK_TIMEOUT_SECONDS : CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1563
1806
|
failClosed: true,
|
|
1564
1807
|
async: false,
|
|
1565
1808
|
}),
|
|
@@ -1578,6 +1821,36 @@ function upsertCursorHooks(hooksPath) {
|
|
|
1578
1821
|
timeout: CODEX_SESSION_TIMEOUT_SECONDS,
|
|
1579
1822
|
}),
|
|
1580
1823
|
};
|
|
1824
|
+
if (hostApprovals) {
|
|
1825
|
+
// Cursor enforces "ask" only here. failClosed: a crash or timeout blocks the call instead
|
|
1826
|
+
// of letting it through (Cursor fails open by default).
|
|
1827
|
+
for (const eventName of CURSOR_EXECUTION_PRE_EVENTS) {
|
|
1828
|
+
settings.hooks[eventName] = reconcileCursorHook(settings, eventName, 'pre-action-hook', {
|
|
1829
|
+
command: eventName === 'beforeMCPExecution' ? CURSOR_MCP_PRE_ACTION_GUARD_COMMAND : CURSOR_PRE_ACTION_HOOK_COMMAND,
|
|
1830
|
+
timeout: CURSOR_GATE_HOOK_TIMEOUT_SECONDS,
|
|
1831
|
+
failClosed: true,
|
|
1832
|
+
async: false,
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
for (const eventName of CURSOR_EXECUTION_POST_EVENTS) {
|
|
1836
|
+
settings.hooks[eventName] = reconcileCursorHook(settings, eventName, 'hook', {
|
|
1837
|
+
command: CURSOR_ACTION_RESULT_HOOK_COMMAND,
|
|
1838
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
// sessionStart tells the hooks whether the session is local and interactive (not a
|
|
1842
|
+
// background agent); without it Cursor holds are denied instead of asked.
|
|
1843
|
+
settings.hooks.sessionStart = reconcileCursorHook(settings, 'sessionStart', 'session-hook', {
|
|
1844
|
+
command: CURSOR_SESSION_END_HOOK_COMMAND,
|
|
1845
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1846
|
+
});
|
|
1847
|
+
// The typed reply ("marrow approve CODE") in a local interactive session. Never failClosed:
|
|
1848
|
+
// a failure must not block the operator's own prompts.
|
|
1849
|
+
settings.hooks.beforeSubmitPrompt = reconcileCursorHook(settings, 'beforeSubmitPrompt', 'context-hook', {
|
|
1850
|
+
command: CURSOR_CONTEXT_HOOK_COMMAND,
|
|
1851
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1581
1854
|
return JSON.stringify(settings, null, 2) + '\n';
|
|
1582
1855
|
}
|
|
1583
1856
|
|
|
@@ -1647,7 +1920,9 @@ function upsertWindsurfHooks(hooksPath) {
|
|
|
1647
1920
|
|
|
1648
1921
|
function geminiMarrowHookEntrypoint(command) {
|
|
1649
1922
|
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|['"]|$)/)
|
|
1923
|
+
const match = command.match(/@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp\s+gemini-(pre-action-hook|context-hook|hook|session-hook)(?:\s|['"]|$)/)
|
|
1924
|
+
// The node guards name the entrypoint as JSON array items.
|
|
1925
|
+
|| command.match(/@getmarrow\/mcp@[^\s"',]+["'],["']marrow-mcp["'],["']gemini-(pre-action-hook|context-hook|hook|session-hook)["']/);
|
|
1651
1926
|
return match?.[1] || null;
|
|
1652
1927
|
}
|
|
1653
1928
|
|
|
@@ -1689,7 +1964,14 @@ function geminiHooksExplicitlyDisabled(settings) {
|
|
|
1689
1964
|
return settings?.hooksConfig?.enabled === false;
|
|
1690
1965
|
}
|
|
1691
1966
|
|
|
1692
|
-
|
|
1967
|
+
// The Gemini BeforeTool command: the JSON-checking guard with host approvals, otherwise the
|
|
1968
|
+
// earlier fixed-string wrapper.
|
|
1969
|
+
function geminiPreActionCommand(hostApprovals) {
|
|
1970
|
+
return hostApprovals ? GEMINI_PRE_ACTION_GUARD_COMMAND : GEMINI_PRE_ACTION_HOOK_COMMAND;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function geminiNativeHookFingerprint(settings, options = {}) {
|
|
1974
|
+
const hostApprovals = options.hostApprovals ?? hostApprovalHooksSupported();
|
|
1693
1975
|
return crypto.createHash('sha256').update(JSON.stringify({
|
|
1694
1976
|
schema: 'marrow-gemini-native-hooks.v1',
|
|
1695
1977
|
adapter_version: MCP_ADAPTER_VERSION,
|
|
@@ -1698,7 +1980,7 @@ function geminiNativeHookFingerprint(settings) {
|
|
|
1698
1980
|
explicitly_disabled: geminiHooksExplicitlyDisabled(settings),
|
|
1699
1981
|
configured: {
|
|
1700
1982
|
pre_action: exactGeminiHookConfigured(
|
|
1701
|
-
settings, 'BeforeTool', 'marrow-before-tool',
|
|
1983
|
+
settings, 'BeforeTool', 'marrow-before-tool', geminiPreActionCommand(hostApprovals),
|
|
1702
1984
|
GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
|
|
1703
1985
|
),
|
|
1704
1986
|
action_result: exactGeminiHookConfigured(
|
|
@@ -1709,6 +1991,12 @@ function geminiNativeHookFingerprint(settings) {
|
|
|
1709
1991
|
settings, 'AfterAgent', 'marrow-after-agent', GEMINI_SESSION_END_HOOK_COMMAND,
|
|
1710
1992
|
undefined, GEMINI_CLOSEOUT_TIMEOUT_MS,
|
|
1711
1993
|
),
|
|
1994
|
+
...(hostApprovals ? {
|
|
1995
|
+
typed_reply: exactGeminiHookConfigured(
|
|
1996
|
+
settings, 'BeforeAgent', 'marrow-before-agent', GEMINI_CONTEXT_HOOK_COMMAND,
|
|
1997
|
+
undefined, GEMINI_HOOK_TIMEOUT_MS,
|
|
1998
|
+
),
|
|
1999
|
+
} : {}),
|
|
1712
2000
|
},
|
|
1713
2001
|
session_end_claimed: false,
|
|
1714
2002
|
})).digest('hex');
|
|
@@ -1825,11 +2113,12 @@ function managedGrokHooksFile(filePath) {
|
|
|
1825
2113
|
}
|
|
1826
2114
|
}
|
|
1827
2115
|
|
|
1828
|
-
function upsertGeminiHooks(settingsPath) {
|
|
2116
|
+
function upsertGeminiHooks(settingsPath, options = {}) {
|
|
1829
2117
|
const settings = parseJsonObject(settingsPath);
|
|
1830
2118
|
const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
|
|
1831
2119
|
? settings.hooks
|
|
1832
2120
|
: {};
|
|
2121
|
+
const hostApprovals = options.hostApprovals === true;
|
|
1833
2122
|
settings.hooks = {
|
|
1834
2123
|
...hooks,
|
|
1835
2124
|
BeforeTool: reconcileGeminiHook(settings, 'BeforeTool', {
|
|
@@ -1837,7 +2126,7 @@ function upsertGeminiHooks(settingsPath) {
|
|
|
1837
2126
|
hooks: [{
|
|
1838
2127
|
name: 'marrow-before-tool',
|
|
1839
2128
|
type: 'command',
|
|
1840
|
-
command:
|
|
2129
|
+
command: geminiPreActionCommand(hostApprovals),
|
|
1841
2130
|
timeout: GEMINI_HOOK_TIMEOUT_MS,
|
|
1842
2131
|
}],
|
|
1843
2132
|
}),
|
|
@@ -1859,6 +2148,17 @@ function upsertGeminiHooks(settingsPath) {
|
|
|
1859
2148
|
}],
|
|
1860
2149
|
}),
|
|
1861
2150
|
};
|
|
2151
|
+
if (hostApprovals) {
|
|
2152
|
+
// The typed reply in a local interactive session (`gemini`, never `gemini -p`).
|
|
2153
|
+
settings.hooks.BeforeAgent = reconcileGeminiHook(settings, 'BeforeAgent', {
|
|
2154
|
+
hooks: [{
|
|
2155
|
+
name: 'marrow-before-agent',
|
|
2156
|
+
type: 'command',
|
|
2157
|
+
command: GEMINI_CONTEXT_HOOK_COMMAND,
|
|
2158
|
+
timeout: GEMINI_HOOK_TIMEOUT_MS,
|
|
2159
|
+
}],
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
1862
2162
|
return JSON.stringify(settings, null, 2) + '\n';
|
|
1863
2163
|
}
|
|
1864
2164
|
|
|
@@ -1935,7 +2235,7 @@ function clineNativeHookFingerprint(detection) {
|
|
|
1935
2235
|
function activationProfile(detection, plan, changes, client) {
|
|
1936
2236
|
const registry = HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === client)
|
|
1937
2237
|
|| HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === 'custom');
|
|
1938
|
-
const sdkDependency = inspectSdkDependency(detection);
|
|
2238
|
+
const sdkDependency = inspectSdkDependency(detection, { sdkMode: plan.mode === 'sdk' || plan.mode === 'both' });
|
|
1939
2239
|
const capabilityLevel = client === 'custom' && (plan.mode === 'sdk' || plan.mode === 'both')
|
|
1940
2240
|
? 'sdk_passive_runtime'
|
|
1941
2241
|
: registry.capability_level;
|
|
@@ -1946,6 +2246,7 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
1946
2246
|
? plan.mcp_target_version
|
|
1947
2247
|
: MCP_ADAPTER_VERSION;
|
|
1948
2248
|
const targetCommand = (command) => retargetMcpPackageSpec(command, mcpTargetVersion);
|
|
2249
|
+
const hostApprovals = hostApprovalHooksSupported(mcpTargetVersion);
|
|
1949
2250
|
const adapterVersion = capabilityLevel === 'native_hooks' || capabilityLevel === 'mcp'
|
|
1950
2251
|
? mcpTargetVersion
|
|
1951
2252
|
: capabilityLevel === 'sdk_passive_runtime'
|
|
@@ -1964,14 +2265,18 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
1964
2265
|
if (exactHookConfigured(codexSettings, 'PostToolUse', targetCommand(CODEX_ACTION_RESULT_HOOK_COMMAND), CODEX_NATIVE_HOOK_MATCHER)) observedHooks.push('action_result');
|
|
1965
2266
|
if (exactHookConfigured(codexSettings, 'SessionEnd', targetCommand(CODEX_SESSION_END_HOOK_COMMAND))) observedHooks.push('session_end');
|
|
1966
2267
|
} else if (client === 'cursor' || client === 'composer') {
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
2268
|
+
const approvalHooks = hostApprovals ? cursorHostApprovalHooksConfigured(cursorSettings, targetCommand) : null;
|
|
2269
|
+
if ((hostApprovals
|
|
2270
|
+
? approvalHooks.pre_tool_use && approvalHooks.beforeShellExecution && approvalHooks.beforeMCPExecution
|
|
2271
|
+
&& approvalHooks.sessionStart && approvalHooks.beforeSubmitPrompt
|
|
2272
|
+
: exactCursorHookConfigured(cursorSettings, 'preToolUse', targetCommand(CURSOR_PRE_ACTION_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
|
|
2273
|
+
timeout: CODEX_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false,
|
|
2274
|
+
}))) observedHooks.push('pre_action');
|
|
1970
2275
|
if (exactCursorHookConfigured(cursorSettings, 'postToolUse', targetCommand(CURSOR_ACTION_RESULT_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
|
|
1971
2276
|
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1972
2277
|
}) && exactCursorHookConfigured(cursorSettings, 'postToolUseFailure', targetCommand(CURSOR_ACTION_RESULT_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
|
|
1973
2278
|
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
1974
|
-
})) observedHooks.push('action_result');
|
|
2279
|
+
}) && (!hostApprovals || (approvalHooks.afterShellExecution && approvalHooks.afterMCPExecution))) observedHooks.push('action_result');
|
|
1975
2280
|
if (exactCursorHookConfigured(cursorSettings, 'stop', targetCommand(CURSOR_SESSION_END_HOOK_COMMAND), undefined, {
|
|
1976
2281
|
timeout: CODEX_SESSION_TIMEOUT_SECONDS,
|
|
1977
2282
|
})) observedHooks.push('outcome_closure');
|
|
@@ -1991,9 +2296,12 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
1991
2296
|
)) observedHooks.push('response_closeout');
|
|
1992
2297
|
} else if (client === 'gemini' && !geminiHooksExplicitlyDisabled(geminiSettings)) {
|
|
1993
2298
|
if (exactGeminiHookConfigured(
|
|
1994
|
-
geminiSettings, 'BeforeTool', 'marrow-before-tool', targetCommand(
|
|
2299
|
+
geminiSettings, 'BeforeTool', 'marrow-before-tool', targetCommand(geminiPreActionCommand(hostApprovals)),
|
|
1995
2300
|
GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
|
|
1996
|
-
)
|
|
2301
|
+
) && (!hostApprovals || exactGeminiHookConfigured(
|
|
2302
|
+
geminiSettings, 'BeforeAgent', 'marrow-before-agent', targetCommand(GEMINI_CONTEXT_HOOK_COMMAND),
|
|
2303
|
+
undefined, GEMINI_HOOK_TIMEOUT_MS,
|
|
2304
|
+
))) observedHooks.push('pre_action');
|
|
1997
2305
|
if (exactGeminiHookConfigured(
|
|
1998
2306
|
geminiSettings, 'AfterTool', 'marrow-after-tool', targetCommand(GEMINI_ACTION_RESULT_HOOK_COMMAND),
|
|
1999
2307
|
GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
|
|
@@ -2017,11 +2325,14 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
2017
2325
|
} else {
|
|
2018
2326
|
if (capabilityLevel === 'native_hooks'
|
|
2019
2327
|
&& exactHookConfigured(claudeSettings, 'UserPromptSubmit', targetCommand(MCP_CONTEXT_HOOK_COMMAND))) observedHooks.push('prompt');
|
|
2328
|
+
const claudeApprovalHooks = hostApprovals ? claudeHostApprovalHooksConfigured(claudeSettings, targetCommand) : null;
|
|
2020
2329
|
if (capabilityLevel === 'native_hooks'
|
|
2021
|
-
&& exactHookConfigured(claudeSettings, 'PreToolUse', targetCommand(MCP_PRE_ACTION_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
|
|
2330
|
+
&& exactHookConfigured(claudeSettings, 'PreToolUse', targetCommand(MCP_PRE_ACTION_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
|
|
2331
|
+
&& (!hostApprovals || claudeApprovalHooks.permission_request)) observedHooks.push('pre_action');
|
|
2022
2332
|
if (capabilityLevel === 'native_hooks'
|
|
2023
2333
|
&& 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)
|
|
2334
|
+
&& exactHookConfigured(claudeSettings, 'PostToolUseFailure', targetCommand(MCP_ACTION_RESULT_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
|
|
2335
|
+
&& (!hostApprovals || claudeApprovalHooks.post_tool_batch)) observedHooks.push('action_result');
|
|
2025
2336
|
if (capabilityLevel === 'native_hooks'
|
|
2026
2337
|
&& exactHookConfigured(claudeSettings, 'Stop', targetCommand(MCP_SESSION_END_HOOK_COMMAND))) observedHooks.push('session_end');
|
|
2027
2338
|
}
|
|
@@ -2048,12 +2359,12 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
2048
2359
|
.join('|');
|
|
2049
2360
|
const configFingerprint = capabilityLevel === 'native_hooks'
|
|
2050
2361
|
? client === 'codex' ? codexNativeHookFingerprint(codexSettings)
|
|
2051
|
-
: client === 'cursor' || client === 'composer' ? cursorNativeHookFingerprint(cursorSettings)
|
|
2362
|
+
: client === 'cursor' || client === 'composer' ? cursorNativeHookFingerprint(cursorSettings, { hostApprovals })
|
|
2052
2363
|
: client === 'cline' ? clineNativeHookFingerprint(detection)
|
|
2053
2364
|
: client === 'windsurf' ? windsurfNativeHookFingerprint(windsurfSettings)
|
|
2054
|
-
: client === 'gemini' ? geminiNativeHookFingerprint(geminiSettings)
|
|
2365
|
+
: client === 'gemini' ? geminiNativeHookFingerprint(geminiSettings, { hostApprovals })
|
|
2055
2366
|
: client === 'grok' ? grokNativeHookFingerprint(grokSettings)
|
|
2056
|
-
: claudeNativeHookFingerprint(claudeSettings)
|
|
2367
|
+
: claudeNativeHookFingerprint(claudeSettings, { hostApprovals })
|
|
2057
2368
|
: crypto.createHash('sha256')
|
|
2058
2369
|
.update(`${client}:${capabilityLevel}:${expectedHooks.join(',')}:${fingerprintMaterial}`)
|
|
2059
2370
|
.digest('hex');
|
|
@@ -2220,8 +2531,9 @@ function upsertMcpServerConfig(filePath, options = {}) {
|
|
|
2220
2531
|
return JSON.stringify(config, null, 2) + '\n';
|
|
2221
2532
|
}
|
|
2222
2533
|
|
|
2223
|
-
function inspectSdkDependency(detection) {
|
|
2224
|
-
|
|
2534
|
+
function inspectSdkDependency(detection, { sdkMode = false } = {}) {
|
|
2535
|
+
// No SDK advice unless this project uses the SDK or the owner asked for the SDK runtime.
|
|
2536
|
+
if (!sdkMode && !sdkUseDetected(detection)) {
|
|
2225
2537
|
return { required: false, present: false, install_command: null };
|
|
2226
2538
|
}
|
|
2227
2539
|
|
|
@@ -2346,11 +2658,14 @@ function defaultHarnessInstallMatrix(detection = detectEnvironment(process.cwd()
|
|
|
2346
2658
|
&& exactHookConfigured(codexSettings, 'PreToolUse', CODEX_PRE_ACTION_HOOK_COMMAND, CODEX_NATIVE_HOOK_MATCHER)
|
|
2347
2659
|
&& exactHookConfigured(codexSettings, 'PostToolUse', CODEX_ACTION_RESULT_HOOK_COMMAND, CODEX_NATIVE_HOOK_MATCHER)
|
|
2348
2660
|
&& exactHookConfigured(codexSettings, 'SessionEnd', CODEX_SESSION_END_HOOK_COMMAND));
|
|
2661
|
+
const hostApprovals = hostApprovalHooksSupported();
|
|
2349
2662
|
const cursorSettings = ['cursor', 'composer'].includes(entry.client) ? safeJsonObject(detection.paths.cursorHooks) : null;
|
|
2663
|
+
const cursorApprovalHooks = cursorSettings && hostApprovals ? cursorHostApprovalHooksConfigured(cursorSettings) : null;
|
|
2350
2664
|
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,
|
|
2665
|
+
&& exactCursorHookConfigured(cursorSettings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, hostApprovals ? CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER : CURSOR_NATIVE_HOOK_MATCHER, {
|
|
2666
|
+
timeout: hostApprovals ? CURSOR_GATE_HOOK_TIMEOUT_SECONDS : CODEX_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false,
|
|
2353
2667
|
})
|
|
2668
|
+
&& (!cursorApprovalHooks || Object.values(cursorApprovalHooks).every(Boolean))
|
|
2354
2669
|
&& exactCursorHookConfigured(cursorSettings, 'postToolUse', CURSOR_ACTION_RESULT_HOOK_COMMAND, CURSOR_NATIVE_HOOK_MATCHER, {
|
|
2355
2670
|
timeout: CODEX_HOOK_TIMEOUT_SECONDS,
|
|
2356
2671
|
})
|
|
@@ -2377,9 +2692,13 @@ function defaultHarnessInstallMatrix(detection = detectEnvironment(process.cwd()
|
|
|
2377
2692
|
const geminiConfigured = Boolean(geminiSettings
|
|
2378
2693
|
&& !geminiHooksExplicitlyDisabled(geminiSettings)
|
|
2379
2694
|
&& exactGeminiHookConfigured(
|
|
2380
|
-
geminiSettings, 'BeforeTool', 'marrow-before-tool',
|
|
2695
|
+
geminiSettings, 'BeforeTool', 'marrow-before-tool', geminiPreActionCommand(hostApprovals),
|
|
2381
2696
|
GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
|
|
2382
2697
|
)
|
|
2698
|
+
&& (!hostApprovals || exactGeminiHookConfigured(
|
|
2699
|
+
geminiSettings, 'BeforeAgent', 'marrow-before-agent', GEMINI_CONTEXT_HOOK_COMMAND,
|
|
2700
|
+
undefined, GEMINI_HOOK_TIMEOUT_MS,
|
|
2701
|
+
))
|
|
2383
2702
|
&& exactGeminiHookConfigured(
|
|
2384
2703
|
geminiSettings, 'AfterTool', 'marrow-after-tool', GEMINI_ACTION_RESULT_HOOK_COMMAND,
|
|
2385
2704
|
GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
|
|
@@ -2462,6 +2781,32 @@ function derivedAgentIdsFor(root) {
|
|
|
2462
2781
|
return [...SOURCE_CLIENTS].map((candidate) => stableAgentId(root, candidate));
|
|
2463
2782
|
}
|
|
2464
2783
|
|
|
2784
|
+
// SDK use in this project: the SDK is a declared or installed dependency, or an earlier install
|
|
2785
|
+
// already wrote Marrow's passive runtime here. Without it, no SDK file or SDK advice is written.
|
|
2786
|
+
function sdkUseDetected(detection) {
|
|
2787
|
+
if (exists(detection.paths.passiveRuntime)) return true;
|
|
2788
|
+
if (!detection.node) return false;
|
|
2789
|
+
if (findUp(detection.root, [path.join('node_modules', '@getmarrow', 'sdk', 'package.json')])) return true;
|
|
2790
|
+
const raw = safeRead(detection.paths.packageJson);
|
|
2791
|
+
if (!raw) return false;
|
|
2792
|
+
// A package.json Marrow cannot parse is checked by hand (the SDK check says so).
|
|
2793
|
+
try {
|
|
2794
|
+
JSON.parse(raw);
|
|
2795
|
+
} catch {
|
|
2796
|
+
return /@getmarrow\/sdk/.test(raw);
|
|
2797
|
+
}
|
|
2798
|
+
return /"@getmarrow\/sdk"/.test(raw) || /@getmarrow\/sdk@/.test(raw);
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
// Hosts that read AGENTS.md. Claude Code reads CLAUDE.md and Cursor gets its own rule file, so
|
|
2802
|
+
// a project with only those gets no AGENTS.md unless it already holds Marrow's block.
|
|
2803
|
+
const AGENTS_MD_READERS = Object.freeze(['codex', 'windsurf', 'gemini', 'cline', 'grok', 'hermes', 'openclaw']);
|
|
2804
|
+
|
|
2805
|
+
function agentsMdWanted(detection) {
|
|
2806
|
+
if (AGENTS_MD_READERS.some((name) => detection[name])) return true;
|
|
2807
|
+
return Boolean(marrowManagedBlockInText(safeRead(detection.paths.agentsMd)));
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2465
2810
|
function buildPlan(detection, options) {
|
|
2466
2811
|
const client = options.client || detectedClient(detection);
|
|
2467
2812
|
// Server-facing configuration carries only a configured agent id. Without one, the MCP
|
|
@@ -2488,9 +2833,14 @@ function buildPlan(detection, options) {
|
|
|
2488
2833
|
return write;
|
|
2489
2834
|
};
|
|
2490
2835
|
const mcpTargetVersion = executableMcpTarget(options).version;
|
|
2836
|
+
// Hooks start the verified local MCP runtime for this exact version when there is one.
|
|
2837
|
+
const runtimeVersion = options.mcpRuntime && options.mcpRuntime.version === mcpTargetVersion ? mcpTargetVersion : null;
|
|
2838
|
+
const localize = (text) => (runtimeVersion ? localizeHookSettingsText(text, runtimeVersion) : text);
|
|
2491
2839
|
const retarget = (value) => retargetMcpPackageSpec(value, mcpTargetVersion);
|
|
2840
|
+
// The host-approval hook layout only for an MCP that answers those hooks.
|
|
2841
|
+
const hostApprovals = hostApprovalHooksSupported(mcpTargetVersion);
|
|
2492
2842
|
const mode = options.mode === 'auto'
|
|
2493
|
-
? detection
|
|
2843
|
+
? sdkUseDetected(detection) ? 'both' : 'mcp'
|
|
2494
2844
|
: options.mode;
|
|
2495
2845
|
const writes = [];
|
|
2496
2846
|
|
|
@@ -2516,7 +2866,7 @@ function buildPlan(detection, options) {
|
|
|
2516
2866
|
type: 'json-transform',
|
|
2517
2867
|
path: detection.paths.claudeSettings,
|
|
2518
2868
|
label: 'Claude Code MCP passive hooks',
|
|
2519
|
-
transform: (filePath) => retarget(upsertClaudeHooks(filePath)),
|
|
2869
|
+
transform: (filePath) => localize(retarget(upsertClaudeHooks(filePath, { hostApprovals }))),
|
|
2520
2870
|
});
|
|
2521
2871
|
}
|
|
2522
2872
|
if (detection.codex) {
|
|
@@ -2524,7 +2874,7 @@ function buildPlan(detection, options) {
|
|
|
2524
2874
|
type: 'json-transform',
|
|
2525
2875
|
path: detection.paths.codexHooks,
|
|
2526
2876
|
label: 'Codex native hooks',
|
|
2527
|
-
transform: (filePath) => retarget(upsertCodexHooks(filePath)),
|
|
2877
|
+
transform: (filePath) => localize(retarget(upsertCodexHooks(filePath))),
|
|
2528
2878
|
});
|
|
2529
2879
|
}
|
|
2530
2880
|
if (detection.cline) {
|
|
@@ -2544,7 +2894,7 @@ function buildPlan(detection, options) {
|
|
|
2544
2894
|
type: 'json-transform',
|
|
2545
2895
|
path: detection.paths.windsurfHooks,
|
|
2546
2896
|
label: 'Windsurf native hooks',
|
|
2547
|
-
transform: (filePath) => retarget(upsertWindsurfHooks(filePath)),
|
|
2897
|
+
transform: (filePath) => localize(retarget(upsertWindsurfHooks(filePath))),
|
|
2548
2898
|
});
|
|
2549
2899
|
}
|
|
2550
2900
|
if (detection.gemini) {
|
|
@@ -2552,7 +2902,7 @@ function buildPlan(detection, options) {
|
|
|
2552
2902
|
type: 'json-transform',
|
|
2553
2903
|
path: detection.paths.geminiSettings,
|
|
2554
2904
|
label: 'Gemini CLI native hooks',
|
|
2555
|
-
transform: (filePath) => retarget(upsertGeminiHooks(filePath)),
|
|
2905
|
+
transform: (filePath) => localize(retarget(upsertGeminiHooks(filePath, { hostApprovals }))),
|
|
2556
2906
|
});
|
|
2557
2907
|
}
|
|
2558
2908
|
if (detection.grok) {
|
|
@@ -2563,7 +2913,7 @@ function buildPlan(detection, options) {
|
|
|
2563
2913
|
label: 'Grok native hooks',
|
|
2564
2914
|
isManaged: managedGrokHooksFile,
|
|
2565
2915
|
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)),
|
|
2916
|
+
transform: (filePath) => localize(retarget(upsertGrokHooks(filePath))),
|
|
2567
2917
|
});
|
|
2568
2918
|
}
|
|
2569
2919
|
writes.push(mcpConfigWrite(detection.paths.mcpJson, 'Project MCP server config'));
|
|
@@ -2587,18 +2937,20 @@ function buildPlan(detection, options) {
|
|
|
2587
2937
|
type: 'json-transform',
|
|
2588
2938
|
path: detection.paths.cursorHooks,
|
|
2589
2939
|
label: 'Cursor native hooks',
|
|
2590
|
-
transform: (filePath) => retarget(upsertCursorHooks(filePath)),
|
|
2940
|
+
transform: (filePath) => localize(retarget(upsertCursorHooks(filePath, { hostApprovals }))),
|
|
2591
2941
|
});
|
|
2592
2942
|
writes.push(mcpConfigWrite(detection.paths.cursorMcp, 'Cursor MCP server config'));
|
|
2593
2943
|
}
|
|
2594
2944
|
}
|
|
2595
2945
|
|
|
2596
|
-
|
|
2946
|
+
// AGENTS.md only for a host that reads it, an explicit --md or --both, or a file that already
|
|
2947
|
+
// carries Marrow's block (so its pins stay current).
|
|
2948
|
+
if (mode === 'md' || (options.mode === 'both') || ((mode === 'both' || mode === 'mcp') && agentsMdWanted(detection))) {
|
|
2597
2949
|
writes.push({
|
|
2598
2950
|
type: 'md-block',
|
|
2599
2951
|
path: detection.paths.agentsMd,
|
|
2600
2952
|
label: 'Agent instructions',
|
|
2601
|
-
block: retarget(passiveInstructions()),
|
|
2953
|
+
block: retarget(passiveInstructions({ hostApprovals })),
|
|
2602
2954
|
});
|
|
2603
2955
|
}
|
|
2604
2956
|
|
|
@@ -2607,11 +2959,11 @@ function buildPlan(detection, options) {
|
|
|
2607
2959
|
type: 'file',
|
|
2608
2960
|
path: detection.paths.cursorRules,
|
|
2609
2961
|
label: 'Cursor Marrow rule',
|
|
2610
|
-
content: retarget(passiveInstructions()).replace(/<!--[^>]+-->/g, '').trim() + '\n',
|
|
2962
|
+
content: retarget(passiveInstructions({ hostApprovals })).replace(/<!--[^>]+-->/g, '').trim() + '\n',
|
|
2611
2963
|
});
|
|
2612
2964
|
}
|
|
2613
2965
|
|
|
2614
|
-
return { mode, root: detection.root, writes, mcp_target_version: mcpTargetVersion };
|
|
2966
|
+
return { mode, root: detection.root, writes, mcp_target_version: mcpTargetVersion, host_approval_hooks: hostApprovals };
|
|
2615
2967
|
}
|
|
2616
2968
|
|
|
2617
2969
|
function assertContainedManagedTarget(root, targetPath) {
|
|
@@ -2728,7 +3080,9 @@ function applyPlan(plan, options) {
|
|
|
2728
3080
|
undo = planned.action === 'update' ? planned.undo || [] : null;
|
|
2729
3081
|
}
|
|
2730
3082
|
} else if (write.type === 'owned-executable') {
|
|
2731
|
-
|
|
3083
|
+
// A file Marrow wrote for another pinned MCP version is still Marrow's and moves to this
|
|
3084
|
+
// pin; any other difference is the owner's edit and is never overwritten.
|
|
3085
|
+
if (fileExists && before !== write.content && !sameMarrowFile(before, [write.content])) {
|
|
2732
3086
|
after = before;
|
|
2733
3087
|
hookConflict = true;
|
|
2734
3088
|
} else {
|
|
@@ -2803,6 +3157,294 @@ function applyPlan(plan, options) {
|
|
|
2803
3157
|
return changes;
|
|
2804
3158
|
}
|
|
2805
3159
|
|
|
3160
|
+
// ---------------------------------------------------------------------------
|
|
3161
|
+
// Uninstall: removes only what Marrow wrote. Every other hook, server, setting and file is kept.
|
|
3162
|
+
// Files are read in-process and never printed; the report names paths and counts only.
|
|
3163
|
+
// ---------------------------------------------------------------------------
|
|
3164
|
+
|
|
3165
|
+
// Hook groups of the Claude Code, Codex, Grok and Gemini shape:
|
|
3166
|
+
// hooks[event] = [{ matcher?, hooks: [handler, ...] }].
|
|
3167
|
+
function withoutMarrowGroupedHandlers(settings, isMarrowHandler) {
|
|
3168
|
+
const hooks = settings?.hooks;
|
|
3169
|
+
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return { settings, removed: 0, kept: 0 };
|
|
3170
|
+
let removed = 0;
|
|
3171
|
+
let kept = 0;
|
|
3172
|
+
const next = {};
|
|
3173
|
+
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
3174
|
+
if (!Array.isArray(entries)) {
|
|
3175
|
+
next[eventName] = entries;
|
|
3176
|
+
continue;
|
|
3177
|
+
}
|
|
3178
|
+
const retained = [];
|
|
3179
|
+
let touched = false;
|
|
3180
|
+
for (const entry of entries) {
|
|
3181
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !Array.isArray(entry.hooks)) {
|
|
3182
|
+
retained.push(entry);
|
|
3183
|
+
kept += 1;
|
|
3184
|
+
continue;
|
|
3185
|
+
}
|
|
3186
|
+
const handlers = entry.hooks.filter((handler) => !isMarrowHandler(handler));
|
|
3187
|
+
kept += handlers.length;
|
|
3188
|
+
if (handlers.length === entry.hooks.length) {
|
|
3189
|
+
retained.push(entry);
|
|
3190
|
+
continue;
|
|
3191
|
+
}
|
|
3192
|
+
touched = true;
|
|
3193
|
+
removed += entry.hooks.length - handlers.length;
|
|
3194
|
+
if (handlers.length > 0) retained.push({ ...entry, hooks: handlers });
|
|
3195
|
+
}
|
|
3196
|
+
// An event that held only Marrow's entries goes away with them.
|
|
3197
|
+
if (touched && retained.length === 0) continue;
|
|
3198
|
+
next[eventName] = retained;
|
|
3199
|
+
}
|
|
3200
|
+
const result = { ...settings, hooks: next };
|
|
3201
|
+
if (removed > 0 && Object.keys(next).length === 0) delete result.hooks;
|
|
3202
|
+
return { settings: result, removed, kept };
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
// Flat hook lists of the Cursor and Windsurf shape: hooks[event] = [{ command, ... }].
|
|
3206
|
+
function withoutMarrowFlatEntries(settings, isMarrowEntry) {
|
|
3207
|
+
const hooks = settings?.hooks;
|
|
3208
|
+
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return { settings, removed: 0, kept: 0 };
|
|
3209
|
+
let removed = 0;
|
|
3210
|
+
let kept = 0;
|
|
3211
|
+
const next = {};
|
|
3212
|
+
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
3213
|
+
if (!Array.isArray(entries)) {
|
|
3214
|
+
next[eventName] = entries;
|
|
3215
|
+
continue;
|
|
3216
|
+
}
|
|
3217
|
+
const retained = entries.filter((entry) => !(entry && typeof entry === 'object' && !Array.isArray(entry) && isMarrowEntry(entry)));
|
|
3218
|
+
kept += retained.length;
|
|
3219
|
+
removed += entries.length - retained.length;
|
|
3220
|
+
if (retained.length === 0 && entries.length > 0) continue;
|
|
3221
|
+
next[eventName] = retained;
|
|
3222
|
+
}
|
|
3223
|
+
const result = { ...settings, hooks: next };
|
|
3224
|
+
if (removed > 0 && Object.keys(next).length === 0) delete result.hooks;
|
|
3225
|
+
return { settings: result, removed, kept };
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
const isMarrowCommandHandler = (handler) => Boolean(handler && typeof handler === 'object' && !Array.isArray(handler)
|
|
3229
|
+
&& handler.type === 'command' && marrowHookSubcommand(handler.command));
|
|
3230
|
+
const isMarrowGeminiHandler = (handler) => Boolean(handler && typeof handler === 'object' && !Array.isArray(handler)
|
|
3231
|
+
&& (String(handler.name || '').startsWith('marrow-') || geminiMarrowHookEntrypoint(handler.command)));
|
|
3232
|
+
const isMarrowGrokHandler = (handler) => Boolean(handler && typeof handler === 'object' && !Array.isArray(handler)
|
|
3233
|
+
&& handler.type === 'command' && grokMarrowHookSubcommand(handler.command));
|
|
3234
|
+
|
|
3235
|
+
// The MCP server entry only when Marrow wrote it: npx running the published package.
|
|
3236
|
+
function withoutMarrowMcpServer(config) {
|
|
3237
|
+
const servers = config?.mcpServers;
|
|
3238
|
+
const entry = servers && typeof servers === 'object' && !Array.isArray(servers) ? servers.marrow : null;
|
|
3239
|
+
if (!entry) return { config, removed: 0, kept: 0, custom: false };
|
|
3240
|
+
const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
|
|
3241
|
+
const ours = entry.command === 'npx' && args.includes('marrow-mcp')
|
|
3242
|
+
&& args.some((arg) => /^--package=@getmarrow\/mcp(?:@[^\s]+)?$/.test(arg));
|
|
3243
|
+
if (!ours) return { config, removed: 0, kept: 1, custom: true };
|
|
3244
|
+
const nextServers = { ...servers };
|
|
3245
|
+
delete nextServers.marrow;
|
|
3246
|
+
const result = { ...config, mcpServers: nextServers };
|
|
3247
|
+
if (Object.keys(nextServers).length === 0) delete result.mcpServers;
|
|
3248
|
+
return { config: result, removed: 1, kept: Object.keys(nextServers).length, custom: false };
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
function withoutMarrowInstructionBlock(content) {
|
|
3252
|
+
const block = marrowManagedBlockInText(content);
|
|
3253
|
+
if (!block) return { content, removed: 0 };
|
|
3254
|
+
const start = content.indexOf(block);
|
|
3255
|
+
const before = content.slice(0, start);
|
|
3256
|
+
const after = content.slice(start + block.length).replace(/^\n/, '');
|
|
3257
|
+
const head = before.trim() ? before.replace(/\n*$/, after.trim() ? '\n\n' : '\n') : '';
|
|
3258
|
+
return { content: `${head}${after}`, removed: 1 };
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
// Marrow-written files compare equal once the pinned MCP version is normalized.
|
|
3262
|
+
function sameMarrowFile(actual, candidates) {
|
|
3263
|
+
const normalize = (value) => String(value).replace(/@getmarrow\/mcp@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g, '@getmarrow/mcp@<pinned>');
|
|
3264
|
+
const normalized = normalize(actual);
|
|
3265
|
+
return candidates.some((candidate) => normalize(candidate) === normalized);
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
function buildUninstallPlan(detection) {
|
|
3269
|
+
const steps = [];
|
|
3270
|
+
const json = (filePath, label, transform, extra = {}) => steps.push({ kind: 'json', path: filePath, label, transform, ...extra });
|
|
3271
|
+
const mcpServer = (value) => {
|
|
3272
|
+
const result = withoutMarrowMcpServer(value);
|
|
3273
|
+
return { settings: result.config, removed: result.removed, kept: result.kept, custom: result.custom };
|
|
3274
|
+
};
|
|
3275
|
+
json(detection.paths.claudeSettings, 'Claude Code hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowCommandHandler));
|
|
3276
|
+
json(detection.paths.codexHooks, 'Codex hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowCommandHandler));
|
|
3277
|
+
json(detection.paths.cursorHooks, 'Cursor hooks', (value) => withoutMarrowFlatEntries(value, (entry) => Boolean(cursorMarrowHookSubcommand(entry.command))));
|
|
3278
|
+
json(detection.paths.windsurfHooks, 'Windsurf hooks', (value) => withoutMarrowFlatEntries(value, (entry) => Boolean(windsurfMarrowHookEntrypoint(entry.command))));
|
|
3279
|
+
json(detection.paths.geminiSettings, 'Gemini CLI hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowGeminiHandler));
|
|
3280
|
+
json(detection.paths.grokHooks, 'Grok hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowGrokHandler), {
|
|
3281
|
+
root: detection.home,
|
|
3282
|
+
grok: true,
|
|
3283
|
+
// Marrow's own global file: deleted when nothing but Marrow's hooks was in it.
|
|
3284
|
+
removeWhenEmpty: true,
|
|
3285
|
+
});
|
|
3286
|
+
json(detection.paths.mcpJson, 'Project MCP server config', mcpServer);
|
|
3287
|
+
json(detection.paths.cursorMcp, 'Cursor MCP server config', mcpServer);
|
|
3288
|
+
steps.push({ kind: 'md-block', path: detection.paths.agentsMd, label: 'Agent instructions' });
|
|
3289
|
+
const instructionVariants = [false, true].map((hostApprovals) => passiveInstructions({ hostApprovals }).replace(/<!--[^>]+-->/g, '').trim() + '\n');
|
|
3290
|
+
steps.push({ kind: 'owned-file', path: detection.paths.cursorRules, label: 'Cursor Marrow rule', candidates: instructionVariants });
|
|
3291
|
+
for (const hook of clineHookContract(detection)) {
|
|
3292
|
+
steps.push({ kind: 'owned-file', path: hook.path, label: hook.label, candidates: [hook.content] });
|
|
3293
|
+
}
|
|
3294
|
+
return steps;
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
function uninstallFileState(filePath) {
|
|
3298
|
+
try {
|
|
3299
|
+
const stat = fs.lstatSync(filePath);
|
|
3300
|
+
if (stat.isSymbolicLink() || !stat.isFile()) return 'unsafe';
|
|
3301
|
+
if (stat.size > 4 * 1024 * 1024) return 'too_large';
|
|
3302
|
+
return 'file';
|
|
3303
|
+
} catch {
|
|
3304
|
+
return 'missing';
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
async function uninstall(options = {}) {
|
|
3309
|
+
const home = options.home || process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
3310
|
+
const detection = detectEnvironment(options.cwd || process.cwd(), { ...process.env, HOME: home, USERPROFILE: home });
|
|
3311
|
+
const apply = Boolean(options.yes && !options.dryRun);
|
|
3312
|
+
const root = path.resolve(detection.root);
|
|
3313
|
+
const changes = [];
|
|
3314
|
+
let controller = null;
|
|
3315
|
+
if (apply && options.controller !== false) {
|
|
3316
|
+
// A running controller restores missing managed hooks every five minutes; stop it first.
|
|
3317
|
+
try {
|
|
3318
|
+
const stopped = await stopProjectControllers({ root, client: detectedClient(detection), mode: 'auto' });
|
|
3319
|
+
controller = { stopped: stopped.stopped || 0, changed: Boolean(stopped.changed) };
|
|
3320
|
+
} catch {
|
|
3321
|
+
controller = { stopped: 0, changed: false, error: 'controller_stop_failed' };
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
for (const step of buildUninstallPlan(detection)) {
|
|
3325
|
+
const state = uninstallFileState(step.path);
|
|
3326
|
+
if (state === 'missing') continue;
|
|
3327
|
+
const change = { path: step.path, label: step.label, removed_entries: 0, kept_entries: 0, action: 'unchanged', applied: false };
|
|
3328
|
+
if (state !== 'file') {
|
|
3329
|
+
changes.push({ ...change, action: 'skipped', reason: state === 'unsafe' ? 'not a regular file' : 'larger than 4 MB' });
|
|
3330
|
+
continue;
|
|
3331
|
+
}
|
|
3332
|
+
try {
|
|
3333
|
+
const writeRoot = step.root ? path.resolve(step.root) : root;
|
|
3334
|
+
if (step.grok) {
|
|
3335
|
+
if (!managedGrokHooksFile(step.path)) {
|
|
3336
|
+
changes.push({ ...change, action: 'skipped', reason: 'not managed by Marrow' });
|
|
3337
|
+
continue;
|
|
3338
|
+
}
|
|
3339
|
+
assertSafeGrokHookTarget(writeRoot, step.path);
|
|
3340
|
+
}
|
|
3341
|
+
assertContainedManagedTarget(writeRoot, step.path);
|
|
3342
|
+
const before = fs.readFileSync(step.path, 'utf8');
|
|
3343
|
+
if (step.kind === 'owned-file') {
|
|
3344
|
+
if (!sameMarrowFile(before, step.candidates)) {
|
|
3345
|
+
if (MARROW_MANAGED_TEXT_RE.test(before)) changes.push({ ...change, action: 'kept', reason: 'edited after Marrow wrote it' });
|
|
3346
|
+
continue;
|
|
3347
|
+
}
|
|
3348
|
+
change.removed_entries = 1;
|
|
3349
|
+
change.action = 'delete_file';
|
|
3350
|
+
if (apply) fs.unlinkSync(step.path);
|
|
3351
|
+
} else if (step.kind === 'md-block') {
|
|
3352
|
+
const result = withoutMarrowInstructionBlock(before);
|
|
3353
|
+
if (!result.removed) continue;
|
|
3354
|
+
change.removed_entries = 1;
|
|
3355
|
+
change.action = result.content.trim() ? 'update' : 'delete_file';
|
|
3356
|
+
if (change.action === 'delete_file') change.note = 'it held only the Marrow block';
|
|
3357
|
+
else change.note = 'block_only';
|
|
3358
|
+
if (apply) {
|
|
3359
|
+
if (change.action === 'delete_file') fs.unlinkSync(step.path);
|
|
3360
|
+
else atomicWriteManagedFile(writeRoot, step.path, result.content);
|
|
3361
|
+
}
|
|
3362
|
+
} else {
|
|
3363
|
+
let parsed;
|
|
3364
|
+
try {
|
|
3365
|
+
// Marrow's hook commands in their canonical form, whether they start through npx or
|
|
3366
|
+
// the local runtime, so both are recognized and removed.
|
|
3367
|
+
parsed = before.trim() ? mapHookCommands(JSON.parse(before), delocalizeHookCommand) : {};
|
|
3368
|
+
} catch {
|
|
3369
|
+
changes.push({ ...change, action: 'skipped', reason: 'invalid JSON' });
|
|
3370
|
+
continue;
|
|
3371
|
+
}
|
|
3372
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
3373
|
+
changes.push({ ...change, action: 'skipped', reason: 'root is not a JSON object' });
|
|
3374
|
+
continue;
|
|
3375
|
+
}
|
|
3376
|
+
const result = step.transform(parsed);
|
|
3377
|
+
change.kept_entries = result.kept;
|
|
3378
|
+
if (result.custom) {
|
|
3379
|
+
changes.push({ ...change, action: 'kept', reason: 'custom marrow server entry' });
|
|
3380
|
+
continue;
|
|
3381
|
+
}
|
|
3382
|
+
if (!result.removed) continue;
|
|
3383
|
+
change.removed_entries = result.removed;
|
|
3384
|
+
const emptied = step.removeWhenEmpty && result.kept === 0
|
|
3385
|
+
&& Object.keys(result.settings).every((key) => key === 'hooks');
|
|
3386
|
+
change.action = emptied ? 'delete_file' : 'update';
|
|
3387
|
+
if (apply) {
|
|
3388
|
+
if (emptied) fs.unlinkSync(step.path);
|
|
3389
|
+
else atomicWriteManagedFile(writeRoot, step.path, JSON.stringify(result.settings, null, 2) + '\n');
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
change.applied = apply;
|
|
3393
|
+
changes.push(change);
|
|
3394
|
+
} catch (error) {
|
|
3395
|
+
changes.push({ ...change, action: 'skipped', reason: error instanceof Error ? error.message.slice(0, 200) : 'unsafe target' });
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
const kept = [];
|
|
3399
|
+
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' });
|
|
3400
|
+
if (exists(detection.paths.passiveEnv)) kept.push({ path: detection.paths.passiveEnv, reason: 'it may hold your own edits; delete it when no longer needed' });
|
|
3401
|
+
let hermesEntry = false;
|
|
3402
|
+
try {
|
|
3403
|
+
hermesEntry = Boolean(detection.hermesConfig)
|
|
3404
|
+
&& /(?:^|\n)mcp_servers:[\s\S]*\n[ \t]+marrow:/.test(fs.readFileSync(detection.paths.hermesConfig, 'utf8'));
|
|
3405
|
+
} catch {
|
|
3406
|
+
hermesEntry = false;
|
|
3407
|
+
}
|
|
3408
|
+
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' });
|
|
3409
|
+
// The local MCP runtime (~/.marrow/runtime) goes too; hooks elsewhere then start through npx.
|
|
3410
|
+
const runtime = removeMcpRuntime(options.home || detection.home, { dryRun: !apply });
|
|
3411
|
+
const removedEntries = changes.reduce((sum, change) => sum + change.removed_entries, 0);
|
|
3412
|
+
return {
|
|
3413
|
+
uninstall: {
|
|
3414
|
+
applied: apply,
|
|
3415
|
+
dry_run: !apply,
|
|
3416
|
+
root,
|
|
3417
|
+
controller,
|
|
3418
|
+
changes,
|
|
3419
|
+
kept,
|
|
3420
|
+
runtime: runtime.removed || runtime.would_remove ? runtime : null,
|
|
3421
|
+
removed_entries: removedEntries,
|
|
3422
|
+
next_step: apply
|
|
3423
|
+
? 'Restart the agent hosts so they stop loading the removed hooks and MCP server.'
|
|
3424
|
+
: removedEntries > 0 || runtime.would_remove
|
|
3425
|
+
? 'Nothing was changed. Run npx @getmarrow/install uninstall --yes to remove these Marrow entries.'
|
|
3426
|
+
: 'Nothing to remove.',
|
|
3427
|
+
},
|
|
3428
|
+
};
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
function printUninstallReport(report, sink = (text) => process.stdout.write(text)) {
|
|
3432
|
+
const result = report.uninstall;
|
|
3433
|
+
const where = (filePath) => (filePath.startsWith(`${result.root}${path.sep}`) ? path.relative(result.root, filePath) : filePath);
|
|
3434
|
+
const entries = (count) => `${count} Marrow entr${count === 1 ? 'y' : 'ies'}`;
|
|
3435
|
+
sink(`Marrow uninstall${result.applied ? '' : ' (preview)'}: ${entries(result.removed_entries)} ${result.applied ? 'removed' : 'to remove'}.\n`);
|
|
3436
|
+
for (const change of result.changes) {
|
|
3437
|
+
if (change.action === 'update' && change.note) sink(`- ${where(change.path)}: ${result.applied ? 'removed' : 'would remove'} the Marrow block; your text is kept.\n`);
|
|
3438
|
+
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`);
|
|
3439
|
+
else if (change.action === 'delete_file') sink(`- ${where(change.path)}: ${result.applied ? 'deleted' : 'would delete'} (${change.note || 'Marrow\'s own file'}).\n`);
|
|
3440
|
+
else if (change.action === 'kept' || change.action === 'skipped') sink(`- ${where(change.path)}: left unchanged (${change.reason}).\n`);
|
|
3441
|
+
}
|
|
3442
|
+
for (const entry of result.kept) sink(`- ${where(entry.path)}: left in place; ${entry.reason}.\n`);
|
|
3443
|
+
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`);
|
|
3444
|
+
if (result.controller?.stopped) sink(`Stopped ${result.controller.stopped} Marrow controller${result.controller.stopped === 1 ? '' : 's'} for this project.\n`);
|
|
3445
|
+
sink(`${result.next_step}\n`);
|
|
3446
|
+
}
|
|
3447
|
+
|
|
2806
3448
|
const SELF_TEST_READ_TIMEOUT_MS = 15_000;
|
|
2807
3449
|
|
|
2808
3450
|
async function requestJson(url, options) {
|
|
@@ -2976,7 +3618,15 @@ function runtimeGateVerified(runtime) {
|
|
|
2976
3618
|
return ['allow', 'warn', 'review_required', 'block'].includes(decision);
|
|
2977
3619
|
}
|
|
2978
3620
|
|
|
2979
|
-
|
|
3621
|
+
// When Marrow refuses this machine's agent identity, the one command that fixes it. A key that
|
|
3622
|
+
// is simply not bound to one agent needs nothing: Marrow resolves the agent on each call.
|
|
3623
|
+
const IDENTITY_REFUSED_RE = /AGENT_NOT_REGISTERED|wrong_agent_id|MARROW_AGENT_SCOPE_MISMATCH|Agent-bound key cannot access another agent/i;
|
|
3624
|
+
function identityRefusalFix(message, configuredAgentId = '') {
|
|
3625
|
+
if (!IDENTITY_REFUSED_RE.test(String(message || ''))) return null;
|
|
3626
|
+
return configuredAgentId
|
|
3627
|
+
? 'unset MARROW_AGENT_ID MARROW_FLEET_AGENT_ID && npx -y @getmarrow/install@latest activate'
|
|
3628
|
+
: 'npx -y @getmarrow/install@latest activate (with a key for one agent from your Marrow account saved in ~/.marrow/env, or exported as MARROW_API_KEY)';
|
|
3629
|
+
}
|
|
2980
3630
|
|
|
2981
3631
|
// The agent Marrow resolved for this key: its single bound agent or the Free plan seat. An
|
|
2982
3632
|
// unbound key has no single agent, and a locally derived id is never substituted for it.
|
|
@@ -3038,7 +3688,7 @@ async function runSelfTest(options) {
|
|
|
3038
3688
|
return {
|
|
3039
3689
|
skipped: true,
|
|
3040
3690
|
reason: 'missing MARROW_API_KEY',
|
|
3041
|
-
exact_fix: '
|
|
3691
|
+
exact_fix: 'Create a key at https://getmarrow.ai/signup, save it as MARROW_API_KEY=your_key in the owner-only ~/.marrow/env, then run chmod 700 ~/.marrow && chmod 600 ~/.marrow/env, then run npx -y @getmarrow/install@latest activate',
|
|
3042
3692
|
mcp_tool_profile: initialProfile,
|
|
3043
3693
|
};
|
|
3044
3694
|
}
|
|
@@ -3224,7 +3874,11 @@ async function runSelfTest(options) {
|
|
|
3224
3874
|
method: 'POST',
|
|
3225
3875
|
headers,
|
|
3226
3876
|
body: JSON.stringify({
|
|
3227
|
-
|
|
3877
|
+
// One registration per agent: two agents with the same hook configuration (a second
|
|
3878
|
+
// agent installed on the same project) are two events, so the second is not refused
|
|
3879
|
+
// as a conflicting reuse of the first agent's event id. Reruns by one agent repeat
|
|
3880
|
+
// the same id.
|
|
3881
|
+
event_id: `activation-${crypto.createHash('sha256').update(`${activationConfigFingerprint}\0${activationAgentId}`).digest('hex').slice(0, 32)}`,
|
|
3228
3882
|
event_type: 'activation_profile_registered',
|
|
3229
3883
|
harness: options.activation.harness,
|
|
3230
3884
|
agent_id: activationAgentId,
|
|
@@ -3291,7 +3945,8 @@ async function runSelfTest(options) {
|
|
|
3291
3945
|
bound_agent_ids: serverIdentity.bound_agent_ids,
|
|
3292
3946
|
activation_verified: activationVerified,
|
|
3293
3947
|
activation_identity_unresolved: activationIdentityUnresolved,
|
|
3294
|
-
|
|
3948
|
+
// An unbound key is fine: Marrow resolves the agent on each call, so there is nothing to fix.
|
|
3949
|
+
activation_exact_fix: null,
|
|
3295
3950
|
activation_scope: options.activation ? 'server_self_test_only' : null,
|
|
3296
3951
|
coverage_verified: false,
|
|
3297
3952
|
passive_live: false,
|
|
@@ -3486,6 +4141,16 @@ function printReport(report, sink) {
|
|
|
3486
4141
|
}
|
|
3487
4142
|
}
|
|
3488
4143
|
|
|
4144
|
+
if (report.mcp_runtime) {
|
|
4145
|
+
const runtime = report.mcp_runtime;
|
|
4146
|
+
out('\nMCP local runtime:\n');
|
|
4147
|
+
out(`- hooks start: ${runtime.hooks_start === 'local_runtime' ? `the verified local copy of @getmarrow/mcp@${runtime.version} (${runtime.path})` : 'through npx'}\n`);
|
|
4148
|
+
out(`- state: ${runtime.state}${runtime.reason ? ` (${runtime.reason})` : ''}\n`);
|
|
4149
|
+
if (runtime.removed_versions?.length) out(`- removed older versions: ${runtime.removed_versions.join(', ')}\n`);
|
|
4150
|
+
}
|
|
4151
|
+
if (report.owner_key_storage) {
|
|
4152
|
+
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`);
|
|
4153
|
+
}
|
|
3489
4154
|
out('\nLocal session loop guard:\n');
|
|
3490
4155
|
out(`- configured: ${report.loop_guard_configured ? 'yes' : 'no'}\n`);
|
|
3491
4156
|
out(`- isolated self-test passed: ${report.loop_guard_self_tested ? 'yes' : 'no'}\n`);
|
|
@@ -3744,6 +4409,62 @@ function hermesWiringReport(detection, changes, options, planMode) {
|
|
|
3744
4409
|
};
|
|
3745
4410
|
}
|
|
3746
4411
|
|
|
4412
|
+
// The local MCP runtime for the target version: installed (or kept) on a write run of the
|
|
4413
|
+
// command, otherwise only an existing verified copy is used. `mcpLocalRuntime: false` (or
|
|
4414
|
+
// MARROW_LOCAL_RUNTIME=0, --no-local-runtime) leaves every hook of this project on npx.
|
|
4415
|
+
function resolveMcpRuntime(detection, options, mcpTarget) {
|
|
4416
|
+
const home = options.home || detection.home;
|
|
4417
|
+
if (options.mcpLocalRuntime === false) return { state: 'disabled' };
|
|
4418
|
+
const version = mcpTarget.version;
|
|
4419
|
+
const integrity = mcpTarget.integrity || (version === MCP_ADAPTER_VERSION ? MCP_ADAPTER_INTEGRITY : null);
|
|
4420
|
+
if (!integrity) return { state: 'skipped', reason: 'no_verified_integrity', version };
|
|
4421
|
+
const install = options.mcpLocalRuntime === true && options.yes && !options.dryRun && !options.doctor && options.maintenance !== true;
|
|
4422
|
+
if (install) {
|
|
4423
|
+
return ensureMcpRuntime({
|
|
4424
|
+
home,
|
|
4425
|
+
version,
|
|
4426
|
+
integrity,
|
|
4427
|
+
sdk: { version: SDK_ADAPTER_VERSION, integrity: SDK_ADAPTER_INTEGRITY },
|
|
4428
|
+
...(typeof options.mcpRuntimeInstall === 'function' ? { npmInstall: options.mcpRuntimeInstall } : {}),
|
|
4429
|
+
...(options.mcpRuntimeNode ? { nodePath: options.mcpRuntimeNode } : {}),
|
|
4430
|
+
// One line on stderr before npm runs, so the wait is never silent (stdout stays clean
|
|
4431
|
+
// for --json).
|
|
4432
|
+
onProgress: typeof options.mcpRuntimeProgress === 'function' ? options.mcpRuntimeProgress : (text) => process.stderr.write(text),
|
|
4433
|
+
});
|
|
4434
|
+
}
|
|
4435
|
+
return verifyMcpRuntime(home, version, integrity) || { state: 'absent', version };
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
// Whether this project's managed hooks start the local runtime now, as its install chose.
|
|
4439
|
+
function projectHooksUseLocalRuntime(detection) {
|
|
4440
|
+
const paths = detection.paths || {};
|
|
4441
|
+
return [paths.claudeSettings, paths.codexHooks, paths.cursorHooks, paths.windsurfHooks, paths.geminiSettings]
|
|
4442
|
+
.some((filePath) => filePath && safeRead(filePath).includes('/.marrow/runtime/mcp/'));
|
|
4443
|
+
}
|
|
4444
|
+
|
|
4445
|
+
// The controller's maintenance rewrites managed hooks for the pinned MCP and keeps each project
|
|
4446
|
+
// in the form its install chose: on the verified local runtime for that version when the
|
|
4447
|
+
// project's hooks start it (verify only: maintenance never installs), on npx otherwise. A
|
|
4448
|
+
// project installed with --no-local-runtime stays on npx even when another project's install
|
|
4449
|
+
// put a runtime on this machine.
|
|
4450
|
+
function maintenanceMcpRuntime(detection, options = {}) {
|
|
4451
|
+
if (options.mcpLocalRuntime === false || !projectHooksUseLocalRuntime(detection)) return null;
|
|
4452
|
+
const runtime = resolveMcpRuntime(detection, { ...options, maintenance: true, yes: false }, executableMcpTarget(options));
|
|
4453
|
+
return runtime.version && ['present', 'installed', 'verified'].includes(runtime.state) ? runtime : null;
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4456
|
+
function mcpRuntimeReport(runtime) {
|
|
4457
|
+
if (!runtime) return null;
|
|
4458
|
+
return {
|
|
4459
|
+
state: runtime.state,
|
|
4460
|
+
version: runtime.version || null,
|
|
4461
|
+
path: runtime.directory || null,
|
|
4462
|
+
...(runtime.reason ? { reason: runtime.reason } : {}),
|
|
4463
|
+
...(runtime.removed_versions?.length ? { removed_versions: runtime.removed_versions } : {}),
|
|
4464
|
+
hooks_start: runtime.directory && ['present', 'installed', 'verified'].includes(runtime.state) ? 'local_runtime' : 'npx',
|
|
4465
|
+
};
|
|
4466
|
+
}
|
|
4467
|
+
|
|
3747
4468
|
function localControllerAgentId(root, client, configuredAgentId = '') {
|
|
3748
4469
|
return String(configuredAgentId || '').trim() || stableAgentId(root, client);
|
|
3749
4470
|
}
|
|
@@ -3790,6 +4511,11 @@ async function install(options) {
|
|
|
3790
4511
|
});
|
|
3791
4512
|
options.mcpTarget = mcpTarget;
|
|
3792
4513
|
options.mcpTargetVersion = mcpTarget.version;
|
|
4514
|
+
// A maintenance pass keeps the project's hooks in the form its install chose.
|
|
4515
|
+
const mcpRuntime = options.maintenance === true
|
|
4516
|
+
? maintenanceMcpRuntime(detection, options) || { state: options.mcpLocalRuntime === false ? 'disabled' : 'absent', version: mcpTarget.version }
|
|
4517
|
+
: resolveMcpRuntime(detection, options, mcpTarget);
|
|
4518
|
+
options.mcpRuntime = mcpRuntime.version && ['present', 'installed', 'verified'].includes(mcpRuntime.state) ? mcpRuntime : null;
|
|
3793
4519
|
const plan = buildPlan(detection, options);
|
|
3794
4520
|
const writeMode = options.doctor ? 'doctor' : options.dryRun ? 'dry-run' : options.repair ? 'repair' : options.yes ? 'write' : 'dry-run';
|
|
3795
4521
|
const changes = applyPlan(plan, options);
|
|
@@ -3839,7 +4565,7 @@ async function install(options) {
|
|
|
3839
4565
|
intervention_verified: false,
|
|
3840
4566
|
closure_verified: false,
|
|
3841
4567
|
} : null;
|
|
3842
|
-
const sdkDependency = inspectSdkDependency(detection);
|
|
4568
|
+
const sdkDependency = inspectSdkDependency(detection, { sdkMode: plan.mode === 'sdk' || plan.mode === 'both' });
|
|
3843
4569
|
const envHints = options.apiKey ? [] : findLikelyEnvFiles(detection);
|
|
3844
4570
|
const mcpProcesses = inspectMcpProcesses({
|
|
3845
4571
|
commands: options.processCommands,
|
|
@@ -4049,6 +4775,7 @@ async function install(options) {
|
|
|
4049
4775
|
harnessReload,
|
|
4050
4776
|
firstCapture: firstCapturePath(detection),
|
|
4051
4777
|
hermes,
|
|
4778
|
+
mcp_runtime: mcpRuntimeReport(mcpRuntime),
|
|
4052
4779
|
changes,
|
|
4053
4780
|
doctor: {
|
|
4054
4781
|
active: Boolean(!selfTest.skipped && selfTest.active),
|
|
@@ -4099,36 +4826,48 @@ async function install(options) {
|
|
|
4099
4826
|
};
|
|
4100
4827
|
}
|
|
4101
4828
|
|
|
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;
|
|
4829
|
+
function activationFailureFix(message, configuredAgentId = '') {
|
|
4830
|
+
return identityRefusalFix(message, configuredAgentId) || INSTALLER_DOCTOR_COMMAND;
|
|
4107
4831
|
}
|
|
4108
4832
|
|
|
4109
4833
|
function installCommandFor(options) {
|
|
4110
|
-
return options.update ? INSTALLER_UPDATE_COMMAND : 'npx -y @getmarrow/install@latest';
|
|
4834
|
+
return options.update ? INSTALLER_UPDATE_COMMAND : 'npx -y @getmarrow/install@latest activate';
|
|
4835
|
+
}
|
|
4836
|
+
|
|
4837
|
+
// Static text: it never echoes a key, a command line or a path. The stored-file form comes first
|
|
4838
|
+
// because a key typed on a command line lands in shell history.
|
|
4839
|
+
function missingKeyMessage(options) {
|
|
4840
|
+
const command = installCommandFor(options);
|
|
4841
|
+
return `Marrow needs your API key. Nothing was written. Create a key at https://getmarrow.ai/signup, save it as MARROW_API_KEY=your_key in the owner-only file ~/.marrow/env, then run chmod 700 ~/.marrow && chmod 600 ~/.marrow/env, then run: ${command} (fallback: MARROW_API_KEY=your_key ${command})`;
|
|
4111
4842
|
}
|
|
4112
4843
|
|
|
4113
4844
|
// One summary for the one-command paths (first install and update). The full report goes to a
|
|
4114
4845
|
// private log file. Nothing here prints the API key.
|
|
4846
|
+
// One verdict for the self-test, used by the summary and by key storage: it passed only when it
|
|
4847
|
+
// ran, raised no error, found the account active and Marrow committed the closure.
|
|
4848
|
+
function selfTestVerdict(selfTest = {}) {
|
|
4849
|
+
const untrusted = !selfTest.skipped && !selfTest.error && Boolean(selfTest.active) && selfTest.decision_committed !== true;
|
|
4850
|
+
const failed = !selfTest.skipped && (Boolean(selfTest.error) || !selfTest.active || untrusted);
|
|
4851
|
+
return { untrusted, failed, passed: !selfTest.skipped && !failed };
|
|
4852
|
+
}
|
|
4853
|
+
|
|
4115
4854
|
function installSummaryLines(report, options) {
|
|
4116
4855
|
const selfTest = report.selfTest || {};
|
|
4117
4856
|
const activation = report.activation || {};
|
|
4118
4857
|
const lines = [];
|
|
4119
|
-
const untrusted
|
|
4120
|
-
const selfTestFailed = !selfTest.skipped && (Boolean(selfTest.error) || !selfTest.active || untrusted);
|
|
4858
|
+
const { untrusted, failed: selfTestFailed } = selfTestVerdict(selfTest);
|
|
4121
4859
|
if (selfTest.skipped) {
|
|
4122
4860
|
lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: configuration updated; self-test skipped (${selfTest.reason}).${selfTest.exact_fix ? ` Fix: ${selfTest.exact_fix}` : ''}`);
|
|
4123
4861
|
} else if (untrusted) {
|
|
4124
4862
|
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
4863
|
} else if (selfTestFailed) {
|
|
4126
|
-
const fix =
|
|
4864
|
+
const fix = identityRefusalFix(selfTest.error, activation.agent_id_source === 'configured' ? activation.agent_id : '')
|
|
4865
|
+
|| report.doctor?.recommendedFix || INSTALLER_DOCTOR_COMMAND;
|
|
4127
4866
|
lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: self-test failed: ${selfTest.error || 'Marrow reported this account as inactive'}. Fix: ${fix}`);
|
|
4128
4867
|
} else {
|
|
4129
4868
|
const agent = activation.agent_id
|
|
4130
4869
|
? `; agent ${activation.agent_id} (${activation.agent_id_source === 'configured' ? 'configured' : 'resolved by Marrow'})`
|
|
4131
|
-
: '
|
|
4870
|
+
: '';
|
|
4132
4871
|
lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: healthy. Self-test decision ${selfTest.decision_id} committed${agent}.`);
|
|
4133
4872
|
}
|
|
4134
4873
|
if (activation.exact_fix) lines.push(`Activation: ${activation.exact_fix}`);
|
|
@@ -4155,6 +4894,14 @@ function installSummaryLines(report, options) {
|
|
|
4155
4894
|
} else if (hermes.state === 'refused' || hermes.state === 'config_not_found' || hermes.state === 'preserved_unverified_ahead') {
|
|
4156
4895
|
lines.push(`Hermes: ${hermes.config_path || 'config.yaml'} was not changed. The exact block to add is in the full report.`);
|
|
4157
4896
|
}
|
|
4897
|
+
const ownerKey = report.owner_key_storage || {};
|
|
4898
|
+
if (ownerKey.state === 'written') {
|
|
4899
|
+
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.`);
|
|
4900
|
+
} else if (ownerKey.state === 'different_key_present') {
|
|
4901
|
+
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.`);
|
|
4902
|
+
} else if (ownerKey.state && !['present', 'no_key'].includes(ownerKey.state)) {
|
|
4903
|
+
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).`);
|
|
4904
|
+
}
|
|
4158
4905
|
if (hermes.owner_key_storage?.state === 'written') {
|
|
4159
4906
|
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
4907
|
} else if (hermes.owner_key_storage?.state === 'different_key_present') {
|
|
@@ -4190,7 +4937,16 @@ async function runCli(argv) {
|
|
|
4190
4937
|
process.stdout.write(usage());
|
|
4191
4938
|
return;
|
|
4192
4939
|
}
|
|
4940
|
+
if (options.uninstall) {
|
|
4941
|
+
const report = await uninstall(options);
|
|
4942
|
+
if (options.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
4943
|
+
else printUninstallReport(report);
|
|
4944
|
+
return;
|
|
4945
|
+
}
|
|
4946
|
+
// An empty or whitespace-only key from any source (environment, --key, owner file) is missing.
|
|
4947
|
+
options.apiKey = String(options.apiKey || '').trim();
|
|
4193
4948
|
const stored = readOwnerApiKey(options.home);
|
|
4949
|
+
stored.apiKey = String(stored.apiKey || '').trim();
|
|
4194
4950
|
const keyInfo = { source: options.apiKey ? 'environment' : null, path: null, owner_files_disagree: stored.conflict };
|
|
4195
4951
|
if (!options.apiKey && stored.apiKey) {
|
|
4196
4952
|
options.apiKey = stored.apiKey;
|
|
@@ -4198,7 +4954,11 @@ async function runCli(argv) {
|
|
|
4198
4954
|
keyInfo.path = stored.source;
|
|
4199
4955
|
}
|
|
4200
4956
|
const oneCommand = Boolean((options.activate || options.update) && !options.dryRun && !options.doctor);
|
|
4201
|
-
|
|
4957
|
+
// Any write run without a key stops here, before anything is written: hooks installed with no
|
|
4958
|
+
// key would deny protected actions on the next harness start. Previews, doctor and uninstall
|
|
4959
|
+
// are not write runs.
|
|
4960
|
+
const writeRun = Boolean(options.yes && !options.dryRun && !options.doctor);
|
|
4961
|
+
if ((oneCommand || writeRun) && !options.apiKey) {
|
|
4202
4962
|
// An update aimed at an unmanaged directory is refused first; it needs no key to answer.
|
|
4203
4963
|
if (options.update) {
|
|
4204
4964
|
assertUpdateTargetsManagedRoot(detectEnvironment(options.cwd, {
|
|
@@ -4207,7 +4967,7 @@ async function runCli(argv) {
|
|
|
4207
4967
|
USERPROFILE: options.home || options.cwd,
|
|
4208
4968
|
}));
|
|
4209
4969
|
}
|
|
4210
|
-
process.stdout.write(
|
|
4970
|
+
process.stdout.write(`${missingKeyMessage(options)}\n`);
|
|
4211
4971
|
process.exitCode = 2;
|
|
4212
4972
|
return;
|
|
4213
4973
|
}
|
|
@@ -4217,10 +4977,23 @@ async function runCli(argv) {
|
|
|
4217
4977
|
} catch (error) {
|
|
4218
4978
|
const message = error instanceof Error ? error.message : String(error);
|
|
4219
4979
|
if (!oneCommand || options.json || !/^Marrow activation failed/.test(message)) throw error;
|
|
4220
|
-
process.stderr.write(`Marrow ${INSTALLER_ADAPTER_VERSION}: ${message}. Fix: ${activationFailureFix(message)}\n`);
|
|
4980
|
+
process.stderr.write(`Marrow ${INSTALLER_ADAPTER_VERSION}: ${message}. Fix: ${activationFailureFix(message, String(options.agentId || '').trim())}\n`);
|
|
4221
4981
|
process.exitCode = 1;
|
|
4222
4982
|
return;
|
|
4223
4983
|
}
|
|
4984
|
+
// Claude Code opened from the desktop app, an IDE or a new terminal has no MARROW_API_KEY.
|
|
4985
|
+
// The MCP server and hooks read ~/.marrow/env, so a first install stores a key that came
|
|
4986
|
+
// from the environment there (owner-only), once the self-test passed by the same verdict the
|
|
4987
|
+
// summary prints (never on an untrusted closure), with --json and --verbose too.
|
|
4988
|
+
const keyWorked = report.selfTest ? selfTestVerdict(report.selfTest).passed : false;
|
|
4989
|
+
if (options.activate && !options.update && keyInfo.source === 'environment' && !options.keyFromArg && keyWorked) {
|
|
4990
|
+
try {
|
|
4991
|
+
const stored = ensureOwnerApiKey(options.home, options.apiKey);
|
|
4992
|
+
report.owner_key_storage = { state: stored.state, path: stored.path };
|
|
4993
|
+
} catch {
|
|
4994
|
+
report.owner_key_storage = { state: 'not_written', path: null };
|
|
4995
|
+
}
|
|
4996
|
+
}
|
|
4224
4997
|
if (options.json) {
|
|
4225
4998
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
4226
4999
|
return;
|
|
@@ -4246,6 +5019,8 @@ async function runCli(argv) {
|
|
|
4246
5019
|
}
|
|
4247
5020
|
|
|
4248
5021
|
module.exports = {
|
|
5022
|
+
argumentLabel,
|
|
5023
|
+
maintenanceMcpRuntime,
|
|
4249
5024
|
parseArgs,
|
|
4250
5025
|
detectEnvironment,
|
|
4251
5026
|
buildPlan,
|
|
@@ -4267,6 +5042,13 @@ module.exports = {
|
|
|
4267
5042
|
activationProfile,
|
|
4268
5043
|
claudeNativeHookFingerprint,
|
|
4269
5044
|
codexNativeHookFingerprint,
|
|
5045
|
+
hostApprovalHooksSupported,
|
|
5046
|
+
uninstall,
|
|
5047
|
+
printUninstallReport,
|
|
5048
|
+
CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER,
|
|
5049
|
+
GEMINI_PRE_ACTION_GUARD_COMMAND,
|
|
5050
|
+
GEMINI_CONTEXT_HOOK_COMMAND,
|
|
5051
|
+
MCP_PERMISSION_REQUEST_HOOK_COMMAND,
|
|
4270
5052
|
cursorNativeHookFingerprint,
|
|
4271
5053
|
clineNativeHookFingerprint,
|
|
4272
5054
|
windsurfNativeHookFingerprint,
|