@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/README.md +92 -59
- package/package.json +1 -1
- package/src/governed-runner.js +40 -25
- package/src/installer.js +170 -79
- package/src/marrow-own-tools.js +121 -0
- package/src/mcp-runtime.js +361 -309
- package/src/owner-env.js +2 -1
- package/src/pins.js +3 -3
- package/src/runtime-core.js +594 -0
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",
|
|
284
|
-
//
|
|
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
|
-
|
|
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
|
|
391
|
-
const
|
|
392
|
-
const
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
862
|
-
// npx
|
|
863
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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 =
|
|
942
|
+
options.mode = optionValue() || options.mode;
|
|
911
943
|
}
|
|
912
944
|
else if (arg === '--key') {
|
|
913
|
-
|
|
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 =
|
|
917
|
-
else if (arg === '--agent-id') 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
|
|
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
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2508
|
-
|
|
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
|
|
2818
|
-
|
|
2819
|
-
const
|
|
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.
|
|
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)
|
|
3391
|
-
|
|
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
|
|
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
|
-
: '
|
|
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: '
|
|
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})` : '
|
|
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.
|
|
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
|
-
//
|
|
4420
|
-
|
|
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
|
-
.
|
|
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
|
|
4427
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
5028
|
+
process.stdout.write(`${missingKeyMessage(options)}\n`);
|
|
4938
5029
|
process.exitCode = 2;
|
|
4939
5030
|
return;
|
|
4940
5031
|
}
|