@getmarrow/install 0.1.68 → 0.1.70

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/src/installer.js CHANGED
@@ -20,11 +20,18 @@ const {
20
20
  planHermesMcpConfig,
21
21
  } = require('./hermes-config');
22
22
  const { ensureOwnerApiKey, readOwnerApiKey } = require('./owner-env');
23
+ const { buildOwnToolMatchers, cursorOwnToolGuardCondition } = require('./marrow-own-tools');
23
24
  const {
24
25
  delocalizeHookCommand,
26
+ delocalizeHookScriptText,
25
27
  ensureMcpRuntime,
28
+ hookTextUsesNpx,
29
+ localizeHookScriptText,
26
30
  localizeHookSettingsText,
27
31
  mapHookCommands,
32
+ mcpServerLaunch,
33
+ mcpServerLaunchVersion,
34
+ mcpServerUsesNpx,
28
35
  removeMcpRuntime,
29
36
  verifyMcpRuntime,
30
37
  } = require('./mcp-runtime');
@@ -231,7 +238,8 @@ function adapterProvenanceForMcpTarget(target) {
231
238
 
232
239
  function retargetMcpPackageSpec(value, version) {
233
240
  const target = compatibleMcpTargetVersion(version) ? version : MCP_ADAPTER_VERSION;
234
- return String(value).split(MCP_PACKAGE_SPEC).join(`@getmarrow/mcp@${target}`);
241
+ return String(value).split(MCP_PACKAGE_SPEC).join(`@getmarrow/mcp@${target}`)
242
+ .split(`/.marrow/runtime/mcp/${MCP_ADAPTER_VERSION}/`).join(`/.marrow/runtime/mcp/${target}/`);
235
243
  }
236
244
 
237
245
  function retargetMcpDowngradeRecommendation(value, targetVersion) {
@@ -280,8 +288,8 @@ const CURSOR_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC}
280
288
  const CURSOR_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cursor-session-hook`;
281
289
  const CURSOR_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cursor-context-hook`;
282
290
  // 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
291
+ // Marrow's own tools (server exactly "marrow", tool one of the registered names in
292
+ // marrow-own-tools.js; this event names the server) without starting npx. Every other call goes to the MCP entrypoint with its input
285
293
  // unchanged; a crash, a non-zero exit or output that is not a JSON object blocks (exit 2).
286
294
  const CURSOR_MCP_GUARD_FAILURE = 'Marrow governance adapter was unavailable; this MCP call is blocked.';
287
295
  const CURSOR_MCP_PRE_ACTION_GUARD_SOURCE = [
@@ -291,7 +299,7 @@ const CURSOR_MCP_PRE_ACTION_GUARD_SOURCE = [
291
299
  'process.stdin.on("error",fail);',
292
300
  'process.stdin.on("data",c=>{size+=c.length;if(size>16777216){over=true;return;}chunks.push(c);});',
293
301
  '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;}',
302
+ `if(ev&&typeof ev==="object"&&${cursorOwnToolGuardCondition()}){done=true;process.stdout.write("{\\"permission\\":\\"allow\\"}\\n");return;}`,
295
303
  'let child;try{',
296
304
  `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
305
  '}catch{fail();return;}',
@@ -387,21 +395,22 @@ const GROK_PRE_ACTION_GUARD_SOURCE = [
387
395
  'child.stdin.end(Buffer.concat(input));}catch{fail();}});',
388
396
  ].join('');
389
397
  const GROK_PRE_ACTION_HOOK_COMMAND = `node -e '${GROK_PRE_ACTION_GUARD_SOURCE}'`;
390
- const NATIVE_HOOK_MATCHER = 'Bash|Edit|Write|MultiEdit|Read|Glob|Grep|Search|WebSearch|Task|functions\\.(?!mcp__marrow__marrow_).*|mcp__(?!marrow__marrow_).*';
391
- const CODEX_NATIVE_HOOK_MATCHER = 'Bash|apply_patch|Edit|Write|MultiEdit|mcp__(?!marrow__marrow_).*|functions\\.(?!marrow_).*';
392
- const CURSOR_NATIVE_HOOK_MATCHER = 'Shell|Write|Delete|Task|Read|Glob|Grep|Search|WebSearch|List|MCP:(?!marrow(?:_.*|:marrow_.*)$).*';
398
+ const OWN_TOOL_MATCHERS = buildOwnToolMatchers();
399
+ const NATIVE_HOOK_MATCHER = OWN_TOOL_MATCHERS.claude;
400
+ const CODEX_NATIVE_HOOK_MATCHER = OWN_TOOL_MATCHERS.codex;
401
+ const CURSOR_NATIVE_HOOK_MATCHER = OWN_TOOL_MATCHERS.cursor;
393
402
  // Host approvals: Cursor enforces an "ask" only on beforeShellExecution and beforeMCPExecution,
394
403
  // never on preToolUse. Shell calls move to beforeShellExecution (it runs in cloud agents too).
395
404
  // MCP calls stay in preToolUse as well, because cloud agents never run beforeMCPExecution: the
396
405
  // MCP hook defers there to beforeMCPExecution only in a local interactive session (sessionStart
397
406
  // 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_.*)$).*';
407
+ const CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER = OWN_TOOL_MATCHERS.cursorPreToolUseHostApproval;
399
408
  // Cursor's gating hooks are failClosed: a cold npx start on a slow network must not block a
400
409
  // call, so they get room beyond the MCP hook's own 4-second budget.
401
410
  const CURSOR_GATE_HOOK_TIMEOUT_SECONDS = 15;
402
411
  const CURSOR_EXECUTION_PRE_EVENTS = Object.freeze(['beforeShellExecution', 'beforeMCPExecution']);
403
412
  const CURSOR_EXECUTION_POST_EVENTS = Object.freeze(['afterShellExecution', 'afterMCPExecution']);
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})$';
413
+ const GEMINI_NATIVE_HOOK_MATCHER = OWN_TOOL_MATCHERS.gemini;
405
414
  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';
406
415
  const CODEX_HOOK_TIMEOUT_SECONDS = 5;
407
416
  const CODEX_SESSION_TIMEOUT_SECONDS = 3;
@@ -644,6 +653,9 @@ function isMcpProcessCommand(command) {
644
653
  const executable = path.basename(args[0]);
645
654
  if (new Set(['bash', 'bwrap', 'dash', 'fish', 'sh', 'zsh']).has(executable)) return false;
646
655
  if (executable === 'marrow-mcp') return true;
656
+ // The verified local runtime: node on the inline loader with the pinned package spec.
657
+ if (executable === 'node' && args[1] === '-e' && args.includes('marrow-mcp')
658
+ && args.some((arg) => /^--package=@getmarrow\/mcp@\d+\.\d+\.\d+$/.test(arg))) return true;
647
659
 
648
660
  if (executable === 'node'
649
661
  && args[1]
@@ -839,6 +851,9 @@ function argumentLabel(arg) {
839
851
  return /^[a-z][a-z-]{0,23}$/.test(text) ? text : '(a value that is not an option; not shown)';
840
852
  }
841
853
 
854
+ // Operation words parseArgs accepts. A --key value equal to one of these is a missing key.
855
+ const SUBCOMMAND_WORDS = new Set(['activate', 'update', 'repair', 'uninstall', 'doctor', 'check']);
856
+
842
857
  function parseArgs(argv, env = process.env) {
843
858
  const options = {
844
859
  cwd: process.cwd(),
@@ -848,7 +863,8 @@ function parseArgs(argv, env = process.env) {
848
863
  doctor: false,
849
864
  repair: false,
850
865
  mode: 'auto',
851
- apiKey: env.MARROW_API_KEY || '',
866
+ // MARROW_KEY is accepted when MARROW_API_KEY is absent or blank, as the runner and owner files do.
867
+ apiKey: String(env.MARROW_API_KEY || '').trim() || String(env.MARROW_KEY || '').trim(),
852
868
  baseUrl: env.MARROW_BASE_URL || DEFAULT_BASE_URL,
853
869
  agentId: env.MARROW_FLEET_AGENT_ID || env.MARROW_AGENT_ID || '',
854
870
  toolProfile: resolveToolProfile(env.MARROW_TOOL_PROFILE),
@@ -858,13 +874,26 @@ function parseArgs(argv, env = process.env) {
858
874
  json: false,
859
875
  activate: false,
860
876
  controller: true,
861
- // Hooks start a verified local copy of the pinned MCP (installed on write runs) instead of
862
- // npx. MARROW_LOCAL_RUNTIME=0 or --no-local-runtime keeps them on npx.
863
- mcpLocalRuntime: env.MARROW_LOCAL_RUNTIME !== '0',
877
+ // Hooks start only the verified local copy of the pinned MCP (installed on write runs); they
878
+ // never start npx, which resolves the package from the project directory first. The old
879
+ // opt-out (MARROW_LOCAL_RUNTIME=0, --no-local-runtime) is accepted and ignored.
880
+ mcpLocalRuntime: true,
881
+ ...(env.MARROW_LOCAL_RUNTIME === '0' ? { localRuntimeSwitchIgnored: true } : {}),
864
882
  };
865
883
  let explicitOperation = false;
866
884
 
867
- for (let i = 0; i < argv.length; i += 1) {
885
+ // Value of the option at argv[i]. Only consumes the next argument when it exists and is not an
886
+ // option itself, so a missing value never swallows a following flag.
887
+ let i = 0;
888
+ const optionValue = (refuseWords = false) => {
889
+ const next = argv[i + 1];
890
+ if (next === undefined || String(next).startsWith('-')) return '';
891
+ // `--key $UNSET activate` must not take the subcommand word as the key.
892
+ if (refuseWords && SUBCOMMAND_WORDS.has(String(next))) return '';
893
+ i += 1;
894
+ return next;
895
+ };
896
+ for (; i < argv.length; i += 1) {
868
897
  const arg = argv[i];
869
898
  if (arg === 'activate' || arg === '--activate') {
870
899
  explicitOperation = true;
@@ -890,6 +919,9 @@ function parseArgs(argv, env = process.env) {
890
919
  explicitOperation = true;
891
920
  options.uninstall = true;
892
921
  }
922
+ else if (arg === '--all') {
923
+ options.all = true;
924
+ }
893
925
  else if (arg === '--doctor' || arg === 'doctor' || arg === 'check') {
894
926
  explicitOperation = true;
895
927
  options.doctor = true;
@@ -902,19 +934,20 @@ function parseArgs(argv, env = process.env) {
902
934
  options.selfTestExplicitlyDisabled = true;
903
935
  }
904
936
  else if (arg === '--no-controller') options.controller = false;
905
- else if (arg === '--no-local-runtime') options.mcpLocalRuntime = false;
937
+ else if (arg === '--no-local-runtime') options.localRuntimeSwitchIgnored = true;
906
938
  else if (arg === '--self-test') options.selfTest = true;
907
- else if (arg === '--cwd') options.cwd = path.resolve(argv[++i] || options.cwd);
939
+ else if (arg === '--cwd') options.cwd = path.resolve(optionValue() || options.cwd);
908
940
  else if (arg === '--mode') {
909
941
  explicitOperation = true;
910
- options.mode = argv[++i] || options.mode;
942
+ options.mode = optionValue() || options.mode;
911
943
  }
912
944
  else if (arg === '--key') {
913
- options.apiKey = argv[++i] || options.apiKey;
945
+ // A missing value (end of argv, or another option next) is a missing key, never a key.
946
+ options.apiKey = optionValue(true) || options.apiKey;
914
947
  options.keyFromArg = true;
915
948
  }
916
- else if (arg === '--base-url') options.baseUrl = argv[++i] || options.baseUrl;
917
- else if (arg === '--agent-id') options.agentId = argv[++i] || options.agentId;
949
+ else if (arg === '--base-url') options.baseUrl = optionValue() || options.baseUrl;
950
+ else if (arg === '--agent-id') options.agentId = optionValue() || options.agentId;
918
951
  else if (arg === '--mcp') {
919
952
  explicitOperation = true;
920
953
  options.mode = 'mcp';
@@ -938,6 +971,9 @@ function parseArgs(argv, env = process.env) {
938
971
  }
939
972
  }
940
973
 
974
+ if (options.all && !options.uninstall) {
975
+ throw new Error('--all is only valid with uninstall');
976
+ }
941
977
  if (options.uninstall && (options.activate || options.repair || options.doctor)) {
942
978
  throw new Error('uninstall cannot be combined with activate, update, repair, or doctor');
943
979
  }
@@ -970,31 +1006,34 @@ function usage() {
970
1006
  npx @getmarrow/install --repair
971
1007
  npx @getmarrow/install update
972
1008
  npx @getmarrow/install doctor
1009
+ npx @getmarrow/install doctor --self-test
973
1010
  npx @getmarrow/install --mcp --yes
974
1011
  npx @getmarrow/install --sdk --yes
975
- npx @getmarrow/install uninstall [--yes]
1012
+ npx @getmarrow/install uninstall [--yes] [--all]
976
1013
 
977
1014
  Options:
978
1015
  (no command) Detect, install, self-test, and start the supported persistent controller
979
1016
  activate Detect, install, self-test, and return a server-confirmed activation receipt
980
1017
  --dry-run Print planned changes without writing
981
1018
  --doctor Check install health without writing
1019
+ doctor --self-test Same as doctor, which already runs the server self-test: run it after restarting the harness
982
1020
  --repair Write missing hooks/config, then run self-test and status check
983
1021
  update Same as --repair: refresh exact MCP/SDK/install package pins after owner approval
984
- --yes, -y Write detected config files
1022
+ --yes, -y Write detected config files (needs your API key; stops before writing without one)
985
1023
  --mode <mode> auto, mcp, sdk, both, or md
986
- --key <key> Marrow API key for self-test. Prefer MARROW_API_KEY because CLI args can appear in process listings.
987
1024
  --base-url <url> Marrow API base URL
988
1025
  --agent-id <id> Agent/fleet id for self-test headers
989
1026
  --no-controller Do not start the local background controller during install/repair
990
- --no-local-runtime Keep hooks on npx instead of the verified local MCP copy in ~/.marrow/runtime
1027
+ --no-local-runtime Ignored: hooks always start the verified local MCP copy in ~/.marrow/runtime, never npx
991
1028
  --no-self-test Skip API smoke/self-test
992
1029
  --verbose Print the full report instead of the one-line summary and log file
993
- uninstall Preview removing only Marrow's own hooks, MCP server entries, instructions and
994
- the local MCP runtime; add --yes to remove them. Your own hooks and settings
995
- are kept.
1030
+ uninstall Preview removing only Marrow's own hooks, MCP server entries and instructions
1031
+ from this project; add --yes to remove them. Your own hooks and settings are
1032
+ kept, and so is the local MCP runtime other projects share; --all removes it.
996
1033
 
997
- The API key comes from MARROW_API_KEY, or from the owner-only ~/.marrow/env when unset.
1034
+ Get a key at https://getmarrow.ai/signup. Put it in the owner-only ~/.marrow/env as
1035
+ 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
1036
+ arguments land in shell history and process listings.
998
1037
 
999
1038
  Environment:
1000
1039
  MARROW_TOOL_PROFILE Leave unset for primary (17 tools), or explicitly set primary, core, or full.
@@ -2171,19 +2210,25 @@ function clineHookContract(detection) {
2171
2210
  stage: 'pre_action',
2172
2211
  path: detection.paths.clinePreToolUseHook,
2173
2212
  label: 'Cline PreToolUse native hook',
2174
- content: clinePreToolUseHookSource(),
2213
+ content: localizeHookScriptText(clinePreToolUseHookSource(), MCP_ADAPTER_VERSION),
2214
+ source: clinePreToolUseHookSource(),
2215
+ legacyContent: clinePreToolUseHookSource(),
2175
2216
  },
2176
2217
  {
2177
2218
  stage: 'action_result',
2178
2219
  path: detection.paths.clinePostToolUseHook,
2179
2220
  label: 'Cline PostToolUse native hook',
2180
- content: clineTelemetryHookSource('cline-hook'),
2221
+ content: localizeHookScriptText(clineTelemetryHookSource('cline-hook'), MCP_ADAPTER_VERSION),
2222
+ source: clineTelemetryHookSource('cline-hook'),
2223
+ legacyContent: clineTelemetryHookSource('cline-hook'),
2181
2224
  },
2182
2225
  {
2183
2226
  stage: 'cancel_closeout',
2184
2227
  path: detection.paths.clineTaskCancelHook,
2185
2228
  label: 'Cline TaskCancel native hook',
2186
- content: clineTelemetryHookSource('cline-session-hook'),
2229
+ content: localizeHookScriptText(clineTelemetryHookSource('cline-session-hook'), MCP_ADAPTER_VERSION),
2230
+ source: clineTelemetryHookSource('cline-session-hook'),
2231
+ legacyContent: clineTelemetryHookSource('cline-session-hook'),
2187
2232
  },
2188
2233
  ];
2189
2234
  }
@@ -2329,9 +2374,7 @@ function activationProfile(detection, plan, changes, client) {
2329
2374
  const mcpConfigs = [detection.paths.mcpJson, detection.paths.cursorMcp]
2330
2375
  .map((filePath) => safeJsonObject(filePath));
2331
2376
  if (capabilityLevel === 'mcp' && mcpConfigs.some((config) => (
2332
- config?.mcpServers?.marrow?.command === 'npx'
2333
- && Array.isArray(config.mcpServers.marrow.args)
2334
- && config.mcpServers.marrow.args.join(' ') === `-y --package=@getmarrow/mcp@${mcpTargetVersion} marrow-mcp`
2377
+ mcpServerLaunchVersion(config?.mcpServers?.marrow) === mcpTargetVersion
2335
2378
  ))) observedHooks.push('mcp_tool_calls');
2336
2379
  const fingerprintMaterial = changes
2337
2380
  .filter((change) => change.applied || change.already_present)
@@ -2503,9 +2546,15 @@ function upsertMcpServerConfig(filePath, options = {}) {
2503
2546
  const env = { MARROW_BASE_URL: baseUrl };
2504
2547
  if (keptAgentId) env.MARROW_FLEET_AGENT_ID = keptAgentId;
2505
2548
  if (configuredProfile !== 'unset') env.MARROW_TOOL_PROFILE = configuredProfile;
2549
+ // The server starts the verified local runtime (it installs the pinned copy itself when it is
2550
+ // missing), never npx from the project directory: a copy planted there would otherwise become
2551
+ // the Marrow MCP server, whose own tools the hook matchers leave ungated.
2552
+ const serverVersion = compatibleMcpTargetVersion(options.mcpVersion) ? options.mcpVersion : MCP_ADAPTER_VERSION;
2506
2553
  servers.marrow = {
2507
- command: 'npx',
2508
- args: ['-y', `--package=${MCP_PACKAGE_SPEC}`, 'marrow-mcp'],
2554
+ ...mcpServerLaunch(serverVersion, {
2555
+ platform: options.hookPlatform || process.platform,
2556
+ ...(options.mcpIntegrity && serverVersion === options.mcpVersion ? { integrity: options.mcpIntegrity } : {}),
2557
+ }),
2509
2558
  env,
2510
2559
  };
2511
2560
  config.mcpServers = servers;
@@ -2808,15 +2857,25 @@ function buildPlan(detection, options) {
2808
2857
  write.identityDivergence = [];
2809
2858
  return retarget(upsertMcpServerConfig(target, {
2810
2859
  ...mcpConfigOptions,
2860
+ mcpVersion: mcpTargetVersion,
2861
+ mcpIntegrity: mcpTargetIntegrity,
2862
+ hookPlatform,
2811
2863
  onDivergence: (divergence) => write.identityDivergence.push(divergence),
2812
2864
  }));
2813
2865
  };
2814
2866
  return write;
2815
2867
  };
2816
2868
  const mcpTargetVersion = executableMcpTarget(options).version;
2817
- // Hooks start the verified local MCP runtime for this exact version when there is one.
2818
- const runtimeVersion = options.mcpRuntime && options.mcpRuntime.version === mcpTargetVersion ? mcpTargetVersion : null;
2819
- const localize = (text) => (runtimeVersion ? localizeHookSettingsText(text, runtimeVersion) : text);
2869
+ // Hooks always start the verified local MCP runtime for this exact version, never npx: when
2870
+ // that copy is missing or changed, a pre-action hook holds the call until a repair installs it.
2871
+ const hookPlatform = options.hookPlatform || process.platform;
2872
+ const mcpTargetIntegrity = executableMcpTarget(options).integrity;
2873
+ // A Marrow command already in the file with the same configuration is kept byte for byte.
2874
+ const localize = (text, filePath) => localizeHookSettingsText(text, mcpTargetVersion, {
2875
+ platform: hookPlatform,
2876
+ integrity: mcpTargetIntegrity,
2877
+ previousText: filePath ? safeRead(filePath) : null,
2878
+ });
2820
2879
  const retarget = (value) => retargetMcpPackageSpec(value, mcpTargetVersion);
2821
2880
  // The host-approval hook layout only for an MCP that answers those hooks.
2822
2881
  const hostApprovals = hostApprovalHooksSupported(mcpTargetVersion);
@@ -2847,7 +2906,7 @@ function buildPlan(detection, options) {
2847
2906
  type: 'json-transform',
2848
2907
  path: detection.paths.claudeSettings,
2849
2908
  label: 'Claude Code MCP passive hooks',
2850
- transform: (filePath) => localize(retarget(upsertClaudeHooks(filePath, { hostApprovals }))),
2909
+ transform: (filePath) => localize(retarget(upsertClaudeHooks(filePath, { hostApprovals })), filePath),
2851
2910
  });
2852
2911
  }
2853
2912
  if (detection.codex) {
@@ -2855,7 +2914,7 @@ function buildPlan(detection, options) {
2855
2914
  type: 'json-transform',
2856
2915
  path: detection.paths.codexHooks,
2857
2916
  label: 'Codex native hooks',
2858
- transform: (filePath) => localize(retarget(upsertCodexHooks(filePath))),
2917
+ transform: (filePath) => localize(retarget(upsertCodexHooks(filePath)), filePath),
2859
2918
  });
2860
2919
  }
2861
2920
  if (detection.cline) {
@@ -2864,7 +2923,9 @@ function buildPlan(detection, options) {
2864
2923
  type: 'owned-executable',
2865
2924
  path: hook.path,
2866
2925
  label: hook.label,
2867
- content: retarget(hook.content),
2926
+ content: localizeHookScriptText(retarget(hook.source), mcpTargetVersion, { integrity: mcpTargetIntegrity }),
2927
+ // The npx script 0.1.68 and earlier wrote is Marrow's too, and is rewritten.
2928
+ legacyContent: retarget(hook.legacyContent),
2868
2929
  mode: 0o755,
2869
2930
  conflict_fix: 'Move or remove the existing owner-managed Cline hook after owner review, then run npx @getmarrow/install --repair.',
2870
2931
  });
@@ -2875,7 +2936,7 @@ function buildPlan(detection, options) {
2875
2936
  type: 'json-transform',
2876
2937
  path: detection.paths.windsurfHooks,
2877
2938
  label: 'Windsurf native hooks',
2878
- transform: (filePath) => localize(retarget(upsertWindsurfHooks(filePath))),
2939
+ transform: (filePath) => localize(retarget(upsertWindsurfHooks(filePath)), filePath),
2879
2940
  });
2880
2941
  }
2881
2942
  if (detection.gemini) {
@@ -2883,7 +2944,7 @@ function buildPlan(detection, options) {
2883
2944
  type: 'json-transform',
2884
2945
  path: detection.paths.geminiSettings,
2885
2946
  label: 'Gemini CLI native hooks',
2886
- transform: (filePath) => localize(retarget(upsertGeminiHooks(filePath, { hostApprovals }))),
2947
+ transform: (filePath) => localize(retarget(upsertGeminiHooks(filePath, { hostApprovals })), filePath),
2887
2948
  });
2888
2949
  }
2889
2950
  if (detection.grok) {
@@ -2894,7 +2955,7 @@ function buildPlan(detection, options) {
2894
2955
  label: 'Grok native hooks',
2895
2956
  isManaged: managedGrokHooksFile,
2896
2957
  conflict_fix: 'Preserve or move the unmanaged owner Grok hook file after owner review, then rerun npx @getmarrow/install update.',
2897
- transform: (filePath) => localize(retarget(upsertGrokHooks(filePath))),
2958
+ transform: (filePath) => localize(retarget(upsertGrokHooks(filePath)), filePath),
2898
2959
  });
2899
2960
  }
2900
2961
  writes.push(mcpConfigWrite(detection.paths.mcpJson, 'Project MCP server config'));
@@ -2918,7 +2979,7 @@ function buildPlan(detection, options) {
2918
2979
  type: 'json-transform',
2919
2980
  path: detection.paths.cursorHooks,
2920
2981
  label: 'Cursor native hooks',
2921
- transform: (filePath) => localize(retarget(upsertCursorHooks(filePath, { hostApprovals }))),
2982
+ transform: (filePath) => localize(retarget(upsertCursorHooks(filePath, { hostApprovals })), filePath),
2922
2983
  });
2923
2984
  writes.push(mcpConfigWrite(detection.paths.cursorMcp, 'Cursor MCP server config'));
2924
2985
  }
@@ -3063,7 +3124,7 @@ function applyPlan(plan, options) {
3063
3124
  } else if (write.type === 'owned-executable') {
3064
3125
  // A file Marrow wrote for another pinned MCP version is still Marrow's and moves to this
3065
3126
  // pin; any other difference is the owner's edit and is never overwritten.
3066
- if (fileExists && before !== write.content && !sameMarrowFile(before, [write.content])) {
3127
+ if (fileExists && before !== write.content && !sameMarrowFile(before, [write.content, write.legacyContent].filter(Boolean))) {
3067
3128
  after = before;
3068
3129
  hookConflict = true;
3069
3130
  } else {
@@ -3219,8 +3280,8 @@ function withoutMarrowMcpServer(config) {
3219
3280
  const entry = servers && typeof servers === 'object' && !Array.isArray(servers) ? servers.marrow : null;
3220
3281
  if (!entry) return { config, removed: 0, kept: 0, custom: false };
3221
3282
  const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
3222
- const ours = entry.command === 'npx' && args.includes('marrow-mcp')
3223
- && args.some((arg) => /^--package=@getmarrow\/mcp(?:@[^\s]+)?$/.test(arg));
3283
+ const ours = Boolean(mcpServerLaunchVersion(entry)) || (entry.command === 'npx' && args.includes('marrow-mcp')
3284
+ && args.some((arg) => /^--package=@getmarrow\/mcp(?:@[^\s]+)?$/.test(arg)));
3224
3285
  if (!ours) return { config, removed: 0, kept: 1, custom: true };
3225
3286
  const nextServers = { ...servers };
3226
3287
  delete nextServers.marrow;
@@ -3241,7 +3302,8 @@ function withoutMarrowInstructionBlock(content) {
3241
3302
 
3242
3303
  // Marrow-written files compare equal once the pinned MCP version is normalized.
3243
3304
  function sameMarrowFile(actual, candidates) {
3244
- const normalize = (value) => String(value).replace(/@getmarrow\/mcp@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g, '@getmarrow/mcp@<pinned>');
3305
+ const normalize = (value) => delocalizeHookScriptText(String(value)).replace(/@getmarrow\/mcp@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g, '@getmarrow/mcp@<pinned>')
3306
+ .replace(/\/\.marrow\/runtime\/mcp\/\d+\.\d+\.\d+\//g, '/.marrow/runtime/mcp/<pinned>/');
3245
3307
  const normalized = normalize(actual);
3246
3308
  return candidates.some((candidate) => normalize(candidate) === normalized);
3247
3309
  }
@@ -3270,7 +3332,7 @@ function buildUninstallPlan(detection) {
3270
3332
  const instructionVariants = [false, true].map((hostApprovals) => passiveInstructions({ hostApprovals }).replace(/<!--[^>]+-->/g, '').trim() + '\n');
3271
3333
  steps.push({ kind: 'owned-file', path: detection.paths.cursorRules, label: 'Cursor Marrow rule', candidates: instructionVariants });
3272
3334
  for (const hook of clineHookContract(detection)) {
3273
- steps.push({ kind: 'owned-file', path: hook.path, label: hook.label, candidates: [hook.content] });
3335
+ steps.push({ kind: 'owned-file', path: hook.path, label: hook.label, candidates: [hook.content, hook.legacyContent] });
3274
3336
  }
3275
3337
  return steps;
3276
3338
  }
@@ -3387,8 +3449,12 @@ async function uninstall(options = {}) {
3387
3449
  hermesEntry = false;
3388
3450
  }
3389
3451
  if (hermesEntry) kept.push({ path: detection.paths.hermesConfig, reason: 'remove mcp_servers.marrow by hand; uninstall never rewrites the Hermes config, which holds other servers\' settings' });
3390
- // The local MCP runtime (~/.marrow/runtime) goes too; hooks elsewhere then start through npx.
3391
- const runtime = removeMcpRuntime(options.home || detection.home, { dryRun: !apply });
3452
+ // The local MCP runtime (~/.marrow/runtime) is shared by every project on this machine, so a
3453
+ // project uninstall keeps it. `uninstall --all` removes it too; hooks left in other projects
3454
+ // then install it again on their next start (or hold when they cannot).
3455
+ const runtime = options.all === true
3456
+ ? removeMcpRuntime(options.home || detection.home, { dryRun: !apply })
3457
+ : { removed: false, would_remove: false, kept: true, path: path.join(options.home || detection.home, '.marrow', 'runtime') };
3392
3458
  const removedEntries = changes.reduce((sum, change) => sum + change.removed_entries, 0);
3393
3459
  return {
3394
3460
  uninstall: {
@@ -3399,6 +3465,7 @@ async function uninstall(options = {}) {
3399
3465
  changes,
3400
3466
  kept,
3401
3467
  runtime: runtime.removed || runtime.would_remove ? runtime : null,
3468
+ runtime_kept: runtime.kept === true && fs.existsSync(runtime.path) ? runtime.path : null,
3402
3469
  removed_entries: removedEntries,
3403
3470
  next_step: apply
3404
3471
  ? 'Restart the agent hosts so they stop loading the removed hooks and MCP server.'
@@ -3421,7 +3488,8 @@ function printUninstallReport(report, sink = (text) => process.stdout.write(text
3421
3488
  else if (change.action === 'kept' || change.action === 'skipped') sink(`- ${where(change.path)}: left unchanged (${change.reason}).\n`);
3422
3489
  }
3423
3490
  for (const entry of result.kept) sink(`- ${where(entry.path)}: left in place; ${entry.reason}.\n`);
3424
- if (result.runtime) sink(`- ${result.runtime.path}: ${result.applied ? 'removed' : 'would remove'} Marrow's local MCP runtime (other projects' hooks start through npx until the next install there).\n`);
3491
+ if (result.runtime) sink(`- ${result.runtime.path}: ${result.applied ? 'removed' : 'would remove'} Marrow's local MCP runtime (Marrow hooks left in other projects install it again on their next start).\n`);
3492
+ else if (result.runtime_kept) sink(`- ${result.runtime_kept}: kept; other projects on this machine may use Marrow's local MCP runtime. Add --all to remove it too.\n`);
3425
3493
  if (result.controller?.stopped) sink(`Stopped ${result.controller.stopped} Marrow controller${result.controller.stopped === 1 ? '' : 's'} for this project.\n`);
3426
3494
  sink(`${result.next_step}\n`);
3427
3495
  }
@@ -3605,8 +3673,8 @@ const IDENTITY_REFUSED_RE = /AGENT_NOT_REGISTERED|wrong_agent_id|MARROW_AGENT_SC
3605
3673
  function identityRefusalFix(message, configuredAgentId = '') {
3606
3674
  if (!IDENTITY_REFUSED_RE.test(String(message || ''))) return null;
3607
3675
  return configuredAgentId
3608
- ? 'unset MARROW_AGENT_ID MARROW_FLEET_AGENT_ID && npx -y @getmarrow/install@latest'
3609
- : 'MARROW_API_KEY=your_agent_key npx -y @getmarrow/install@latest (replace your_agent_key with a key for one agent from your Marrow account)';
3676
+ ? 'unset MARROW_AGENT_ID MARROW_FLEET_AGENT_ID && npx -y @getmarrow/install@latest activate'
3677
+ : '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)';
3610
3678
  }
3611
3679
 
3612
3680
  // The agent Marrow resolved for this key: its single bound agent or the Free plan seat. An
@@ -3669,7 +3737,7 @@ async function runSelfTest(options) {
3669
3737
  return {
3670
3738
  skipped: true,
3671
3739
  reason: 'missing MARROW_API_KEY',
3672
- exact_fix: 'export MARROW_API_KEY=mrw_live_... && npx @getmarrow/install --repair',
3740
+ 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',
3673
3741
  mcp_tool_profile: initialProfile,
3674
3742
  };
3675
3743
  }
@@ -4125,7 +4193,10 @@ function printReport(report, sink) {
4125
4193
  if (report.mcp_runtime) {
4126
4194
  const runtime = report.mcp_runtime;
4127
4195
  out('\nMCP local runtime:\n');
4128
- out(`- hooks start: ${runtime.hooks_start === 'local_runtime' ? `the verified local copy of @getmarrow/mcp@${runtime.version} (${runtime.path})` : 'through npx'}\n`);
4196
+ out(`- hooks start: ${runtime.hooks_start === 'local_runtime' ? `the verified local copy of @getmarrow/mcp@${runtime.version} (${runtime.path})` : 'no verified local copy yet: Marrow hooks install the pinned copy on their next start; if npm or the registry cannot be reached they warn and let the action run, and any other failure holds it'}\n`);
4197
+ for (const filePath of runtime.legacy_npx_hooks || []) out(`- needs repair: ${filePath} still starts @getmarrow/mcp through npx\n`);
4198
+ if (runtime.needs_repair) out('- repair: run npx -y @getmarrow/install@latest --repair (from a terminal outside the project, with --cwd <project folder>)\n');
4199
+ if (runtime.local_runtime_switch === 'ignored') out('- note: MARROW_LOCAL_RUNTIME=0 / --no-local-runtime are ignored; hooks never start npx\n');
4129
4200
  out(`- state: ${runtime.state}${runtime.reason ? ` (${runtime.reason})` : ''}\n`);
4130
4201
  if (runtime.removed_versions?.length) out(`- removed older versions: ${runtime.removed_versions.join(', ')}\n`);
4131
4202
  }
@@ -4391,11 +4462,9 @@ function hermesWiringReport(detection, changes, options, planMode) {
4391
4462
  }
4392
4463
 
4393
4464
  // The local MCP runtime for the target version: installed (or kept) on a write run of the
4394
- // command, otherwise only an existing verified copy is used. `mcpLocalRuntime: false` (or
4395
- // MARROW_LOCAL_RUNTIME=0, --no-local-runtime) leaves every hook of this project on npx.
4465
+ // command, otherwise only an existing verified copy is used. Hooks start nothing else.
4396
4466
  function resolveMcpRuntime(detection, options, mcpTarget) {
4397
4467
  const home = options.home || detection.home;
4398
- if (options.mcpLocalRuntime === false) return { state: 'disabled' };
4399
4468
  const version = mcpTarget.version;
4400
4469
  const integrity = mcpTarget.integrity || (version === MCP_ADAPTER_VERSION ? MCP_ADAPTER_INTEGRITY : null);
4401
4470
  if (!integrity) return { state: 'skipped', reason: 'no_verified_integrity', version };
@@ -4406,6 +4475,7 @@ function resolveMcpRuntime(detection, options, mcpTarget) {
4406
4475
  version,
4407
4476
  integrity,
4408
4477
  sdk: { version: SDK_ADAPTER_VERSION, integrity: SDK_ADAPTER_INTEGRITY },
4478
+ ...(options.hookPlatform ? { platform: options.hookPlatform } : {}),
4409
4479
  ...(typeof options.mcpRuntimeInstall === 'function' ? { npmInstall: options.mcpRuntimeInstall } : {}),
4410
4480
  ...(options.mcpRuntimeNode ? { nodePath: options.mcpRuntimeNode } : {}),
4411
4481
  // One line on stderr before npm runs, so the wait is never silent (stdout stays clean
@@ -4413,36 +4483,43 @@ function resolveMcpRuntime(detection, options, mcpTarget) {
4413
4483
  onProgress: typeof options.mcpRuntimeProgress === 'function' ? options.mcpRuntimeProgress : (text) => process.stderr.write(text),
4414
4484
  });
4415
4485
  }
4416
- return verifyMcpRuntime(home, version, integrity) || { state: 'absent', version };
4486
+ return verifyMcpRuntime(home, version, integrity, options.hookPlatform ? { platform: options.hookPlatform } : {}) || { state: 'absent', version };
4417
4487
  }
4418
4488
 
4419
- // Whether this project's managed hooks start the local runtime now, as its install chose.
4420
- function projectHooksUseLocalRuntime(detection) {
4489
+ // Managed hook files that still start @getmarrow/mcp through npx (written by 0.1.68 or earlier,
4490
+ // or by hand): each needs a repair, which rewrites it in the local runtime form.
4491
+ function legacyNpxHookFiles(detection) {
4421
4492
  const paths = detection.paths || {};
4422
- return [paths.claudeSettings, paths.codexHooks, paths.cursorHooks, paths.windsurfHooks, paths.geminiSettings]
4423
- .some((filePath) => filePath && safeRead(filePath).includes('/.marrow/runtime/mcp/'));
4493
+ return [paths.claudeSettings, paths.codexHooks, paths.cursorHooks, paths.windsurfHooks, paths.geminiSettings,
4494
+ paths.grokHooks, paths.clinePreToolUseHook, paths.clinePostToolUseHook, paths.clineTaskCancelHook]
4495
+ .filter((filePath, index, all) => filePath && all.indexOf(filePath) === index && hookTextUsesNpx(safeRead(filePath)))
4496
+ // The Marrow MCP server entry too (F-I1): npx there would start a copy planted in the project.
4497
+ .concat([paths.mcpJson, paths.cursorMcp].filter((filePath, index, all) => filePath && all.indexOf(filePath) === index
4498
+ && mcpServerUsesNpx(safeJsonObject(filePath))));
4424
4499
  }
4425
4500
 
4426
- // The controller's maintenance rewrites managed hooks for the pinned MCP and keeps each project
4427
- // in the form its install chose: on the verified local runtime for that version when the
4428
- // project's hooks start it (verify only: maintenance never installs), on npx otherwise. A
4429
- // project installed with --no-local-runtime stays on npx even when another project's install
4430
- // put a runtime on this machine.
4501
+ // The controller's maintenance rewrites managed hooks for the pinned MCP, always in the local
4502
+ // runtime form; it only verifies the copy (maintenance never installs one).
4431
4503
  function maintenanceMcpRuntime(detection, options = {}) {
4432
- if (options.mcpLocalRuntime === false || !projectHooksUseLocalRuntime(detection)) return null;
4433
4504
  const runtime = resolveMcpRuntime(detection, { ...options, maintenance: true, yes: false }, executableMcpTarget(options));
4434
4505
  return runtime.version && ['present', 'installed', 'verified'].includes(runtime.state) ? runtime : null;
4435
4506
  }
4436
4507
 
4437
- function mcpRuntimeReport(runtime) {
4508
+ function mcpRuntimeReport(runtime, { legacyNpxHooks = [], switchIgnored = false } = {}) {
4438
4509
  if (!runtime) return null;
4510
+ const verified = Boolean(runtime.directory && ['present', 'installed', 'verified'].includes(runtime.state));
4439
4511
  return {
4440
4512
  state: runtime.state,
4441
4513
  version: runtime.version || null,
4442
4514
  path: runtime.directory || null,
4443
4515
  ...(runtime.reason ? { reason: runtime.reason } : {}),
4444
4516
  ...(runtime.removed_versions?.length ? { removed_versions: runtime.removed_versions } : {}),
4445
- hooks_start: runtime.directory && ['present', 'installed', 'verified'].includes(runtime.state) ? 'local_runtime' : 'npx',
4517
+ // Hooks never start npx: without a verified copy they install the pinned one themselves on
4518
+ // their next start, and pre-action hooks hold the call only when that fails.
4519
+ hooks_start: verified ? 'local_runtime' : 'self_heal_on_next_start',
4520
+ ...(legacyNpxHooks.length ? { legacy_npx_hooks: legacyNpxHooks } : {}),
4521
+ needs_repair: !verified || legacyNpxHooks.length > 0,
4522
+ ...(switchIgnored ? { local_runtime_switch: 'ignored' } : {}),
4446
4523
  };
4447
4524
  }
4448
4525
 
@@ -4494,7 +4571,7 @@ async function install(options) {
4494
4571
  options.mcpTargetVersion = mcpTarget.version;
4495
4572
  // A maintenance pass keeps the project's hooks in the form its install chose.
4496
4573
  const mcpRuntime = options.maintenance === true
4497
- ? maintenanceMcpRuntime(detection, options) || { state: options.mcpLocalRuntime === false ? 'disabled' : 'absent', version: mcpTarget.version }
4574
+ ? maintenanceMcpRuntime(detection, options) || { state: 'absent', version: mcpTarget.version }
4498
4575
  : resolveMcpRuntime(detection, options, mcpTarget);
4499
4576
  options.mcpRuntime = mcpRuntime.version && ['present', 'installed', 'verified'].includes(mcpRuntime.state) ? mcpRuntime : null;
4500
4577
  const plan = buildPlan(detection, options);
@@ -4756,7 +4833,7 @@ async function install(options) {
4756
4833
  harnessReload,
4757
4834
  firstCapture: firstCapturePath(detection),
4758
4835
  hermes,
4759
- mcp_runtime: mcpRuntimeReport(mcpRuntime),
4836
+ mcp_runtime: mcpRuntimeReport(mcpRuntime, { legacyNpxHooks: legacyNpxHookFiles(detection), switchIgnored: options.localRuntimeSwitchIgnored === true }),
4760
4837
  changes,
4761
4838
  doctor: {
4762
4839
  active: Boolean(!selfTest.skipped && selfTest.active),
@@ -4812,7 +4889,14 @@ function activationFailureFix(message, configuredAgentId = '') {
4812
4889
  }
4813
4890
 
4814
4891
  function installCommandFor(options) {
4815
- return options.update ? INSTALLER_UPDATE_COMMAND : 'npx -y @getmarrow/install@latest';
4892
+ return options.update ? INSTALLER_UPDATE_COMMAND : 'npx -y @getmarrow/install@latest activate';
4893
+ }
4894
+
4895
+ // Static text: it never echoes a key, a command line or a path. The stored-file form comes first
4896
+ // because a key typed on a command line lands in shell history.
4897
+ function missingKeyMessage(options) {
4898
+ const command = installCommandFor(options);
4899
+ 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})`;
4816
4900
  }
4817
4901
 
4818
4902
  // One summary for the one-command paths (first install and update). The full report goes to a
@@ -4917,7 +5001,10 @@ async function runCli(argv) {
4917
5001
  else printUninstallReport(report);
4918
5002
  return;
4919
5003
  }
5004
+ // An empty or whitespace-only key from any source (environment, --key, owner file) is missing.
5005
+ options.apiKey = String(options.apiKey || '').trim();
4920
5006
  const stored = readOwnerApiKey(options.home);
5007
+ stored.apiKey = String(stored.apiKey || '').trim();
4921
5008
  const keyInfo = { source: options.apiKey ? 'environment' : null, path: null, owner_files_disagree: stored.conflict };
4922
5009
  if (!options.apiKey && stored.apiKey) {
4923
5010
  options.apiKey = stored.apiKey;
@@ -4925,7 +5012,11 @@ async function runCli(argv) {
4925
5012
  keyInfo.path = stored.source;
4926
5013
  }
4927
5014
  const oneCommand = Boolean((options.activate || options.update) && !options.dryRun && !options.doctor);
4928
- if (oneCommand && !options.apiKey) {
5015
+ // Any write run without a key stops here, before anything is written: hooks installed with no
5016
+ // key would deny protected actions on the next harness start. Previews, doctor and uninstall
5017
+ // are not write runs.
5018
+ const writeRun = Boolean(options.yes && !options.dryRun && !options.doctor);
5019
+ if ((oneCommand || writeRun) && !options.apiKey) {
4929
5020
  // An update aimed at an unmanaged directory is refused first; it needs no key to answer.
4930
5021
  if (options.update) {
4931
5022
  assertUpdateTargetsManagedRoot(detectEnvironment(options.cwd, {
@@ -4934,7 +5025,7 @@ async function runCli(argv) {
4934
5025
  USERPROFILE: options.home || options.cwd,
4935
5026
  }));
4936
5027
  }
4937
- process.stdout.write(`Marrow needs your API key. Run: MARROW_API_KEY=your_key ${installCommandFor(options)} (replace your_key with your key; create one at https://getmarrow.ai)\n`);
5028
+ process.stdout.write(`${missingKeyMessage(options)}\n`);
4938
5029
  process.exitCode = 2;
4939
5030
  return;
4940
5031
  }