Files
msd-core/bin/install.js
Jakub Zych 6b0b92674a refactor: remove dead descriptor-driven mechanisms (no remaining consumer)
Drop code whose only consumer was a retired runtime: hostBehaviors readers for
agentFileExtension, localTargetIsProjectRoot, sharedHooksDirName,
skillsManifestPrefix and skipCodexSkillsManifest; the empty
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS array and live-config-guard plumbing; the
empty RUNTIME_NOTE_AUDIENCE_BY_HEADING filter; the unused resolveVersionFrom
export; and the WINDSURF_SESSION_ID workstream session key. Delete tests that
only exercised those mechanisms.
2026-10-06 20:49:35 +02:00

11103 lines
510 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const os = require('os');
const readline = require('readline');
const crypto = require('crypto');
const {
isManagedHookBasename,
isManagedHookCommand,
projectLocalHookPrefix,
projectLegacySettingsHookCommand,
projectManagedHookCommand,
projectPathActionProjection,
projectPortableHookBaseDir,
projectPersistentPathExportActions,
PATH_ACTION_REASON,
projectShellCommandText,
projectCodexHookTomlCommand,
shellHookOmitsBashRunner,
buildLocalShellHookCommand,
} = require('../msd-core/bin/lib/shell-command-projection.cjs');
// Bidirectional MSD slash-command namespace transformer (#3583).
// Required at module scope so the command list can be computed once per install
// and passed down to convertClaudeCommandToClaudeSkill, avoiding repeated
// fs.readdirSync + RegExp work for every skill.
const {
transformContentToHyphen,
readMsdCommandNames,
} = require('../msd-core/bin/lib/command-roster.cjs');
const {
resolveAntigravityGlobalDir,
getGlobalConfigDir,
getGlobalSkillsBase,
isRegisteredRuntimeId,
} = require('../msd-core/bin/lib/runtime-homes.cjs');
// #2870: the Install Scope Module — turns a bare 'global' | 'local' scope id
// plus a runtime into one resolved value (configHome, settingsFile,
// consentRequired, hostPrecedenceRank) instead of the id being re-derived
// and re-interpreted at each call site. See src/install-scope.cts.
const { resolveScope } = require('../msd-core/bin/lib/install-scope.cjs');
const { isTestHomeGuardRefusal } = require('../msd-core/bin/lib/real-home-guard.cjs');
// getDirName (runtime -> local config dir name) is relocated out of this
// installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the
// conversion module's rewrite engine can consume it without importing
// bin/install.js. Imported here for install.js's own internal call sites
// (getConfigDirFromHome and the runtime-content-rewrite loops below) — #2876
// retired the re-export; tests now import getDirName directly from
// msd-core/bin/lib/runtime-name-policy.cjs.
const { getDirName, getRuntimeLabel, getGlobalConfigHomeFragment, runtimeFlags, getRuntimeNewProjectCommand } = require('../msd-core/bin/lib/runtime-name-policy.cjs');
const {
applyWorktreeBaseRef,
readBaseRefFromSettings,
} = require('../msd-core/bin/lib/worktree-base-ref.cjs');
const { resolveInstallPlan } = require('../msd-core/bin/lib/runtime-config-adapter-registry.cjs');
const { createImperativeAdapter } = require('../msd-core/bin/lib/adapter-imperative.cjs');
// #2930 (epic #1671 Phase 3): strips `<!-- msd:section -->` markers from
// workflow .md content at emit time, before any per-runtime rewrite runs.
const { composeWorkflow } = require('../msd-core/bin/lib/workflow-fragments.cjs');
// #3072: THE shared composition-scope predicate (also consumed by the served
// MCP catalog, src/mcp-catalog.cts) — see the comment at its call site below.
const { shouldCompose } = require('../msd-core/bin/lib/mcp-catalog.cjs');
const runtimeArtifactConversion = require('../msd-core/bin/lib/runtime-artifact-conversion.cjs');
const { escapeRegex: escapeRegExp } = require('../msd-core/bin/lib/pattern.cjs');
// #2873: cross-scope shadow detection — reports (never fails) when a
// MSD-owned scope shadows another on this machine (design doc:
// .msd/phase/feat-2873-cross-scope-shadowing/40-design.md).
const { buildShadowReport, renderShadowReport } = require('../msd-core/bin/lib/install-shadow-report.cjs');
// #2544: the CommonJS marker's single source of truth. classifyMarker() backs
// BOTH ensureCommonJsMarker() (install) and removeCommonJsMarker() (uninstall),
// so the write side can no longer clobber a package.json the remove side would
// correctly refuse to delete.
const { ensureCommonJsMarker, removeCommonJsMarker } = require('../msd-core/bin/lib/commonjs-marker.cjs');
// Canonical set of hook files shipped to users. Imported here so writeManifest()
// records exactly the same set that build-hooks.js copies to hooks/dist/, making
// the manifest and the installed hooks/ dir structurally identical. Avoids the
// prefix/extension-regex approach that missed managed-hooks-registry.cjs (#941).
const { HOOKS_TO_COPY: _HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY);
// ADR-857 phase 5f-1: hook-surface writer functions extracted to a dedicated
// module. install.js used to re-export the whole hooksSurface surface so
// existing callers (require('../bin/install.js').writeCursorHooksJson etc.)
// kept working — #2876 found zero production/test consumers of any of those
// re-exports (tests import runtime-hooks-surface.cjs directly) and retired
// them from module.exports. install.js still requires hooksSurface below for
// its own internal call sites (writeCursorHooksJson,
// resolveNodeRunner, applySettingsJsonHooks, etc.).
const hooksSurface = require('../msd-core/bin/lib/runtime-hooks-surface.cjs');
/**
* #3677 predicate — true when an agent body needs `/msd:<cmd>` → `/msd-<cmd>`
* normalization at install time. Descriptor-driven
* (capabilities/<runtime>/capability.json -> runtime.hostBehaviors.hyphenNameAgentBody)
* instead of a hardcoded runtime allow-list (ADR-1239 / #2086). Sibling fixes
* #3583 / #3629 covered SKILL.md bodies, #3584 / #3606 covered runtime
* emissions — this is the agent-body surface (#3677).
*
* Unknown / future runtimes that don't declare the flag default to "no
* rewrite" (better to leak than to mangle a runtime whose namespace
* behavior we haven't verified).
*/
function shouldNormalizeHyphenNamespaceInAgentBody(runtime) {
if (typeof runtime !== 'string' || runtime === '') return false;
return _hostBehaviors(runtime).hyphenNameAgentBody === true;
}
/**
* #3677 helper — applies the hyphen-namespace transform iff the predicate
* says so. Pure function; safe to call unconditionally from the install
* loop. Returns the input unchanged for runtimes that self-convert or
* intentionally keep colon refs.
*/
function normalizeAgentBodyForRuntime(content, runtime, cmdNames) {
if (!shouldNormalizeHyphenNamespaceInAgentBody(runtime)) return content;
return transformContentToHyphen(content, cmdNames);
}
// Colors
const cyan = '\x1b[36m';
const green = '\x1b[32m';
const yellow = '\x1b[33m';
const red = '\x1b[31m';
const bold = '\x1b[1m';
const dim = '\x1b[2m';
const reset = '\x1b[0m';
// Codex config.toml constants
const MSD_CODEX_MARKER = '# MSD Agent Configuration \u2014 managed by msd-core installer';
const MSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# MSD codex_hooks ownership: ';
// Known scalar fields of Codex's `AgentsToml` struct (codex-rs/config/src/
// config_toml.rs \u2014 `[agents]` table). Codex marks the struct
// `#[schemars(deny_unknown_fields)]`, so a bare `[agents]` table is valid ONLY
// when every direct key is one of these (named agent roles live in the flattened
// `[agents.<name>]` sub-tables, a separate `AgentRoleToml`). MSD writes only
// `max_depth` (ADR-1239 upgrade 2 / #2088); the full set is enumerated so the
// schema check accepts a user's other legitimate AgentsToml scalars too.
const CODEX_AGENTS_TOML_SCALAR_KEYS = new Set([
'max_threads',
'max_depth',
'job_max_runtime_seconds',
'interrupt_message',
]);
// MSD's managed dispatch-depth value. Codex's implicit default is also 1 (root
// sessions start at depth 0); writing it EXPLICITLY pins the negotiated
// `dispatch.maxDepth: 1` axis instead of relying on codex-cli's implicit default
// (ADR-1239 upgrade 2 / #2088). Per the negotiated capability, MSD-hosted Codex
// dispatch is single-level (maxDepth === 1 \u2192 `degradationFor` flattens waves).
const MSD_CODEX_AGENTS_MAX_DEPTH = 1;
// Codex hooks.json lifecycle events MSD registers beyond SessionStart (which has
// its own dedicated path). This is Codex's OWN hook-event vocabulary (per
// developers.openai.com/codex/config-reference), distinct from the cross-runtime
// settings.json `extendedHookEvents` descriptor field (a claude/gemini-family
// allowlist consumed only by hooksSurface==='settings-json' runtimes — Codex is
// codex-hooks-json). All route through msd-context-monitor.js. #772 wired the
// first three; #2088 adds the remaining six documented events so MSD's monitor
// fires at the same lifecycle points as in Claude Code. Install and uninstall
// share this list so the registered set and the removed set never diverge.
const CODEX_EXTENDED_HOOK_EVENTS = [
'SubagentStart',
'Stop',
'PostToolUse',
'PreToolUse',
'PermissionRequest',
'PreCompact',
'PostCompact',
'SubagentStop',
'UserPromptSubmit',
];
// Codex's hook-enabling feature flag (issue #3566). Codex itself marks
// `codex_hooks` as a `legacy_key` in codex-rs/features/src/legacy.rs; the
// canonical current key under [features] is `hooks`. The installer always
// emits the canonical key going forward, recognizes legacy aliases as
// equivalent during reinstall, and migrates them forward on rewrite. The
// audit-marker string above is intentionally unchanged so existing
// installs' ownership lines continue to round-trip.
const CODEX_HOOKS_FEATURE_KEY = 'hooks';
const CODEX_HOOKS_FEATURE_LEGACY_KEYS = ['codex_hooks'];
const CODEX_HOOKS_FEATURE_ALL_KEYS = [CODEX_HOOKS_FEATURE_KEY, ...CODEX_HOOKS_FEATURE_LEGACY_KEYS];
function isCodexHooksFeatureKey(key) {
return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key);
}
// #768 \u2014 Claude Code permissions.allow entries.
// Pre-populated during Claude installs to eliminate first-run approval friction
// for msd-core's own known-safe tool calls. (The defense-in-depth deny entries
// for credential files that #768 also wrote are retired \u2014 see
// MSD_CLAUDE_LEGACY_DENY_PERMISSIONS below.)
//
// Format: each string uses Claude Code's documented permission rule syntax \u2014
// "Tool(pattern)" e.g. "Bash(npx msd-core *)", "Read(.planning/*)"
// "Tool" (bare tool name, no pattern)
//
// Merge policy: additive, non-destructive \u2014 existing user entries are preserved;
// MSD entries are appended only when not already present (idempotent).
// The reference/default runtime (ADR-1239 reference host). Single-sourced here
// instead of scattered literal 'claude' defaults/rosters (#2086).
const DEFAULT_RUNTIME = 'claude';
const MSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([
'Bash(npx msd-core *)',
'Read(.planning/*)',
'Edit(.planning/*)',
'Read(STATE.md)',
'Edit(STATE.md)',
]);
// #4221 \u2014 Retired deny rules. #768 wrote these three `Read()` deny rules
// into settings.json; Claude Code 2.1.259 hardened the Bash-side enforcement
// of Read() deny rules so that ANY such rule makes every
// `cd DIR && grep/cat relative-path` compound prompt for approval, even in
// `auto` permission mode \u2014 and MSD subagents emit hundreds of those per
// session. The same protection now ships as the managed PreToolUse hook
// hooks/msd-secret-read-guard.js (a hook denial is not a permission rule and
// never arms that check). Unlike the #2278 allow-side migration, there is no
// surviving "current" deny list: the constant is RENAMED to its legacy role
// and only ever filtered, never added. Byte-equal strings only \u2014 a user's
// own hand-written identical rule is indistinguishable and is removed too
// (the install manifest never recorded permission strings, so a
// manifest-gated cleanup is not possible).
const MSD_CLAUDE_LEGACY_DENY_PERMISSIONS = Object.freeze([
'Read(.env)',
'Read(.env.*)',
'Read(.secrets)',
]);
// #2278 — Stale allow-rule forms from before the fix. Claude Code has no
// standalone `Write` permission gate: file-editing tools (Write/Edit/
// NotebookEdit) are gated collectively via `Edit(pattern)`. The original
// `Write(.planning/*)` / `Write(STATE.md)` entries were therefore silently
// unmatched (never granted anything) and Claude Code additionally surfaces a
// session-start warning about unmatched permission rules. This list lets
// mergeClaudePermissions and uninstall cleanup retire those stale entries on
// existing installs while the current MSD_CLAUDE_ALLOW_PERMISSIONS above
// carries the working `Edit(...)` forms.
const MSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS = Object.freeze([
'Write(.planning/*)',
'Write(STATE.md)',
]);
/**
* Merge MSD-owned permission entries into a Claude Code settings object.
*
* Additive and idempotent: existing allow/deny entries are preserved; MSD
* entries are appended only if not already present. No other permission sub-keys
* (ask, disableBypassPermissionsMode, etc.) are touched.
*
* Migration (#2278): before adding the current MSD_CLAUDE_ALLOW_PERMISSIONS,
* any stale MSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS entry (e.g. the unmatched
* `Write(...)` forms from before the fix) is removed from permissions.allow,
* so existing installs end up with the working `Edit(...)` forms instead of
* both the dead legacy entry and its replacement sitting side by side.
*
* Migration (#4221): the retired MSD_CLAUDE_LEGACY_DENY_PERMISSIONS entries
* are removed from permissions.deny (byte-equal only). Nothing is added to
* deny any more: an absent `deny` key is left absent (never created as an
* empty array), and a `deny` array emptied BY THIS FILTER is deleted so the
* retirement leaves no `"deny": []` residue; a user's pre-existing empty
* `deny: []` is untouched.
*
* Defensive: if settings is not a plain object, returns immediately without
* throwing. If permissions.allow / permissions.deny exist but are not arrays
* (malformed settings), they are replaced with valid arrays.
*
* @param {object} settings - The parsed settings.json object to mutate in-place.
*/
function mergeClaudePermissions(settings) {
if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) return;
if (!settings.permissions || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)) {
settings.permissions = {};
}
if (!Array.isArray(settings.permissions.allow)) {
settings.permissions.allow = [];
}
if (settings.permissions.deny !== undefined && !Array.isArray(settings.permissions.deny)) {
settings.permissions.deny = [];
}
settings.permissions.allow = settings.permissions.allow.filter(
(e) => !MSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS.includes(e)
);
for (const entry of MSD_CLAUDE_ALLOW_PERMISSIONS) {
if (!settings.permissions.allow.includes(entry)) {
settings.permissions.allow.push(entry);
}
}
if (Array.isArray(settings.permissions.deny)) {
const before = settings.permissions.deny.length;
settings.permissions.deny = settings.permissions.deny.filter(
(e) => !MSD_CLAUDE_LEGACY_DENY_PERMISSIONS.includes(e)
);
if (settings.permissions.deny.length === 0 && before > 0) {
delete settings.permissions.deny;
}
}
}
// #777 — Cursor CLI lifecycle hook constants.
// Cursor reads hook configs from <project-root>/.cursor/hooks.json (local) or
// ~/.cursor/hooks.json (global) with the shape { version: 1, hooks: { <event>: [...] } }.
// Events use camelCase: sessionStart, postToolUse, preToolUse, etc.
// A `command` hook entry runs an external script. MSD registers six managed hooks
// (AC4a upgrade, #2089 — ADR-1239):
// sessionStart → msd-cursor-session-start.js (context injection)
// postToolUse → msd-cursor-post-tool.js (STATE.md update monitor)
// preToolUse → msd-cursor-pre-tool.js (write-path guard)
// stop → msd-cursor-stop.js (verify-work reminder)
// subagentStart → msd-cursor-subagent-start.js (subagent context injection)
// subagentStop → msd-cursor-subagent-stop.js (subagent completion reminder)
// Cursor docs: https://cursor.com/docs/hooks
//
// These script-name/marker constants used to be independently re-declared
// here with their own string literals — a second, unlinked copy of exactly
// the values runtime-hooks-surface.cts also defines for its own internal use
// (buildCursorHookEntry, writeCursorHooksJson, etc.). #2876's code review
// found tests reading the constant from install.js's copy while calling
// functions built from hooksSurface's copy, with nothing guarding the two
// staying equal — the same unlinked-duplicate-value hazard the ADR-1508
// dedup elsewhere in this file exists to prevent. Fixed at the root: these
// are now bare references to hooksSurface's own exports, so there is exactly
// one literal definition of each value, full stop.
const MSD_CURSOR_SESSION_HOOK_SCRIPT = hooksSurface.MSD_CURSOR_SESSION_HOOK_SCRIPT;
const MSD_CURSOR_POST_TOOL_HOOK_SCRIPT = hooksSurface.MSD_CURSOR_POST_TOOL_HOOK_SCRIPT;
const MSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = hooksSurface.MSD_CURSOR_PRE_TOOL_HOOK_SCRIPT;
const MSD_CURSOR_STOP_HOOK_SCRIPT = hooksSurface.MSD_CURSOR_STOP_HOOK_SCRIPT;
const MSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = hooksSurface.MSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT;
const MSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = hooksSurface.MSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT;
// All MSD-managed Cursor hook scripts (used by uninstall cleanup). Not
// independently defined in hooksSurface — built here from the bare
// references above, so it can never drift from them either.
const MSD_CURSOR_HOOK_SCRIPTS = [
MSD_CURSOR_SESSION_HOOK_SCRIPT,
MSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
MSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
MSD_CURSOR_STOP_HOOK_SCRIPT,
MSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
MSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
];
// Marker comment embedded in managed hook entries so MSD can find+remove them.
const MSD_CURSOR_HOOK_MARKER = hooksSurface.MSD_CURSOR_HOOK_MARKER;
// MSD-managed files under hooks/lib/ (helpers required by msd-*.js hooks).
// git-cmd.js does not start with "msd-" (shared classifier for #3129), msd-graphify-rebuild.sh does.
// cursor-workspace.js (#2587) is required by the Cursor lifecycle hooks. Those
// are staged individually by writeCursorHooksJson (Cursor sets
// hostBehaviors.skipSharedHooksInstall, so it never reaches the bulk hooks/lib
// copy below) — that function stages this helper alongside them. Listing it
// here keeps uninstall and the manifest managing it for every OTHER runtime
// that does receive hooks/lib.
// injection-patterns.js (#3504) is required by msd-prompt-guard.js and
// msd-read-injection-scanner.js — the shared prompt-injection pattern list the
// two guards require so their copies cannot drift.
const MSD_HOOK_LIB_FILES = ['git-cmd.js', 'msd-graphify-rebuild.sh', 'cursor-workspace.js', 'injection-patterns.js'];
/** Directory name MSD stages its shared hook bundle under, inside a runtime's install root. */
const SHARED_HOOKS_DIR = 'hooks';
// #3184 — MSD-managed file enumerations for scripts/changeset/ and scripts/lib/
// uninstall. The install-side copy of both directories is wholesale ("copy every
// file present"), so these enumerations MUST be kept in parity with the real
// directory contents or an added file ships on install and then orphans on
// uninstall (survives removal, keeps the dir non-empty, blocks its rmdir).
// Hoisted to module scope (and exported below) so tests/install.test.cjs can
// assert parity against fs.readdirSync(scripts/lib) / fs.readdirSync(scripts/changeset)
// without source-grepping this file.
const MSD_CHANGESET_FILES = [
'cli.cjs', 'parse.cjs', 'render.cjs', 'serialize.cjs',
'github-release-notes.cjs', 'lint.cjs', 'new.cjs',
'README.md', // documentation only — not user-authored
];
const MSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs', 'drift-scan.cjs', 'alias-drift-families.cjs', 'exit-code-registry.cjs', 'ndjson-reporter.cjs', 'ci-job-timing.cjs', 'shellcheck-fetch.cjs', 'npm-version-check-diagnosis.cjs', 'platform-conformance-tier.generated.cjs', 'suite-detection.cjs', 'macos-conformance-tier.generated.cjs'];
// #4544 — the Codex hook payload the install stages into <targetDir>/hooks/.
// Hoisted to module scope (the #3184 precedent above) so the rollback's
// incomplete-capture path can name exactly the files MSD owns without a
// second copy of the list drifting away from the staging site, which lives
// inside the Codex config block where the constant used to be declared.
const CODEX_HOOKS_TO_COPY = [
'msd-check-update.js',
'msd-check-update-worker.js',
'managed-hooks-registry.cjs',
];
// #3897 rung 3 — sandbox_mode derivation, the hold list, and the hold-roster
// validator now live in `src/codex-agent-toml.cts` (compiled to
// `msd-core/bin/lib/codex-agent-toml.cjs`), NOT here. This module used to be
// the sole owner, and `agent-install-check.cts`'s `checkCodexSandboxPosture`
// lazily `require()`d THIS FILE to reach `deriveCodexSandboxMode` — but
// requiring `bin/install.js` runs its whole top-level script, including the
// CLI's ASCII banner print to stdout, which corrupted every stdout-JSON
// caller downstream of that posture check (`msd-tools validate agents`).
// `codex-agent-toml.cjs` is a genuine leaf (no top-level side effects), so
// both this file and `agent-install-check.cts` import the derivation from
// there — ONE owner, no second predicate. See that module's header for the
// full rationale, and CAUSE B (below, `installCodexConfig`) for the removal
// of `validateCodexSandboxHolds`'s call from the install runtime path.
const {
CODEX_SANDBOX_HOLDS,
deriveCodexSandboxMode,
validateCodexSandboxHolds,
// #3897 list-form parse fix, Fix 3 (generative-fix-divergence): this
// file's own `generateCodexAgentToml` used to pull `tools:` via its
// private `extractFrontmatterField` (single-line only) instead of this
// shared reader — the two sandbox-feeding paths (this emitter and
// `agent-install-check.cts`'s `checkCodexSandboxPosture`) silently
// disagreed on YAML block-list `tools:` form. Both now route through this
// ONE extractor.
extractToolsValue,
} = require(path.join(__dirname, '..', 'msd-core', 'bin', 'lib', 'codex-agent-toml.cjs'));
// Get version from package.json
const pkg = require('../package.json');
// #2517 — runtime-aware tier resolution shared with core.cjs.
// Hoisted to top with absolute __dirname-based paths so `msd install codex` works
// when invoked via npm global install (cwd is the user's project, not the msd repo
// root). Inline `require('../msd-core/...')` from inside install functions
// works only because Node resolves it relative to the install.js file regardless
// of cwd, but keeping the require at the top makes the dependency explicit and
// surfaces resolution failures at process start instead of at first install call.
const _msdLibDir = path.join(__dirname, '..', 'msd-core', 'bin', 'lib');
const {
RUNTIME_PROFILE_MAP: MSD_RUNTIME_PROFILE_MAP,
isAnthropicFlavoredModel: msdIsAnthropicFlavoredModel,
} = require(path.join(_msdLibDir, 'model-catalog.cjs'));
// #4145: shared hash-first recovery for msd-pristine/ baselines stored at an
// unexpected path (e.g. without the msd-core/ prefix an earlier release's
// writer dropped). Same module the reapply verifier uses, so the two readers
// cannot drift apart again.
const {
findPristineByHash: msdFindPristineByHash,
} = require(path.join(_msdLibDir, 'pristine-baseline.cjs'));
// #2875 Part 2: MODEL_PROFILES + resolveTierEntry are now consumed only by
// install-model-override-resolver.cjs's readMsdRuntimeProfileResolver
// (required below) — this installer no longer needs its own bindings.
// #2071 — install-time effort resolution (readMsdEffectiveEffortConfig /
// resolveInstallTimeEffort, plus their _getMsdEffortCatalog + _readMsdConfigFile
// helpers) was extracted into the shipped msd-core/bin/lib/install-effort-resolver.cjs
// so `msd-tools effort sync` can require it from the installed runtime instead of this
// package-root bin/install.js, which the installer never copies (#2071 crash). The
// installer imports it back here — single source of truth for both surfaces.
const {
readMsdEffectiveEffortConfig,
resolveInstallTimeEffort,
_getMsdEffortCatalog,
_readMsdConfigFile,
} = require(path.join(_msdLibDir, 'install-effort-resolver.cjs'));
const {
MINIMAL_SKILL_ALLOWLIST,
PROFILES,
isMinimalMode,
stageSkillsForMode,
readActiveProfile,
writeActiveProfile,
resolveEffectiveProfile,
mostRestrictiveProfile,
resolveProfile,
loadSkillsManifest,
stageSkillsForProfile,
stageAgentsForProfile,
stageSkillsForRuntimeAsSkills,
} = require(path.join(_msdLibDir, 'install-profiles.cjs'));
// ADR-857 phase 4c: load capability registry (optional; missing → falls back to undefined)
let _capabilityRegistry;
try {
_capabilityRegistry = require(path.join(_msdLibDir, 'capability-registry.cjs'));
} catch (_) {
_capabilityRegistry = undefined;
}
// #2322 BLOCKER 2: `_capabilityRegistry` above is the FROZEN first-party registry
// (capability-registry.cjs, built at publish time) — it never reflects an
// INSTALLED third-party overlay capability, so a fresh `msd install` could never
// stage an installed third-party capability's skill regardless of registration,
// even on the DEFAULT `--profile full`. `_installedCapabilityRegistry` composes
// the overlay via capability-loader's `loadRegistry({includeInstalled:true})` —
// the SAME call capability-writer.cts's `capability set --runtime` path already
// uses — so a fresh install and a post-install `capability set` agree on
// third-party skill availability. Used ONLY for skill-profile resolution and
// runtime-artifact-layout staging below; `_capabilityRegistry` (frozen) remains
// the source for msd-core's OWN runtime/host-behavior descriptors (unaffected —
// those are always first-party). A load failure degrades to the frozen
// `_capabilityRegistry` (no overlay data -> no third-party skills staged; never
// a crash and never a scan-and-guess fallback).
let _installedCapabilityRegistry;
try {
const _capabilityLoader = require(path.join(_msdLibDir, 'capability-loader.cjs'));
_installedCapabilityRegistry = _capabilityLoader.loadRegistry({
includeInstalled: true,
cwd: process.cwd(),
msdHome: process.env['MSD_HOME'],
});
} catch (_) {
_installedCapabilityRegistry = _capabilityRegistry;
}
// Fail-safe floor for the reference host's #338-privacy-critical behaviors, used
// ONLY when the first-party capability registry cannot be loaded (a broken bundle).
// Without it, a registry-load failure would make `_hostBehaviors('claude')` return
// {} and silently route a claude LOCAL install to the repo-shared, committed
// `settings.json` instead of the gitignored `settings.local.json` (#338) — leaking
// engineer-specific absolute paths. Keyed by runtime id (a DATA lookup, not a
// hardcoded string-equality branch) so behavior degrades CLOSED (safe), never open.
// The live descriptor (capabilities/claude/capability.json) remains the source of
// truth; this mirrors only the privacy-load-bearing subset. (ADR-1239 / #2086)
//
// #2870: NOT routed through the Install Scope Module (resolveScope,
// src/install-scope.cts) despite that module owning per-scope settings-file
// resolution elsewhere in this file. resolveScope's own descriptor lookup
// goes through the SAME capability registry require this floor exists to
// survive the failure of (see getRegistry() in install-scope.cts) — so on
// exactly the "registry failed to load" path this constant is for,
// resolveScope would throw too. Routing through it here would trade a
// graceful, documented degrade for a crash in the one case this floor was
// added to prevent. This hardcoded literal is the correct, honest answer,
// not an un-migrated leftover.
const FALLBACK_HOST_BEHAVIORS = Object.freeze({
claude: Object.freeze({
settingsFileByScope: Object.freeze({ local: 'settings.local.json', global: 'settings.json' }),
permissionsSchema: 'claude',
sourceMarkerFile: '.msd-source',
hyphenNameAgentBody: true,
legacyCommandsMsdInstallMigration: true,
legacyCommandsMsdUninstall: 'global',
}),
// antigravity's global config dir is resolved dynamically (env-overridable,
// multi-segment) via resolveAntigravityGlobalDir in getConfigDirFromHome. If the
// registry fails to load, this floor keeps that routing intact instead of
// silently falling through to the generic getGlobalConfigHomeFragment default
// (which would return the wrong '.claude' fragment). (ADR-1239 / #2096)
antigravity: Object.freeze({ globalDirResolver: 'antigravity' }),
});
/**
* Resolve a runtime's host behaviors from a capability registry, with the
* #338-privacy fail-safe floor when the registry (or the runtime's descriptor)
* is unavailable. Registry is passed in so this is unit-testable under a
* simulated registry-load failure. (ADR-1239 / #2086)
*/
function _resolveHostBehaviors(runtime, registry) {
const cap = registry && registry.runtimes && registry.runtimes[runtime];
const declared = cap && cap.runtime && cap.runtime.hostBehaviors;
if (declared) return declared;
return FALLBACK_HOST_BEHAVIORS[runtime] || {};
}
/**
* Host-specific install behaviors, declared on the runtime descriptor
* (capabilities/<runtime>/capability.json -> runtime.hostBehaviors) instead of
* scattered `runtime === '<id>'` string checks (ADR-1239 / #2086). Returns {}
* for runtimes that declare none, so every behavior branch degrades to the
* generic path by default — EXCEPT the reference host's #338-critical keys, which
* fall back to FALLBACK_HOST_BEHAVIORS if the registry failed to load.
*/
function _hostBehaviors(runtime) {
return _resolveHostBehaviors(runtime, _capabilityRegistry);
}
/**
* #2870: shared install()/uninstall() scope resolution. Routes `id` through
* the Install Scope Module (src/install-scope.cts) and degrades to `null` on
* failure (unknown/non-installable runtime, broken registry bundle) instead
* of throwing, so each call site's own plain-id fallback keeps working
* exactly as it did before this migration. Both call sites previously carried
* their own copy of this try/catch; this is the one shared copy.
*/
function _resolveScopeSafe(id, runtime) {
try {
return resolveScope({ id, runtime });
} catch (_) {
return null;
}
}
/**
* Resolve a layout kind's on-disk destination directory. Shared by the
* skills-root resolution above and the #3664 warning below so the
* home-override + destSubpath join exists once (#3659-class re-encoding guard).
*/
function _kindDestDir(layout, kindName, targetDir) {
const kind = layout.kinds.find((k) => k.kind === kindName);
if (!kind) return null;
return path.join(kind.home || targetDir, kind.destSubpath);
}
/**
* #3738: scope-aware, layout-resolving wrapper over _kindDestDir for callers
* that have (runtime, configDir, scope) rather than a resolved Layout — the
* writeManifest agents surface being the first. Never throws: a runtime whose
* layout cannot be resolved (unknown id, descriptor error) keeps the caller's
* own fallback rather than losing the manifest.
*/
function _kindDestDirSafe(runtime, configDir, scope, kindName) {
try {
return _kindDestDir(resolveRuntimeArtifactLayout(runtime, configDir, scope), kindName, configDir);
} catch {
return null;
}
}
/**
* #3664 — warn (never refuse) when `--config-dir` points the install at a
* directory whose agent destination already holds FOREIGN (non-MSD) agent
* files — the fingerprint of another harness's config home (e.g. ~/.junie,
* ~/.factory) or a hand-curated agents dir. MSD emits the selected runtime's
* artifacts verbatim: tool IDs (`Skill`) and MCP grants (`mcp__server__tool`)
* that are inert or invalid in a foreign harness surface only at dispatch
* time, months later. Warn-and-proceed is the issue-sanctioned option (b):
* a fresh custom dir (the documented brand-specific-dir use), a msd-only dir
* (updates, the --all shared dir), and the no-flag default-home path
* (users keep personal agents in ~/.claude/agents) all stay silent. Degrades
* silently on any resolution failure — the warn path never blocks install.
*/
function warnIfForeignAgentDest(runtime, targetDir, scope, explicitConfigDir) {
if (explicitConfigDir !== true) return;
try {
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
const agentsDir = _kindDestDir(layout, 'agents', targetDir);
if (!agentsDir) return;
if (!fs.existsSync(agentsDir) || !fs.statSync(agentsDir).isDirectory()) return;
const foreign = fs
.readdirSync(agentsDir)
.filter((f) => f.endsWith('.md') && !f.startsWith('msd-') && f !== 'msd.md');
if (foreign.length === 0) return;
console.log(
` ${yellow}⚠${reset} ${bold}${targetDir}${reset} already contains ${foreign.length} non-MSD agent file(s) — this may be another harness's config home. MSD emits artifacts shaped for ${runtime}: tool IDs and MCP grants may be inert or invalid for whatever harness reads this directory (#3664).`
);
} catch (_) {
/* never block install on the warning path */
}
}
/**
* Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a
* skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills ->
* $HOME/.agents/skills instead of the runtime's configDir). Descriptor-driven
* (no runtime === '<id>' check) so the snapshot/rollback machinery and post-install
* verification look where the skills actually landed. Falls back to <targetDir>/skills.
*/
function _resolveSkillsRootDir(runtime, targetDir, scope) {
try {
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
const skillsDir = _kindDestDir(layout, 'skills', targetDir);
if (skillsDir) return skillsDir;
} catch (_e) { /* fall through to the configDir default */ }
return path.join(targetDir, 'skills');
}
/**
* Construct the imperative Host-Integration adapter (ADR-1239 / #2086), FAIL-OPEN.
* `createImperativeAdapter` composes the capability registry via
* `loadRegistry({includeInstalled:true})`, which require()s several capability
* modules. If any is unavailable (e.g. a packaging regression), return null so
* the caller degrades to the engine directly rather than hard-crashing install/
* uninstall — matching the optional `capability-registry.cjs` load posture above.
*/
function _runtimeAdapter(runtime) {
try {
return createImperativeAdapter({ runtime });
} catch {
return null;
}
}
const {
acquireInstallMigrationLock,
applyInstallerMigrationPlan,
discoverInstallerMigrations,
MANIFEST_SCHEMA_VERSION,
readInstallManifest,
runInstallerMigrations,
} = require(path.join(_msdLibDir, 'installer-migrations.cjs'));
const {
assertInstallerMigrationsUnblocked,
resolveInstallerMigrationPromptsForNonTty,
summarizeInstallerMigrationResult,
} = require(path.join(_msdLibDir, 'installer-migration-report.cjs'));
const {
resolveRuntimeArtifactLayout,
} = require(path.join(_msdLibDir, 'runtime-artifact-layout.cjs'));
const {
readSurface,
resolveSurface,
} = require(path.join(_msdLibDir, 'surface.cjs'));
const {
assertDestWithinConfigHome,
createRuntimeArtifactInstallPlan,
createRuntimeArtifactUninstallPlan,
} = require(path.join(_msdLibDir, 'runtime-artifact-install-plan.cjs'));
const {
planLegacyCleanup,
applyLegacyCleanup,
} = require(path.join(__dirname, '..', 'msd-core', 'bin', 'lib', 'legacy-cleanup.cjs'));
const {
updateCacheFileName,
PACKAGE_NAME,
} = require(path.join(__dirname, '..', 'msd-core', 'bin', 'lib', 'package-identity.cjs'));
// ADR-1239 Phase B: runtime-artifact install cluster extracted to install-engine.cjs.
// getCommitAttribution STAYS here (impure install-time config I/O); it is injected
// into the engine functions via the resolveAttribution parameter at each call site.
const installEngine = require(path.join(_msdLibDir, 'install-engine.cjs'));
// #2876: _copyStaged and convertClaudeCommandToOpencodeSkill used to be
// destructured here too — both had no install.js internal caller and no
// export consumer (tests import them directly from
// msd-core/bin/lib/install-engine.cjs), so the retired bindings were dead
// code. applyOpencodeFamilyPathPrefix, _runLegacyInstallMigrations,
// _runLegacyUninstallCleanup, _removeMsdEntries, and _restoreDir were the
// same — unused local bindings that were never part of this module's export
// surface either — found and retired in the same sweep.
const {
installRuntimeArtifacts,
uninstallRuntimeArtifacts,
installOpencodeFamilySkills,
installAgentsKindStandalone,
hasExistingSymlinkBetween,
isSymlinkedDestOptIn,
USER_OWNED_ARTIFACTS,
_snapshotDir,
} = installEngine;
// #2875 (epic #2866 Phase 6): durable on-disk staging for USER_OWNED_ARTIFACTS
// across the preserve -> wipe -> restore window (#1874-F19). See
// src/user-artifact-staging.cts's module doc.
const {
stageUserArtifacts,
restoreStagedUserArtifacts,
discardStagedUserArtifacts,
recoverOrphanedUserArtifacts,
} = require(path.join(_msdLibDir, 'user-artifact-staging.cjs'));
/**
* Resolve the durable staging root for `configDir`, confined via
* `assertDestWithinConfigHome` and refused via `hasExistingSymlinkBetween`
* (test-matrix E1/E4) — mirrors install-engine.cts's
* `_resolveUserArtifactStagingRoot`, kept local here because bin/install.js's
* two call sites (uninstall's legacy-migration block, install's mainline
* msd-core copy) are not inside that module.
*/
function _resolveUserArtifactStagingRoot(configDir) {
const stagingRoot = assertDestWithinConfigHome(configDir, path.posix.join('.msd-staging', 'user-artifacts'));
if (hasExistingSymlinkBetween(path.resolve(configDir), stagingRoot, { allowOptInFollow: isSymlinkedDestOptIn() })) {
throw new Error(
`_resolveUserArtifactStagingRoot: staging root "${stagingRoot}" contains a symlink the install root "${configDir}" does not trust — refusing to stage. If this is an intentional user-owned symlink layout, re-run with MSD_ALLOW_SYMLINKED_DEST=1.`,
);
}
return stagingRoot;
}
/**
* Degrade-not-abort wrapper over `_resolveUserArtifactStagingRoot` — mirrors
* install-engine.cts's own `_tryResolveUserArtifactStagingRoot` (kept local
* here for the same reason the throwing version above is: bin/install.js's
* own call sites are not inside that module). A hostile/broken
* `.msd-staging` path (or a symlinked configDir itself) must never brick
* `install()` or `uninstall()` — before this fix, `_resolveUserArtifactStagingRoot`
* was called UNGUARDED as the first statement of both, so
* `ln -s /nonexistent ~/.claude/.msd-staging` killed both commands, including
* uninstall, the remedy for the first problem. Returns `null` (never throws),
* logging one warning; every call site MUST treat `null` as "skip the
* staging-dependent step for this run".
*/
function _tryResolveUserArtifactStagingRoot(configDir) {
try {
return _resolveUserArtifactStagingRoot(configDir);
} catch (err) {
console.warn(` ${yellow}!${reset} user-artifact staging unavailable for "${configDir}" (${err.message}) — proceeding without durable staging for this step.`);
return null;
}
}
// Parse args
const args = process.argv.slice(2);
const hasGlobal = args.includes('--global') || args.includes('-g');
const hasLocal = args.includes('--local') || args.includes('-l');
const hasUninstall = args.includes('--uninstall') || args.includes('-u');
const hasSkillsRoot = args.includes('--skills-root');
const hasPortableHooks = args.includes('--portable-hooks') || process.env.MSD_PORTABLE_HOOKS === '1';
const hasMinimal = args.includes('--minimal') || args.includes('--core-only');
const hasDryRun = args.includes('--dry-run');
// #4377: emit project-relative `@` includes (`.claude/msd-core/...`) for a
// LOCAL install instead of this checkout's absolute path.
//
// Opt-in, and it stays opt-in: absolute includes work for a single checkout,
// which is nearly everyone, and flipping the default would change every
// existing local install to solve a problem those users do not have. The
// people who need it know they do — they run the same repo from several git
// worktrees, where a baked absolute path means every worktree reads its
// workflow prose out of whichever checkout happened to run the installer, and
// updating that one checkout breaks all the others at once with no way to
// stage it.
//
// Exported through the environment rather than threaded as a parameter,
// exactly like --portable-hooks/MSD_PORTABLE_HOOKS above: five separate seams
// compute a path prefix (the install engine, both rewrite entry points, the
// install plan, and applySurface), and one variable they all read cannot fall
// out of sync the way five signatures can.
const hasRelativeIncludes = args.includes('--relative-includes') || process.env.MSD_RELATIVE_INCLUDES === '1';
if (hasRelativeIncludes) process.env.MSD_RELATIVE_INCLUDES = '1';
// --profile=<name> or --profile=<n1>,<n2> (composable); mutually exclusive with --minimal
const _profileArgRaw = (() => {
for (const arg of args) {
if (arg.startsWith('--profile=')) return arg.slice('--profile='.length);
}
return null;
})();
// Resolve active profile name:
// 1. --minimal / --core-only → 'core' (back-compat alias)
// 2. --profile=<name> → named profile
// 3. neither → 'full' (default, back-compat)
// Note: when re-running as `msd update` the marker is read later (after
// configDir is resolved) and may override 'full' — see writeActiveProfile call below.
const _profileIsCore = _profileArgRaw === 'core';
const _requestedProfileName = (hasMinimal || _profileIsCore) ? 'core' : (_profileArgRaw || null);
if (hasMinimal && _profileArgRaw) {
console.error(` ${yellow}Cannot specify both --minimal/--core-only and --profile${reset}`);
process.exit(1);
}
function selectRuntimesFromArgs(runtimeArgs) {
if (runtimeArgs.includes('--all')) {
return ['claude', 'opencode', 'codex', 'antigravity', 'cursor', 'zcode'];
}
if (runtimeArgs.includes('--both')) {
return ['claude', 'opencode'];
}
const selected = [];
if (runtimeArgs.includes('--claude')) selected.push('claude');
if (runtimeArgs.includes('--opencode')) selected.push('opencode');
if (runtimeArgs.includes('--codex')) selected.push('codex');
if (runtimeArgs.includes('--antigravity')) selected.push('antigravity');
if (runtimeArgs.includes('--cursor')) selected.push('cursor');
if (runtimeArgs.includes('--zcode')) selected.push('zcode');
return selected;
}
// Runtime selection - can be set by flags or interactive prompt
let selectedRuntimes = selectRuntimesFromArgs(args);
// #1928: Google sunset Gemini CLI on 2026-06-18; Antigravity CLI is its
// official successor. `--gemini` is no longer a valid runtime selector —
// selectRuntimesFromArgs above no longer recognizes it, so it never lands in
// selectedRuntimes. Print a one-time redirect notice, and — when `--gemini`
// was the ONLY runtime flag supplied (selectedRuntimes is empty) — exit
// deterministically rather than silently falling through to the "no runtime
// specified" defaults below (which would install Claude Code, surprising a
// user who explicitly asked for Gemini). Other flags (e.g. `--codex`) still
// parse and install normally alongside the notice.
if (args.includes('--gemini')) {
const wantsHelp = args.includes('--help') || args.includes('-h');
console.error('Gemini CLI was sunset by Google on 2026-06-18 and is no longer served for free/Pro/Ultra tiers.');
console.error('MSD now supports Antigravity CLI (the official successor). Re-run with: --antigravity');
if (hasUninstall) {
// The gemini runtime was removed (#1928), so there is no automated
// `--gemini --uninstall`. Guide manual cleanup and exit — do NOT fall
// through to the uninstall dispatch below, which defaults an empty runtime
// selection to 'claude' and would wrongly uninstall the user's Claude install.
console.error('The gemini runtime was removed, so `--gemini --uninstall` is no longer available.');
console.error('To remove a prior Gemini install, delete MSD files under your Gemini config dir');
console.error('(e.g. ~/.gemini/commands/msd) and MSD hook entries in ~/.gemini/settings.json.');
process.exit(1);
}
// For `--gemini --help`, fall through so the usage block still prints. For a
// bare install attempt (no other runtime selected), exit rather than silently
// installing Claude.
if (!wantsHelp && selectedRuntimes.length === 0) {
process.exit(1);
}
}
// WSL + Windows Node.js detection
// When Windows-native Node runs on WSL, os.homedir() and path.join() produce
// backslash paths that don't resolve correctly on the Linux filesystem.
if (process.platform === 'win32') {
let isWSL = false;
try {
if (process.env.WSL_DISTRO_NAME) {
isWSL = true;
} else if (fs.existsSync('/proc/version')) {
const procVersion = fs.readFileSync('/proc/version', 'utf8').toLowerCase();
if (procVersion.includes('microsoft') || procVersion.includes('wsl')) {
isWSL = true;
}
}
} catch {
// Ignore read errors — not WSL
}
if (isWSL) {
console.error(`
${yellow}⚠ Detected WSL with Windows-native Node.js.${reset}
This causes path resolution issues that prevent correct installation.
Please install a Linux-native Node.js inside WSL:
curl -fsSL https://fnm.vercel.app/install | bash
fnm install --lts
Then re-run: npx ${pkg.name}@latest
`);
process.exit(1);
}
}
// getDirName (runtime -> local config dir name) now lives in
// runtime-name-policy.cjs (ADR-1508 / #1510 Phase 1); imported above for
// install.js's own internal use only — #2876 retired the re-export.
/**
* Get the config directory path relative to home directory for a runtime
* Used for templating hooks that use path.join(homeDir, '<configDir>', ...)
* @param {string} runtime - 'claude', 'opencode', 'codex', ...
* @param {boolean} isGlobal - Whether this is a global install
*/
function getConfigDirFromHome(runtime, isGlobal) {
if (!isGlobal) {
// Local installs use the same dir name pattern
return `'${getDirName(runtime)}'`;
}
// Global installs. antigravity's home is resolved dynamically (env-overridable,
// multi-segment via resolveAntigravityGlobalDir + path.relative) — not a table
// entry. (The prior inner `if (!isGlobal) return "'.agents'"` was unreachable:
// !isGlobal returns at the top of this function.)
// Descriptor-driven (ADR-1239 / #2096): folded from a hardcoded
// `runtime === 'antigravity'` literal into a read of the runtime's
// `hostBehaviors.globalDirResolver` descriptor field (via _hostBehaviors, which
// also degrades to FALLBACK_HOST_BEHAVIORS on registry-load failure). This is
// antigravity-unique: `globalDirResolver` is only set by antigravity.
if (_hostBehaviors(runtime).globalDirResolver === 'antigravity') {
const antigravityDir = resolveAntigravityGlobalDir();
const rel = path.relative(os.homedir(), antigravityDir);
const segments = rel.split(path.sep).filter(Boolean);
if (segments.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel)) {
return segments.map((seg) => `'${seg}'`).join(', ');
}
// If resolution points outside HOME (e.g. via env override), keep the
// stable legacy template so generated path.join() calls remain valid.
return "'.gemini', 'antigravity'";
}
// All other runtimes: single source-of-truth fragment table (ADR-1239 Phase B,
// #1679). claude/unknown fall through to the table's default '.claude'.
return getGlobalConfigHomeFragment(runtime);
}
/**
* Compatibility seam for tests and older installer consumers.
* Runtime home resolution now lives in runtime-homes.cjs.
*/
function getGlobalDir(runtime, explicitDir = null) {
return getGlobalConfigDir(runtime, explicitDir);
}
const banner = '\n' +
cyan + ' ███╗ ███╗███████╗██████╗\n' +
' ████╗ ████║██╔════╝██╔══██╗\n' +
' ██╔████╔██║███████╗██║ ██║\n' +
' ██║╚██╔╝██║╚════██║██║ ██║\n' +
' ██║ ╚═╝ ██║███████║██████╔╝\n' +
' ╚═╝ ╚═╝╚══════╝╚═════╝' + reset + '\n' +
'\n' +
' MSD Core ' + dim + 'v' + pkg.version + reset + '\n' +
' Make Software Done.\n' +
' A meta-prompting, context engineering and spec-driven\n' +
' development workflows for Claude Code, Codex, OpenCode, Cursor, ZCode and Antigravity.\n';
// Pure seam: parse --config-dir / -c from an arbitrary args array.
// Returns the path string, '' for an empty equals-form value, or null when the
// flag is absent. Space-separated form returns null (not an error string) when
// the next token is missing or flag-looking — callers that want process.exit
// behaviour must check after calling this function.
// Exported via module.exports so unit tests can exercise it directly.
function parseConfigDirFromArgs(argsArray) {
const configDirIndex = argsArray.findIndex(arg => arg === '--config-dir' || arg === '-c');
if (configDirIndex !== -1) {
const nextArg = argsArray[configDirIndex + 1];
// No value / next token is a flag → signal "missing" by returning null
if (!nextArg || nextArg.startsWith('-')) {
return null;
}
return nextArg;
}
// Handle --config-dir=value and -c=value format.
// Use indexOf('=') + 1 so that = signs inside the path value are preserved.
const configDirArg = argsArray.find(arg => arg.startsWith('--config-dir=') || arg.startsWith('-c='));
if (configDirArg) {
return configDirArg.slice(configDirArg.indexOf('=') + 1);
}
return null;
}
// Parse --no-legacy-cleanup (#3799) — skip the legacy get-shit-done-cc scan
// entirely. Some users run msd-core alongside a live legacy install on
// purpose; the scan is best-effbelt cleanup, never load-bearing for the
// install itself.
function parseNoLegacyCleanupArg(args = process.argv) {
return args.includes('--no-legacy-cleanup');
}
// Parse --config-dir argument
function parseConfigDirArg() {
const result = parseConfigDirFromArgs(args);
if (result === null) {
// Check if the space-separated form was present but missing a value
const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c');
if (configDirIndex !== -1) {
console.error(` ${yellow}--config-dir requires a path argument${reset}`);
process.exit(1);
}
return null;
}
if (result === '') {
console.error(` ${yellow}--config-dir requires a non-empty path${reset}`);
process.exit(1);
}
return result;
}
const explicitConfigDir = parseConfigDirArg();
const hasHelp = args.includes('--help') || args.includes('-h');
const forceStatusline = args.includes('--force-statusline');
if (!hasSkillsRoot) console.log(banner);
if (hasUninstall) {
console.log(' Mode: Uninstall\n');
}
// Show help if requested
if (hasHelp) {
console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall MSD (remove all MSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}--no-legacy-cleanup${reset} Skip the legacy get-shit-done-cc artifact scan\n (an explicit --config-dir already scopes the scan to it)\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n and resolve the node runner at hook-fire time via\n hooks/msd-node-runner.sh (WSL/Docker bind-mount\n setups; also MSD_PORTABLE_HOOKS=1)\n ${cyan}--relative-includes${reset} With --local: write project-relative @ includes\n (.claude/msd-core/...) instead of this checkout's\n absolute path, so several git worktrees of one repo\n each read their own copy (also MSD_RELATIVE_INCLUDES=1)\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`msd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --codex --global --config-dir ~/.codex-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Local install for a repo worked from several git worktrees${reset}\n npx ${pkg.name} --claude --local --relative-includes\n\n ${dim}# Uninstall MSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / CODEX_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / ZCODE_CONFIG_DIR environment variables.\n`);
process.exit(0);
}
// computePathPrefix: implementation moved to runtimeArtifactConversion._computePathPrefix
// (ADR-1508 / #1511 Phase 2 — single owner). The const binding above re-binds
// it here for install.js's own internal call sites only — #2876 retired the
// module.exports entry (zero consumers found; tests import
// runtimeArtifactConversion._computePathPrefix directly).
// Original doc: Compute the path prefix used for `@file` references in installed
// command/skill markdown. For global installs under $HOME uses $HOME/... form;
// OpenCode always uses the absolute path (#2376 Windows, #2831 macOS/Linux).
// resolveNodeRunner, resolveBashRunner, referencesHook are now owned by the
// runtime-hooks-surface module. Import them here so install.js callers
// continue to work and so there is a single implementation of these helpers.
// (normalizeNodePath was re-bound here too until #2876 found bin/install.js
// had no internal caller and no export consumer for it — hooksSurface owns
// the single implementation now, used internally by resolveNodeRunner there.)
const resolveNodeRunner = hooksSurface.resolveNodeRunner;
// #3662: the runtime-resolving runner token for managed JS hooks — the baked
// absolute node path tried FIRST, then `command -v node`, then well-known
// layouts, resolved by the shell at hook-fire time instead of bake time.
const buildNodeRunnerChainToken = hooksSurface.buildNodeRunnerChainToken;
const resolveBashRunner = hooksSurface.resolveBashRunner;
// referencesHook: pure predicate over hook entry objects, shared between
// install() and finishInstall() (ADR-857 phase 5f-1b).
const referencesHook = hooksSurface.referencesHook;
// applySettingsJsonHooks: mutates settings.hooks.* in place with all MSD-managed
// hook registrations for settings.json-surface runtimes (ADR-857 phase 5f-1b).
const applySettingsJsonHooks = hooksSurface.applySettingsJsonHooks;
// processAttribution: pure Co-Authored-By content transform, relocated to the
// conversion module (ADR-1508 / #1510 Phase 1). Bound here so install.js
// callers continue to work and there is a single implementation. (All call
// sites are below this line, so the const binding has no TDZ hazard.)
const processAttribution = runtimeArtifactConversion.processAttribution;
const filterRuntimeNotesForTarget = runtimeArtifactConversion.filterRuntimeNotesForTarget;
// computePathPrefix: implementation lives in runtimeArtifactConversion
// (ADR-1508 / #1511 Phase 2 — single owner). Re-bound here so install.js call
// sites continue to work. #2876 retired the sibling
// applyRuntimeContentRewritesInPlace / applyRuntimeContentRewritesForCommandsInPlace
// re-bindings that used to sit alongside it.
// (All call sites are below this line → no TDZ hazard.)
const computePathPrefix = runtimeArtifactConversion._computePathPrefix;
// #2931 (ADR-1508): single-sourced in the conversion module — was a second,
// unlinked verbatim copy here (used by the local Cursor converter below),
// the exact drift class this PR exists to reduce. Verified
// behaviorally identical (no block / one block / adjacent blocks / whole-
// content block / unclosed opening tag / nested-looking tags / repeated
// sequential calls for global-regex lastIndex leakage) before merging.
// install.js re-binds (does not re-define) — RUNTIME_COMPATIBILITY_BLOCK_RE
// is no longer duplicated here either. (All call sites are below this line
// → no TDZ hazard.)
const applyClaudeCodeBrandSwap = runtimeArtifactConversion.applyClaudeCodeBrandSwap;
function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
}
// #2876: reconcileManagedShellHookCommands, buildCodexHookBlock,
// rewriteLegacyCodexHookBlock, reconcileCodexHooksJsonEvent, and
// reconcileCodexHooksJsonSessionStart used to be re-bound here as one-line
// delegates to the equivalent hooksSurface.* implementations. None had an
// install.js internal caller or an export consumer (tests import all five
// directly from msd-core/bin/lib/runtime-hooks-surface.cjs), so the retired
// bindings were dead code with no reachable body — removed rather than kept
// as unreachable wrappers.
/**
* Ensure Codex hooks.json contains exactly one managed SessionStart
* msd-check-update hook entry, while preserving user-owned entries.
*
* Codex accepts hook config from hooks.json and config.toml. To avoid the
* startup warning for mixed representations in the same layer, MSD now stores
* the managed SessionStart hook in hooks.json and keeps config.toml for
* feature flags / agent metadata only.
*
* Supports both known hooks.json shapes:
* 1) { "SessionStart": [...] }
* 2) { "hooks": { "SessionStart": [...] } }
*
* On Windows, writes a .cmd shim alongside the .js hook file and uses the
* .cmd shim path as the hook command to avoid the `bash.exe: cannot execute
* binary file` failure (#3426).
*
* #772: also emits `commandWindows` in the hook entry so that a
* cross-platform hooks.json works on both POSIX and Windows without
* requiring per-OS regeneration. Codex dispatches `commandWindows` on
* Windows and `command` on other platforms (HookHandlerConfig in
* codex-rs/config/src/hook_config.rs).
*
* @param {string} targetDir
* @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts
* @returns {{ changed: boolean, wrote: boolean, path: string }}
*/
function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
return hooksSurface.ensureCodexHooksJsonSessionStart(targetDir, opts);
}
/**
* Remove a MSD-managed event entry from hooks.json. Called during uninstall,
* and (#2586) unconditionally during install/reinstall to clean up a
* pre-#2586 install's stale msd-context-monitor.js registrations — MSD no
* longer ADDS entries for these events (see CODEX_HOOKS_TO_COPY /
* cleanupOrphanedCodexContextMonitorScript in bin/install.js's Codex branch),
* only removes recognized ones, so the `ensureCodexHooksJsonEvent` wrapper
* that used to add them was removed as dead code.
*
* @param {string} targetDir
* @param {string} eventName
*/
function removeCodexHooksJsonEvent(targetDir, eventName) {
return hooksSurface.removeCodexHooksJsonEvent(targetDir, eventName);
}
function removeCodexHooksJsonSessionStart(targetDir) {
return hooksSurface.removeCodexHooksJsonSessionStart(targetDir);
}
/**
* Build a hook command path using forward slashes for cross-platform compatibility.
* On Windows, $HOME is not expanded by cmd.exe/PowerShell, so we use the actual path.
*
* @param {string} configDir - Resolved absolute config directory path
* @param {string} hookName - Hook filename (e.g. 'msd-statusline.js')
* @param {{ portableHooks?: boolean, platform?: NodeJS.Platform, runtime?: string }} [opts] - Options
* portableHooks: when true, emit $HOME-relative paths instead of absolute paths.
* Safe for Linux/macOS global installs and WSL/Docker bind-mount scenarios.
* Not suitable for pure Windows (cmd.exe/PowerShell do not expand $HOME).
* platform: test injection for shell command formatting. Defaults to process.platform.
* runtime: target runtime name for shell projection policy.
*/
function buildHookCommand(configDir, hookName, opts) {
return hooksSurface.buildHookCommand(configDir, hookName, opts);
}
/**
* Resolve the opencode config file path, preferring .jsonc if it exists.
*/
function resolveOpencodeConfigPath(configDir) {
const jsoncPath = path.join(configDir, 'opencode.jsonc');
if (fs.existsSync(jsoncPath)) {
return jsoncPath;
}
return path.join(configDir, 'opencode.json');
}
// #2087 — attribution config-path resolvers, keyed by descriptor (hostBehaviors.attributionConfigResolver)
const ATTRIBUTION_CONFIG_RESOLVERS = { opencode: resolveOpencodeConfigPath };
/**
* Strip JSONC comments (// and /* *​/) from a string to produce valid JSON.
* Handles comments inside strings correctly (does not strip them).
*/
function stripJsonComments(text) {
let result = '';
let i = 0;
let inString = false;
let stringChar = '';
while (i < text.length) {
// Handle string literals — don't strip comments inside strings
if (inString) {
if (text[i] === '\\') {
result += text[i] + (text[i + 1] || '');
i += 2;
continue;
}
if (text[i] === stringChar) {
inString = false;
}
result += text[i];
i++;
continue;
}
// Start of string
if (text[i] === '"' || text[i] === "'") {
inString = true;
stringChar = text[i];
result += text[i];
i++;
continue;
}
// Line comment
if (text[i] === '/' && text[i + 1] === '/') {
// Skip to end of line
while (i < text.length && text[i] !== '\n') i++;
continue;
}
// Block comment
if (text[i] === '/' && text[i + 1] === '*') {
i += 2;
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
i += 2; // skip closing */
continue;
}
result += text[i];
i++;
}
// Remove trailing commas before } or ] (common in JSONC)
return result.replace(/,\s*([}\]])/g, '$1');
}
/**
* Read and parse settings.json, returning empty object if it doesn't exist.
* Supports JSONC (JSON with comments) — many CLI tools allow comments in
* their settings files, so we strip them before parsing to avoid silent
* data loss from JSON.parse failures.
*/
function readSettings(settingsPath) {
if (fs.existsSync(settingsPath)) {
try {
const raw = fs.readFileSync(settingsPath, 'utf8');
let parsed;
// Try standard JSON first (fast path)
try { parsed = JSON.parse(raw); }
catch { parsed = JSON.parse(stripJsonComments(raw)); }
return parsed === null ? {} : parsed; // valid JSON null = empty settings, not malformed
} catch (e) {
// If even JSONC stripping fails, warn instead of silently returning {}
console.warn(' ' + yellow + '⚠' + reset + ' Warning: Could not parse ' + settingsPath + ' — file may be malformed. Existing settings preserved.');
return null;
}
}
return {};
}
/**
* Write settings.json with proper formatting.
*
* Atomic (temp+rename) because hosts discard the ENTIRE settings file on any
* parse failure, so a truncated write costs the user every hook, permission,
* and statusline they have — not just MSD's entries. This is the sole writer
* of that surface for six runtimes.
*
* `atomicWriteFileSync` is declared further down this file; it is dereferenced
* at call time, after module evaluation, so the ordering is safe.
*/
function writeSettings(settingsPath, settings) {
atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
}
// #2875 Part 2 (J8): model-override resolution (readMsdGlobalModelOverrides /
// readMsdEffectiveModelOverrides / readMsdRuntimeProfileResolver, plus the
// shared resolveAgentModelOverride precedence chain) was extracted into the
// shipped msd-core/bin/lib/install-model-override-resolver.cjs, mirroring
// install-effort-resolver.cjs's existing #2071 precedent, so the descriptor-
// driven agents pipeline (runtime-artifact-layout.cts's convertedAgentsKind)
// and this installer resolve model_overrides / model_profile_overrides
// through the SAME code — a single source of truth for the precedence chain
// the inline agent loop below used to duplicate across ~24 lines per runtime
// (opencode). See install-model-override-resolver.cts's module doc.
const {
readMsdEffectiveModelOverrides,
readMsdRuntimeProfileResolver,
resolveAgentModelOverride,
} = require(path.join(_msdLibDir, 'install-model-override-resolver.cjs'));
// #2875 Part 2: effort frontmatter injection moved to runtimeArtifactConversion
// (single source of truth with the descriptor pipeline's
// applyAgentFrontmatterExtensions step, which now also owns disallowedTools
// injection + the read-only agent deny-list internally — see its module doc
// in src/runtime-artifact-conversion.cts). injectEffortFrontmatter used to be
// re-bound here purely to stay on this module's export surface; #2876 found
// no install.js internal caller either (tests import it directly from
// msd-core/bin/lib/runtime-artifact-conversion.cjs) and retired the binding.
// Cache for attribution settings (populated once per runtime during install)
const attributionCache = new Map();
/**
* Get commit attribution setting for a runtime
* @param {string} runtime - 'claude', 'opencode', 'codex', ...
* @returns {null|undefined|string} null = remove, undefined = keep default, string = custom
*/
function getCommitAttribution(runtime) {
// Return cached value if available
if (attributionCache.has(runtime)) {
return attributionCache.get(runtime);
}
let result;
const _attrResolverKey = _hostBehaviors(runtime).attributionConfigResolver;
if (_attrResolverKey && ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey]) {
const resolveConfigPath = ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey];
const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
result = (config && config.disable_ai_attribution === true) ? null : undefined;
} else if (_hostBehaviors(runtime).attributionSource === 'settings-json-commit') {
// Claude Code
const settings = readSettings(path.join(getGlobalConfigDir(runtime, explicitConfigDir), 'settings.json'));
if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
result = undefined;
} else if (settings.attribution.commit === '') {
result = null;
} else {
result = settings.attribution.commit;
}
} else {
// Codex currently has no attribution setting equivalent
result = undefined;
}
// Cache and return
attributionCache.set(runtime, result);
return result;
}
// processAttribution (pure Co-Authored-By content transform) relocated to
// runtime-artifact-conversion.cjs (ADR-1508 / #1510 Phase 1); bound above.
// getCommitAttribution stays here — it is impure install-time config I/O.
/**
* Convert Claude Code frontmatter to opencode format
* - Converts 'allowed-tools:' array to 'permission:' object
* @param {string} content - Markdown file content with YAML frontmatter
* @returns {string} - Content with converted frontmatter
*/
// Color name to hex mapping for opencode compatibility
const colorNameToHex = {
cyan: '#00FFFF',
red: '#FF0000',
green: '#00FF00',
blue: '#0000FF',
yellow: '#FFFF00',
magenta: '#FF00FF',
orange: '#FFA500',
purple: '#800080',
pink: '#FFC0CB',
white: '#FFFFFF',
black: '#000000',
gray: '#808080',
grey: '#808080',
};
// Tool name mapping from Claude Code to OpenCode
// OpenCode uses lowercase tool names; special mappings for renamed tools
const claudeToOpencodeTools = {
AskUserQuestion: 'question',
SlashCommand: 'skill',
TodoWrite: 'todowrite',
WebFetch: 'webfetch',
WebSearch: 'websearch', // Plugin/MCP - keep for compatibility
};
// Tool name mapping from Claude Code to Antigravity
// Antigravity uses Gemini's snake_case built-in tool names
const claudeToAntigravityTools = {
// #4705: Antigravity-NATIVE tool names (see src/runtime-artifact-conversion.cts)
Read: 'view_file',
Write: 'write_file',
Edit: 'replace_file_content',
Bash: 'run_command',
Glob: 'glob',
Grep: 'grep_search',
WebSearch: 'google_web_search',
WebFetch: 'web_fetch',
TodoWrite: 'write_todos',
}
/**
* Convert a Claude Code tool name to OpenCode format
* - Applies special mappings (AskUserQuestion -> question, etc.)
* - Converts to lowercase (except MCP tools which keep their format)
*/
function convertToolName(claudeTool) {
// Check for special mapping first
if (claudeToOpencodeTools[claudeTool]) {
return claudeToOpencodeTools[claudeTool];
}
// MCP tools (mcp__*) keep their format
if (claudeTool.startsWith('mcp__')) {
return claudeTool;
}
// Default: convert to lowercase
return claudeTool.toLowerCase();
}
/**
* Convert a Claude Code tool name to Antigravity format
* - Applies Claude→Antigravity mapping (Read→read_file, Bash→run_shell_command, etc.)
* - Filters out MCP tools (mcp__*) — they are auto-discovered at runtime in Antigravity
* - Filters out Task/Agent — agents are auto-registered as tools in Antigravity
* @returns {string|null} Antigravity tool name, or null if tool should be excluded
*/
function convertAntigravityToolName(claudeTool) {
// MCP tools: exclude — auto-discovered from mcpServers config at runtime
if (claudeTool.startsWith('mcp__')) {
return null;
}
// Task/Agent: exclude — agents are auto-registered as callable tools.
// AskUserQuestion: exclude — Antigravity (Gemini tool dialect) does not expose
// an ask_user tool; emitting it causes frontmatter validation errors (#3362).
// Skill/SlashCommand: exclude — Antigravity (Gemini tool dialect) has no 'skill'
// built-in tool; the lowercase fallback would emit an invalid
// 'skill'/'slashcommand' name that fails frontmatter validation
// (tools.N: Invalid tool name) and aborts the entire agent load (#1394).
if (
claudeTool === 'Task' ||
claudeTool === 'Agent' ||
claudeTool === 'AskUserQuestion' ||
claudeTool === 'ask_user' ||
claudeTool === 'Skill' ||
claudeTool === 'SlashCommand'
) {
return null;
}
// Check for explicit mapping
if (claudeToAntigravityTools[claudeTool]) {
return claudeToAntigravityTools[claudeTool];
}
// Default: lowercase
return claudeTool.toLowerCase();
}
/**
* Map a skill directory name (msd-<cmd>) to the frontmatter `name:` used
* by Claude Code as the skill identity. Emits the hyphen form (msd-<cmd>)
* so Claude Code autocomplete shows the canonical invocation form, not the
* deprecated colon form. See #2808.
*
* Historical note: this previously returned `msd:<cmd>` (colon) because
* workflows called Skill(skill="msd:<cmd>"). Those calls have been updated
* to use hyphen form (#2808) so the colon rewrite is no longer needed.
*
* Codex must NOT use this helper: its adapter invokes skills as `$msd-<cmd>`
* (shell-var syntax) — hyphen form is already correct there.
*/
function skillFrontmatterName(skillDirName) {
if (typeof skillDirName !== 'string') return skillDirName;
// Return the hyphen form as-is (msd-<cmd>) — canonical since #2808.
return skillDirName;
}
/**
* Convert a Claude command (.md) to a Claude skill (SKILL.md).
* Claude Code is the native format, so minimal conversion needed —
* preserve allowed-tools as YAML multiline list, preserve argument-hint.
* Emits `name: msd-<cmd>` (hyphen) so Skill(skill="msd-<cmd>") calls and
* tab autocomplete use the canonical command namespace.
*/
function convertClaudeCommandToClaudeSkill(content, skillName, _runtime = null, cmdNames = null) {
const { frontmatter, body } = extractFrontmatterAndBody(content);
if (!frontmatter) return content;
// #3583: rewrite any /msd:<cmd> or msd:<cmd> in the body to the canonical
// hyphen form (msd-<cmd>) so installed SKILL.md bodies match the hyphen
// `name:` Claude Code registers under (#2808). `cmdNames`
// is optional and pre-computed by the caller for performance; direct test
// calls fall back to reading the list.
const names = cmdNames || readMsdCommandNames();
const normalizedBody = transformContentToHyphen(body, names);
// #4324: the description is the text the host's skill picker renders, so it
// needs the same hyphen normalisation the body gets — otherwise a `/msd:<cmd>`
// mention in a command description ships the retired colon form to the user.
const description = transformContentToHyphen(
extractFrontmatterField(frontmatter, 'description') || '', names,
);
const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
const agent = extractFrontmatterField(frontmatter, 'agent');
// #769: preserve context: from source command files so it is emitted into
// the installed SKILL.md frontmatter unchanged. (#3151: effort: is no longer
// emitted into skill frontmatter — a static effort value invalidates the
// caller's prompt cache at both scope boundaries.)
const context = extractFrontmatterField(frontmatter, 'context');
// Preserve allowed-tools as YAML multiline list (Claude native format)
const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m);
let toolsBlock = '';
if (toolsMatch) {
toolsBlock = 'allowed-tools:\n' + toolsMatch[1];
// Ensure trailing newline
if (!toolsBlock.endsWith('\n')) toolsBlock += '\n';
}
// Reconstruct frontmatter in Claude skill format
const frontmatterName = skillFrontmatterName(skillName);
let fm = `---\nname: ${frontmatterName}\ndescription: ${yamlQuote(description)}\n`;
if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
if (agent) fm += `agent: ${agent}\n`;
// #769: emit context: when present so the runtime can honour it natively
// (context: fork = isolated subagent window). Claude-specific; unknown
// frontmatter fields are silently ignored by other runtimes (backward-compatible).
// (#3151: effort: is intentionally NOT emitted into skill frontmatter — a
// static effort value changes output_config.effort on invocation and
// invalidates the caller's prompt cache at both scope boundaries.)
if (context) fm += `context: ${context}\n`;
if (toolsBlock) fm += toolsBlock;
fm += '---';
return `${fm}\n${normalizedBody}`;
}
/**
* Apply Antigravity-specific content conversion — path replacement + command name conversion.
* Path mappings depend on install mode:
* Global: ~/.claude/skills/ → ~/.gemini/config/skills/ (#3738),
* ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
* Local: ~/.claude/ → .agents/, ./.claude/ → ./.agents/
* Applied to ALL Antigravity content (skills, agents, engine files).
* @param {string} content - Source content to convert
* @param {boolean} [isGlobal=false] - Whether this is a global install
*/
function convertClaudeToAntigravityContent(content, isGlobal = false) {
let c = filterRuntimeNotesForTarget(content, 'antigravity');
if (isGlobal) {
// #3738: global skills install under ~/.gemini/config/skills (the dir AGY
// scans for global discovery), so skills-path references must divert there
// — BEFORE the configHome rewrite below, which is correct for msd-core
// runtime-file references (settings, workflows, VERSION) but wrong for the
// skills dir itself. Mirrors src/runtime-artifact-conversion.cts (ADR-1508
// keeps bin/install.js hand-authored; the two copies must stay in sync).
c = c.replace(/\$HOME\/\.claude\/skills\//g, '$HOME/.gemini/config/skills/');
c = c.replace(/~\/\.claude\/skills\//g, '~/.gemini/config/skills/');
// Bare skills form (no trailing slash) — must also precede the generic
// slash rule, which would otherwise divert it to the retired configHome
// path ($HOME/.gemini/antigravity/skills).
c = c.replace(/\$HOME\/\.claude\/skills\b/g, '$HOME/.gemini/config/skills');
c = c.replace(/~\/\.claude\/skills\b/g, '~/.gemini/config/skills');
c = c.replace(/\$HOME\/\.claude\//g, '$HOME/.gemini/antigravity/');
c = c.replace(/~\/\.claude\//g, '~/.gemini/antigravity/');
// Bare form (no trailing slash) — must come after slash form to avoid double-replace
c = c.replace(/\$HOME\/\.claude\b/g, '$HOME/.gemini/antigravity');
c = c.replace(/~\/\.claude\b/g, '~/.gemini/antigravity');
} else {
c = c.replace(/\$HOME\/\.claude\//g, '.agents/');
c = c.replace(/~\/\.claude\//g, '.agents/');
// Bare form (no trailing slash) — must come after slash form to avoid double-replace
c = c.replace(/\$HOME\/\.claude\b/g, '.agents');
c = c.replace(/~\/\.claude\b/g, '.agents');
}
c = c.replace(/\.\/\.claude\//g, './.agents/');
c = c.replace(/\.claude\//g, '.agents/');
// Command name conversion (all msd: references → msd-)
c = c.replace(/msd:/g, 'msd-');
// Runtime-neutral agent name replacement (#766)
c = neutralizeAgentReferences(c, 'GEMINI.md');
return c;
}
// isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
/**
* Convert a Claude command (.md) to an Antigravity skill (SKILL.md).
* Transforms frontmatter to minimal name + description only.
* Body passes through with path/command conversions applied.
*/
function convertClaudeCommandToAntigravitySkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
const converted = convertClaudeToAntigravityContent(content, isGlobal);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = skillName || extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
// #2876: quote description so YAML flow indicators in the source
// (e.g. `[BETA] …`) don't break downstream frontmatter parsers.
const fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n---`;
return `${fm}\n${body}`;
}
/**
* Convert a Claude agent (.md) to an Antigravity agent.
* Uses Gemini tool names since Antigravity runs on Gemini 3 backend.
*/
function convertClaudeAgentToAntigravityAgent(content, isGlobal = false) {
const converted = convertClaudeToAntigravityContent(content, isGlobal);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const color = extractFrontmatterField(frontmatter, 'color');
const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || '';
// Map tools to Antigravity equivalents (reuse existing convertAntigravityToolName)
const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean);
const mappedTools = claudeTools.map(t => convertAntigravityToolName(t)).filter(Boolean);
// #2876: quote description for the same reason as the skill variant.
// #4705: tools is a YAML SEQUENCE of native names (see the src twin).
const toolsBlock = mappedTools.length > 0
? `tools:\n${mappedTools.map((t) => `- ${t}`).join('\n')}\n`
: 'tools: []\n';
let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n${toolsBlock}`;
if (color) fm += `color: ${color}\n`;
fm += '---';
return `${fm}\n${body}`;
}
function toSingleLine(value) {
return value.replace(/\s+/g, ' ').trim();
}
function yamlQuote(value) {
return JSON.stringify(value);
}
function yamlIdentifier(value) {
const text = String(value).trim();
if (/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(text)) {
return text;
}
return yamlQuote(text);
}
function extractFrontmatterAndBody(content) {
if (!content.startsWith('---')) {
return { frontmatter: null, body: content };
}
const endIndex = content.indexOf('---', 3);
if (endIndex === -1) {
return { frontmatter: null, body: content };
}
return {
frontmatter: content.substring(3, endIndex).trim(),
body: content.substring(endIndex + 3),
};
}
function extractFrontmatterField(frontmatter, fieldName) {
const regex = new RegExp(`^${fieldName}:\\s*(.+)$`, 'm');
const match = frontmatter.match(regex);
if (!match) return null;
return match[1].trim().replace(/^['"]|['"]$/g, '');
}
// Tool name mapping from Claude Code to Cursor CLI
const claudeToCursorTools = {
Bash: 'Shell',
Edit: 'StrReplace',
AskUserQuestion: null, // No direct equivalent — use conversational prompting
SlashCommand: null, // No equivalent — skills are auto-discovered
};
function convertSlashCommandsToCursorSkillMentions(content) {
// Keep leading "/" for slash commands; only normalize msd: -> msd-.
// This preserves rendered "next step" commands like "/msd-execute-phase 17".
return content.replace(/msd:/gi, 'msd-');
}
function convertClaudeToCursorMarkdown(content) {
let converted = convertSlashCommandsToCursorSkillMentions(filterRuntimeNotesForTarget(content, 'cursor'));
// Replace tool name references in body text
converted = converted.replace(/\bBash\(/g, 'Shell(');
converted = converted.replace(/\bEdit\(/g, 'StrReplace(');
converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting');
// Replace subagent_type from Claude to Cursor format
converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
converted = converted.replace(/\$ARGUMENTS\b/g, '{{MSD_ARGS}}');
// Replace project-level Claude conventions with Cursor equivalents
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.cursor/rules/`');
converted = converted.replace(/\.\/CLAUDE\.md/g, '.cursor/rules/');
converted = converted.replace(/`CLAUDE\.md`/g, '`.cursor/rules/`');
converted = converted.replace(/\bCLAUDE\.md\b/g, '.cursor/rules/');
converted = converted.replace(/\.claude\/skills\//g, '.cursor/skills/');
// Remove Claude Code-specific bug workarounds before brand replacement
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
// Replace "Claude Code" brand references with "Cursor" — #2284(b): skips
// <runtime_compatibility> comparison-table content (protected region).
converted = applyClaudeCodeBrandSwap(converted, 'Cursor');
return converted;
}
function getCursorSkillAdapterHeader(skillName) {
return `<cursor_skill_adapter>
## A. Skill Invocation
- This skill is invoked when the user mentions \`${skillName}\` or describes a task matching this skill.
- Treat all user text after the skill mention as \`{{MSD_ARGS}}\`.
- If no arguments are present, treat \`{{MSD_ARGS}}\` as empty.
## B. User Prompting
When the workflow needs user input, prompt the user conversationally:
- Present options as a numbered list in your response text
- Ask the user to reply with their choice
- For multi-select, ask for comma-separated numbers
## C. Tool Usage
Use these Cursor tools when executing MSD workflows:
- \`Shell\` for running commands (terminal operations)
- \`StrReplace\` for editing existing files
- \`Read\`, \`Write\`, \`Glob\`, \`Grep\`, \`Task\`, \`WebSearch\`, \`WebFetch\`, \`TodoWrite\` as needed
## D. Subagent Spawning
When the workflow needs to spawn a subagent:
- Use \`Task(subagent_type="generalPurpose", ...)\`
- The \`model\` parameter maps to Cursor's model options (e.g., "fast")
</cursor_skill_adapter>`;
}
function convertClaudeCommandToCursorSkill(content, skillName) {
const converted = convertClaudeToCursorMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
let description = `Run MSD workflow ${skillName}.`;
if (frontmatter) {
const maybeDescription = extractFrontmatterField(frontmatter, 'description');
if (maybeDescription) {
description = maybeDescription;
}
}
description = toSingleLine(description);
const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
const adapter = getCursorSkillAdapterHeader(skillName);
// Cursor skills are both slash-invocable and model-invocable. Do not emit the
// unsupported `user-invocable` field: it is ignored by Cursor and previously
// hid the real cause of duplicate entries, the parallel commands/ surface
// retired in #2644.
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
}
/**
* Convert Claude Code agent markdown to Cursor agent format.
* Strips frontmatter fields Cursor doesn't support (color, skills),
* converts tool references, and adds a role context header.
*/
function convertClaudeAgentToCursorAgent(content) {
let converted = convertClaudeToCursorMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
return `${cleanFrontmatter}\n${body}`;
}
function convertSlashCommandsToCodexSkillMentions(content) {
// Colon-style /msd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below).
let converted = content.replace(/\/msd:([a-z0-9-]+)/gi, (_, commandName) => {
return `$msd-${String(commandName).toLowerCase()}`;
});
// Convert hyphen-style command references (workflow output) to Codex $ prefix.
// A real /msd-<cmd> MENTION is defined positively by two boundaries, so any
// in-path occurrence is excluded by construction (no denylist of preceding
// chars to maintain — see #712, supersedes the #637/#704 lookbehind treadmill):
// 1. Left boundary: opens at start-of-string, whitespace, or an inline-prose
// delimiter (backtick/quote/paren/bracket) — e.g. `/msd-execute-phase`.
// 2. Right boundary: the command token is NOT followed by a path separator
// `/` (a path continues: `/msd-core/bin/...`; a command does not). The
// `(?![a-z0-9/-])` also blocks regex backtracking to a shorter command.
// This converts backtick-wrapped MENTIONS (`/msd-foo`) while leaving backtick-
// wrapped PATHS (`/msd-core/workflows/update.md`) untouched (#712).
converted = converted.replace(/(?<=^|[\s`"'([])\/msd-([a-z0-9-]+)(?![a-z0-9/-])/gi, (_, commandName) => {
return `$msd-${String(commandName).toLowerCase()}`;
});
return converted;
}
const CODEX_MSD_TOOLS_INVOCATION = 'node "$HOME/.codex/msd-core/bin/msd-tools.cjs"';
function rewriteBareMsdToolsCommandsForCodex(content) {
return content
.replace(/(^[ \t]*)msd-tools(?=\s)/gm, `$1${CODEX_MSD_TOOLS_INVOCATION}`)
.replace(/(\$\(\s*)msd-tools(?=\s)/g, `$1${CODEX_MSD_TOOLS_INVOCATION}`)
.replace(/(`\s*)msd-tools(?=\s)/g, `$1${CODEX_MSD_TOOLS_INVOCATION}`)
.replace(/((?:&&|\|\||[;|])\s*)msd-tools(?=\s)/g, `$1${CODEX_MSD_TOOLS_INVOCATION}`);
}
function convertClaudeToCodexMarkdown(content) {
let converted = convertSlashCommandsToCodexSkillMentions(filterRuntimeNotesForTarget(content, 'codex'));
converted = converted.replace(/\$ARGUMENTS\b/g, '{{MSD_ARGS}}');
// Remove /clear references — Codex has no equivalent command
// Handle backtick-wrapped: `\/clear` then: → (removed)
converted = converted.replace(/`\/clear`\s*,?\s*then:?\s*\n?/gi, '');
// Handle bare: /clear then: → (removed)
converted = converted.replace(/\/clear\s*,?\s*then:?\s*\n?/gi, '');
// Handle standalone /clear on its own line
converted = converted.replace(/^\s*`?\/clear`?\s*$/gm, '');
// Path replacement: .claude → .codex (#1430)
converted = converted.replace(/\$HOME\/\.claude\//g, '$HOME/.codex/');
converted = converted.replace(/~\/\.claude\//g, '~/.codex/');
converted = converted.replace(/\.\/\.claude\//g, './.codex/');
// Bare ~/.claude without trailing slash (e.g. configDir = ~/.claude)
converted = converted.replace(/\$HOME\/\.claude\b/g, '$HOME/.codex');
converted = converted.replace(/~\/\.claude\b/g, '~/.codex');
// Bare/project-relative .claude/... references (#2639). Covers strings like
// "check `.claude/skills/`" where there is no ~/, $HOME/, or ./ anchor.
// Negative lookbehind prevents double-replacing already-anchored forms and
// avoids matching inside URLs or other slash-prefixed paths.
converted = converted.replace(/(?<![A-Za-z0-9_\-./~$])\.claude\//g, '.codex/');
// `.claudeignore` → `.codexignore` (#2639). Codex honors its own ignore
// file; leaving the Claude-specific name is misleading in agent prompts.
converted = converted.replace(/\.claudeignore\b/g, '.codexignore');
// Codex installs the tools shim under ~/.codex but does not guarantee a
// bare `msd-tools` binary on PATH. Keep resolver probes such as
// `command -v msd-tools` intact; rewrite only command invocations.
converted = rewriteBareMsdToolsCommandsForCodex(converted);
// Runtime-neutral agent name replacement (#766)
converted = neutralizeAgentReferences(converted, 'AGENTS.md');
return converted;
}
function getCodexSkillAdapterHeader(skillName) {
const invocation = `$${skillName}`;
return `<codex_skill_adapter>
## A. Skill Invocation
- This skill is invoked by mentioning \`${invocation}\`.
- Treat all user text after \`${invocation}\` as \`{{MSD_ARGS}}\`.
- If no arguments are present, treat \`{{MSD_ARGS}}\` as empty.
## B. AskUserQuestion → request_user_input Mapping
MSD workflows use \`AskUserQuestion\` (Claude Code syntax). Translate to Codex \`request_user_input\`:
Parameter mapping:
- \`header\` → \`header\`
- \`question\` → \`question\`
- Options formatted as \`"Label" — description\` → \`{label: "Label", description: "description"}\`
- Generate \`id\` from header: lowercase, replace spaces with underscores
Batched calls:
- \`AskUserQuestion([q1, q2])\` → single \`request_user_input\` with multiple entries in \`questions[]\`
Multi-select workaround:
- Codex has no \`multiSelect\`. Use sequential single-selects, or present a numbered freeform list asking the user to enter comma-separated numbers.
Execute mode fallback:
- When \`request_user_input\` is rejected or unavailable, activate TEXT_MODE: append \`--text\` to \`{{MSD_ARGS}}\` so the workflow's built-in text-mode branching takes over. Present every \`AskUserQuestion\` call as a plain-text numbered list, then stop and wait for the user's reply. Do NOT pick a default and continue (#3018 / #3808).
- You may only proceed without a user answer when one of these is true:
(a) the invocation included an explicit non-interactive flag (\`--auto\` or \`--all\`),
(b) the user has explicitly approved a specific default for this question, or
(c) the workflow's documented contract says defaults are safe (e.g. autonomous lifecycle paths).
- Do NOT write workflow artifacts (CONTEXT.md, DISCUSSION-LOG.md, PLAN.md, checkpoint files) until the user has answered the plain-text questions or one of (a)-(c) above applies. Surfacing the questions and waiting is the correct response — silently defaulting and writing artifacts is the #3018 failure mode.
## C. Task() → spawn_agent Mapping
MSD workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collaboration tools:
**Schema detection (required first step):** Before spawning, inspect the \`spawn_agent\`
tool's visible parameter schema (via \`tool_search\` or the tool list). Use the presence
of \`agent_type\` only to choose typed dispatch versus the generic-agent workaround.
Detect optional fields independently: \`model\`, \`reasoning_effort\`, \`task_name\`,
\`fork_turns\`, and \`fork_context\` may be added or removed without \`agent_type\` changing.
Never infer one field from a schema/version label or from the presence of another field.
- **agent_type-capable schema:** \`spawn_agent\` advertises \`agent_type\` — typed MSD agent dispatch is available.
- **Generic schema:** \`spawn_agent\` does not advertise \`agent_type\` — typed MSD agent dispatch is unavailable in this session, even if other optional fields are present.
Typed mapping (agent_type-capable schema only):
- \`Task(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
- \`Agent(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
- \`Task(model="{resolved_model}")\` → pass \`model="{resolved_model}"\` when the
visible \`spawn_agent\` schema advertises \`model\` and the resolved value is explicit.
This is how \`model_profile\` tier routing, including \`adaptive\`, reaches the child agent.
Omit \`model\` only when the schema does not advertise \`model\`, or when the value is
missing, empty, or \`"inherit"\`; omission deliberately inherits the session/static agent
configuration. Explicit \`model_overrides\` may also be embedded in agent \`.toml\` files,
but ordinary profile-resolved models are not, so a TOML file is not a reason to discard
an available inline value.
- Before each typed spawn, obtain the paired effort for its role with
\`msd_run query resolve-model <subagent_type> --pick effort\` when the workflow has not
already exposed it. The resolver's unified \`effort\` field maps to the Codex spawn argument
\`reasoning_effort\`; do not look for a resolver field named \`reasoning_effort\`.
Pass it when the visible \`spawn_agent\` schema advertises \`reasoning_effort\`. Omit the
field when it is not advertised, or when the value is missing, empty, \`"inherit"\`, or
unsupported; do not invent one-off effort literals in workflow prose.
- \`fork_context: false\` by default — MSD agents load their own context via \`<required_reading>\` blocks
- \`task_name\` — when advertised, provide a descriptive name for each spawned task
- \`fork_turns\` — when advertised, controls turn-forking depth; coexists with \`fork_context\` (not a replacement)
- \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct \`spawn_agent\` mapping,
but Codex declares \`dispatch.isolation: orchestrator-worktree\` (#2584). Codex
\`spawn_agent\` still does not create or bind a git worktree; instead MSD itself
creates the worktree and process-spawns the executor into it with
\`codex exec --cd <dir>\`, performing every git operation on the executor's behalf
(its \`workspace-write\` sandbox makes \`.git\` read-only). Workflows must therefore
never fabricate a manual worktree protocol — route through the negotiated
isolation adapter, which still fails closed for hosts declaring \`none\` (#3360).
Generic-agent workaround (multi_agent_v1 schema — NO agent_type field):
When only the generic \`multi_agent_v1\` schema is available, typed MSD agent dispatch
(\`msd-planner\`, \`msd-executor\`, etc.) is NOT possible. This is a known Codex limitation
(openai/codex#15250). **This workaround is NOT equivalent to typed msd-planner/msd-executor
execution** — MSD agents carry project-aware prompts, audit logging, and workflow context
that a generic subagent lacks. Use the following fallback:
1. Resolve your active Codex config root — the directory that contains your \`config.toml\`.
This directory is determined in priority order: \`$CODEX_HOME\` (if set), the path given
by \`--config-dir\` (if passed on invocation), a local \`.codex\` directory in the current
project (if \`--local\` was used), or the default global config directory. Read
\`agents/<agent-name>.toml\` relative to that config root to extract the agent's system
instructions.
2. Inject those instructions as a role-preamble into a generic \`spawn_agent(message=...)\` call.
3. Label results and logs clearly as "generic-agent workaround" so the orchestrator and user
know full typed-agent guarantees are not in effect.
4. Where typed dispatch is mandatory for correctness (e.g. worktree isolation), fail closed
and report the schema limitation rather than silently degrading.
Spawn restriction:
- Codex restricts \`spawn_agent\` to cases where the user has explicitly
requested sub-agents. When automatic spawning is not permitted, do the
work inline in the current agent rather than attempting to force a spawn.
- In some Codex sessions, multi-agent tooling can be deferred. If \`spawn_agent\`
is not currently visible, discover tools first via \`tool_search\` before
defaulting to inline execution.
Parallel fan-out:
- Spawn multiple agents → collect agent IDs → \`collaboration.wait_agent(timeout_ms=...)\` for each to complete
- Do NOT use \`functions.wait(cell_id=...)\` — that is an unrelated exec-cell tool, not the collaboration wait
Result parsing:
- Look for structured markers in agent output: \`CHECKPOINT\`, \`PLAN COMPLETE\`, \`SUMMARY\`, etc.
- \`close_agent(id)\` after collecting results — but only if \`close_agent\` is visible in the current
tool schema (check via \`tool_search\` first, same schema-detection gate as \`spawn_agent\` above)
</codex_skill_adapter>`;
}
function convertClaudeCommandToCodexSkill(content, skillName) {
const converted = convertClaudeToCodexMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
let description = `Run MSD workflow ${skillName}.`;
if (frontmatter) {
const maybeDescription = extractFrontmatterField(frontmatter, 'description');
if (maybeDescription) {
description = maybeDescription;
}
}
description = toSingleLine(description);
const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
const adapter = getCodexSkillAdapterHeader(skillName);
return `---\nname: ${yamlQuote(skillName)}\ndescription: ${yamlQuote(description)}\nmetadata:\n short-description: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
}
/**
* Convert Claude Code agent markdown to Codex agent format.
* Applies base markdown conversions, then adds a <codex_agent_role> header
* and cleans up frontmatter (removes tools/color fields).
*/
function convertClaudeAgentToCodexAgent(content) {
let converted = convertClaudeToCodexMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const tools = extractFrontmatterField(frontmatter, 'tools') || '';
const roleHeader = `<codex_agent_role>
role: ${name}
tools: ${tools}
purpose: ${toSingleLine(description)}
</codex_agent_role>`;
const cleanFrontmatter = `---\nname: ${yamlQuote(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
return `${cleanFrontmatter}\n\n${roleHeader}\n${body}`;
}
/**
* #2310 — True if `model` is an Anthropic-flavored value that must never appear as a
* Codex agent `.toml` `model`. Two forms: (a) a bare Claude Agent-tool tier alias
* (opus/sonnet/haiku/fable — the canonical CLAUDE_AGENT_ALIASES); (b) any Claude model
* id in any provider namespacing — `claude-*`, `anthropic/claude-*`, `us.anthropic.claude-*`
* (the forms the catalog assigns to opencode, reachable on a Codex .toml via
* the runtime-resolver path). No OpenAI/Codex model id contains "claude", so a
* case-insensitive substring test is a safe, exhaustive guard for (b). Codex/ChatGPT
* rejects all of these.
*
* #3241 — thin delegation to the shared predicate on src/model-catalog.cts (moved there
* so it can't diverge across Codex-posture surfaces); kept as a local name because it
* reads better at the call sites below.
*/
function _isAnthropicFlavoredModel(model) {
return msdIsAnthropicFlavoredModel(model);
}
// #2310 — dedupe stderr warnings so repeated agent emits don't spam (mirrors the
// #2041/#1133 model-resolver warn-dedupe). Value is length-capped so an oversized
// or secret-shaped override cannot leak in full to logs.
const _codexModelOverrideDroppedWarned = new Set();
function _warnCodexModelOverrideDropped(agentName, value) {
const key = `${agentName}::${value}`;
if (_codexModelOverrideDroppedWarned.has(key)) return;
_codexModelOverrideDroppedWarned.add(key);
const safe = String(value).length > 64 ? `${String(value).slice(0, 64)}…` : String(value);
process.stderr.write(
`msd: warning — Codex agent "${agentName}" model "${safe}" is not a valid Codex model ` +
`(Anthropic alias/id); dropping it so Codex uses a valid default. ` +
`Set runtime:"codex" or pin a gpt-* model to route it.\n`,
);
}
// #3241 — one-time per-install deprecation notice: the automatic runtime-resolver
// per-tier Codex model embed was removed (D1/D5, ADR-2313 passive-posture epic). When
// the resolver *would have* supplied a model and nothing else ends up pinned, this
// notice points the user at model_overrides as the explicit-pin replacement. Dedupes
// with a module-level boolean (mirrors _codexModelOverrideDroppedWarned's Set above)
// so a multi-agent install — every Codex agent hits this condition simultaneously —
// emits exactly one line, not one per agent. Reset once per install() call (see
// install()) so the "at most once" window is per-install, not per-process. That
// reset lives ONLY inside install() (~:10116) — generateCodexAgentToml is also
// exported standalone (~:13460), and a caller invoking it directly/repeatedly
// outside install() gets process-lifetime dedupe instead of per-install. No
// current test depends on the standalone caller's dedupe window.
let _codexResolverModelOmittedWarned = false;
function _warnCodexResolverModelOmitted() {
if (_codexResolverModelOmittedWarned) return;
_codexResolverModelOmittedWarned = true;
process.stderr.write(
'msd: notice — Codex agents no longer auto-pin a per-tier model from the runtime ' +
'resolver; set model_overrides for an agent if you want a specific Codex model ' +
'instead of the session model.\n',
);
}
// Test seam only — bin/install.js deliberately keeps per-install warning/notice
// dedupe in module scope (both the _codexModelOverrideDroppedWarned Set above and
// the _codexResolverModelOmittedWarned boolean; install() resets the latter at
// ~:10116). A unit test that drives generateCodexAgentToml() directly, without
// going through install(), has no other way to reset either store between
// assertions without busting the require.cache (which breaks module-instance
// sharing with the rest of the suite). This is the single sanctioned way for a
// unit test to clear both dedupe stores — exported so tests can call it instead.
function _resetCodexWarningDedupeForTests() {
_codexModelOverrideDroppedWarned.clear();
_codexResolverModelOmittedWarned = false;
}
/**
* Generate a per-agent .toml config file for Codex.
* Sets required agent metadata, sandbox_mode, and developer_instructions
* from the agent markdown content.
*
* @param {string} agentName
* @param {string} agentContent
* @param {object|null} modelOverrides
* @param {object|null} runtimeResolver — runtime-aware tier resolver from readMsdRuntimeProfileResolver
* @param {object|null} effortCfg — #443: merged effort config from readMsdEffectiveEffortConfig
*/
function generateCodexAgentToml(agentName, agentContent, modelOverrides = null, runtimeResolver = null, effortCfg = null, sandboxTier = 'codex-agent-sandbox') {
const { frontmatter, body } = extractFrontmatterAndBody(agentContent);
const frontmatterText = frontmatter || '';
// #3897 list-form parse fix, Fix 3: `toolsRaw` MUST come from the same
// shared `extractToolsValue` reader `checkCodexSandboxPosture` uses, not
// this file's own `extractFrontmatterField` — the two used to disagree on
// YAML block-list `tools:` form (`extractFrontmatterField`'s single-line
// regex read only the first list item), which is exactly the generative-
// fix-divergence shape CLAUDE.md warns about for two paths feeding one
// derivation. `extractToolsValue` does its own `---`-delimited frontmatter
// scan of the full `agentContent`, so it is not re-derived from
// `frontmatterText` here.
const toolsRaw = extractToolsValue(agentContent) ?? '';
const resolvedName = extractFrontmatterField(frontmatterText, 'name') || agentName;
// #3897 rung 3 — derived from the role's own tool contract (HALT.md option
// 2). The former hand-maintained CODEX_AGENT_SANDBOX map is deleted (ADR-3473
// §8.3): it was fully redundant with this derivation, zero disagreements
// across all 11 entries. Never a silent `|| 'read-only'` fallback either.
// Derivation itself lives in codex-agent-toml.cjs, which does no frontmatter
// parsing of its own (no third copy of that extraction) — it takes the
// already-resolved `tools:` value, extracted via the shared reader above.
//
// #3897 security review F1 (blocker): pass BOTH candidate identities —
// `agentName` (the caller's filename-stem identity) AND `resolvedName`
// (the frontmatter `name:` this function's OWN emitted `name = ...` line
// uses, and what `installCodexConfig`'s caller keys the output PATH on) —
// never just one. Deciding the sandbox for `agentName` alone and applying
// it to an artifact that a DIFFERENT identity (`resolvedName`) names is
// exactly how a held role's own `.toml` could end up `workspace-write`
// (rename the source file, or plant a sibling whose `name:` collides with
// a held role). `deriveCodexSandboxMode` takes the most restrictive result
// across every candidate — see its doc and `isSandboxHeld` in
// codex-agent-toml.cjs.
const sandboxMode = deriveCodexSandboxMode([agentName, resolvedName], toolsRaw);
const resolvedDescription = toSingleLine(
extractFrontmatterField(frontmatterText, 'description') || `MSD agent ${resolvedName}`
);
const instructions = body.trim();
const lines = [
`name = ${JSON.stringify(resolvedName)}`,
`description = ${JSON.stringify(resolvedDescription)}`,
];
if (sandboxTier != null && sandboxTier !== 'none') {
lines.push(`sandbox_mode = "${sandboxMode}"`);
}
// Embed model override when configured in ~/.msd/defaults.json so that
// model_overrides is respected on Codex (which uses static TOML, not inline
// Task() model parameters). See #2256.
// #2310 — a Codex .toml `model` MUST be a real Codex/OpenAI model id. Codex is a
// passive/session-only model host (ADR-1239): MSD cannot reliably route per-agent
// tiers, and a bare MSD/Claude tier alias (opus/sonnet/haiku/fable) or a claude-*
// id 400s on a ChatGPT-account Codex ("The 'sonnet' model is not supported when
// using Codex with a ChatGPT account"). So: embed ONLY an explicit real-Codex
// model pin from model_overrides; omit anything Anthropic-flavored so the agent
// inherits the always-available session model.
// #3241 (D1/D5) — the runtime-aware tier-resolver auto-embed that used to fall
// through here when model_overrides had nothing was removed: Codex is passive by
// default now, and only an explicit model_overrides pin survives. See the
// deprecation-notice block below for the population that used to get a pin from
// the resolver and no longer does.
const rawModelOverride = modelOverrides?.[resolvedName] || modelOverrides?.[agentName];
let pinnedModel = null;
if (rawModelOverride) {
// Trim before the truthiness test (#3241 defect fix): a whitespace-only value
// (e.g. ' ') is a truthy JS string but not a model id — it must not be
// embedded verbatim (`model = " "`, a live pre-fix defect) or routed to
// _warnCodexModelOverrideDropped, whose "is not a valid Codex model
// (Anthropic alias/id)" text would misdescribe a blank config field. It is
// silently dropped, matching how '' already behaves (no pin, no warning).
const trimmedOverride = typeof rawModelOverride === 'string' ? rawModelOverride.trim() : rawModelOverride;
if (typeof trimmedOverride === 'string' && trimmedOverride && !_isAnthropicFlavoredModel(trimmedOverride)) {
pinnedModel = trimmedOverride; // explicit real-Codex model pin → embed verbatim (#2256)
} else if (typeof rawModelOverride === 'string' && trimmedOverride === '') {
// whitespace-only override — no pin, no warning (#3241).
} else {
_warnCodexModelOverrideDropped(resolvedName, rawModelOverride); // alias/claude-* → omit
}
}
// #2310 — final safety gate: never emit an Anthropic-flavored model into a Codex
// .toml, even one that reached here through some other path than the override
// check above.
if (pinnedModel && _isAnthropicFlavoredModel(pinnedModel)) {
_warnCodexModelOverrideDropped(resolvedName, pinnedModel);
pinnedModel = null;
}
// #3241 — one-time deprecation notice: if nothing ends up pinned but the
// runtime resolver would have supplied a per-tier model that would actually
// have been EMBEDDED (the population that loses a pin now that the
// auto-embed above is gone), point the user at model_overrides. The would-be
// model must also clear the #2310 Anthropic-flavored gate above — if it
// wouldn't have survived that gate, the user never had that pin pre-Phase-1
// either, and the notice would be false. Never fires when the resolver is
// null, resolves to nothing, resolves to an Anthropic-flavored model, or an
// explicit real-Codex pin survived — in all of those cases nothing was lost.
if (!pinnedModel && runtimeResolver) {
const wouldHavePinned = runtimeResolver.resolve(resolvedName) || runtimeResolver.resolve(agentName);
if (wouldHavePinned?.model && !_isAnthropicFlavoredModel(wouldHavePinned.model)) {
_warnCodexResolverModelOmitted();
}
}
let hasPinnedModel = false;
if (pinnedModel) {
lines.push(`model = ${JSON.stringify(pinnedModel)}`);
hasPinnedModel = true;
// model is resolved here; reasoning_effort from catalog tier is REPLACED by the
// unified effort resolver below (#443). Do NOT emit entry.reasoning_effort here.
}
// #443 — Unified effort for Codex .toml. Uses the same config-driven precedence chain
// as the Claude .md effort injection (resolveInstallTimeEffort), so both runtimes read
// from the same effort.agent_overrides / effort.routing_tier_defaults / effort.default
// config source. #3007 — Codex advertises supported_reasoning_levels per model, so the
// pinned model id is passed through and the value is resolved against that model's own
// set: 'max' now passes, 'minimal' clamps up to 'low', and 'ultra' is refused (no key
// emitted) rather than clamped to a fabricated level.
// #838 — Do not pin effort when Codex is intentionally inheriting the parent
// chat model. A TOML with no `model` but a static `model_reasoning_effort`
// creates confusing partial routing: model follows the Codex UI while effort
// follows MSD. Keep those knobs coupled unless MSD also pins the model.
if (hasPinnedModel) {
const _universalEffortCodex = resolveInstallTimeEffort(effortCfg, resolvedName !== agentName ? resolvedName : agentName);
// #3533 (10d): 'inherit' means OMIT the pin — the agent follows the host's
// own effort default. Never write the literal.
if (_universalEffortCodex !== 'inherit') {
const _renderedEffortCodex = _getMsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex, pinnedModel).value;
// #3007 — 'ultra' is rejected by the model's supported_reasoning_levels and
// renders as null. Omit the key entirely rather than write a literal `null`.
if (_renderedEffortCodex !== null) {
lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
}
}
}
// #774 — Emit service_tier and model_verbosity for light-tier agents.
// Light-tier agents (routingTier: "light" in model-catalog.json) are haiku-equivalent
// and benefit from Codex's "flex" service tier (lower cost, background processing)
// and "low" verbosity (reduced token output). Both fields are validated against the
// Codex ConfigProfile schema (codex-rs/config/src/profile_toml.rs):
// service_tier: Option<String> — "flex" | "fast" (legacy)
// model_verbosity: Option<Verbosity> — "low" | "medium" | "high"
const { AGENT_DEFAULT_TIERS: _agentTiers } = _getMsdEffortCatalog();
const _agentRoutingTier = _agentTiers?.[resolvedName] || _agentTiers?.[agentName];
if (_agentRoutingTier === 'light') {
lines.push(`service_tier = "flex"`);
lines.push(`model_verbosity = "low"`);
}
// Agent prompts contain raw backslashes in regexes and shell snippets.
// TOML literal multiline strings preserve them without escape parsing.
lines.push(`developer_instructions = '''`);
lines.push(instructions);
lines.push(`'''`);
return lines.join('\n') + '\n';
}
/**
* Remove stale agents/openai.yaml sidecar files from MSD-managed Codex skill dirs.
*
* Prior to #1326, MSD's Codex install path wrote an agents/openai.yaml file
* alongside each msd-* SKILL.md. Recent Codex builds index BOTH SKILL.md and
* the sidecar, causing each MSD skill to appear twice in autocomplete. This
* function removes those stale sidecars and — if the agents/ subdirectory is
* now empty — prunes it too.
*
* Behaviour:
* - Returns immediately if skillsDir does not exist (fails open).
* - Only touches directories whose names start with "msd-".
* - Skips user-owned dirs (msd-dev-preferences) — their agents/ content is
* never modified, mirroring the same USER_OWNED_SKILL_DIRS guard used by
* installOpencodeFamilySkills.
* - For each managed msd-* dir, if agents/openai.yaml exists, deletes it.
* - If agents/ is now empty, removes the directory; if it still contains
* other files (e.g. user-added content), leaves it in place.
* - Non-msd-* dirs and their agents/ content are never touched.
* - Individual failures are caught and swallowed so a single bad dir cannot
* block the install (fail-open, matching the original design).
*
* @param {string} skillsDir - Path to the skills/ directory (e.g. ~/.codex/skills)
*/
function cleanupCodexSkillMetadataSidecars(skillsDir) {
if (!fs.existsSync(skillsDir)) return;
// Mirror the user-owned list from installOpencodeFamilySkills (#2973).
// We MUST skip these dirs — their contents are user-generated and must
// never be modified by MSD's install path.
const _userOwnedSkillDirs = new Set(['msd-dev-preferences']);
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('msd-')) continue;
if (_userOwnedSkillDirs.has(entry.name)) continue; // preserve user content
const agentsSubdir = path.join(skillsDir, entry.name, 'agents');
const sidecarPath = path.join(agentsSubdir, 'openai.yaml');
try {
// Symlink guard: if agents/ is a symlink pointing outside the skills tree,
// deleting through it could escape the tree. Skip this dir entirely.
let agentsStat;
try { agentsStat = fs.lstatSync(agentsSubdir); } catch (_e) { continue; }
if (agentsStat.isSymbolicLink()) continue;
if (fs.existsSync(sidecarPath)) {
fs.rmSync(sidecarPath);
}
// Prune the agents/ dir only if it is now empty (leave it if other files remain).
if (fs.existsSync(agentsSubdir) && fs.readdirSync(agentsSubdir).length === 0) {
fs.rmdirSync(agentsSubdir);
}
} catch (_err) {
// Fail open — a single bad dir must not block the install.
}
}
}
/**
* Migrate a skills kind that moved to an alternate `home` (ADR-1239 split-home):
* remove now-stale `<prefix>*` skill dirs left at the OLD configDir-rooted
* location by installs from before the move. Without this, upgrading (e.g. Codex
* relocating skills to ~/.agents/skills) orphans the pre-move dirs at
* ~/.codex/skills. Only managed `<prefix>*` dirs are touched; user-owned content
* (non-prefixed dirs, msd-dev-preferences, symlinks) is preserved. Fail-open.
* @param {string} oldSkillsDir absolute path to the pre-move skills location
* @param {string} prefix managed skill-dir prefix (e.g. 'msd-')
* @returns {number} count of stale dirs removed
*/
function cleanupMovedSkillsOldLocation(oldSkillsDir, prefix) {
if (!fs.existsSync(oldSkillsDir)) return 0;
// Mirror the user-owned list from cleanupCodexSkillMetadataSidecars (#2973).
const _userOwnedSkillDirs = new Set(['msd-dev-preferences']);
let removed = 0;
for (const entry of fs.readdirSync(oldSkillsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
if (_userOwnedSkillDirs.has(entry.name)) continue;
const dirToRemove = path.join(oldSkillsDir, entry.name);
try {
// Symlink guard: never delete
// through a symlinked msd-* dir — it could escape the tree.
const stat = fs.lstatSync(dirToRemove);
if (stat.isSymbolicLink()) continue;
fs.rmSync(dirToRemove, { recursive: true, force: true });
removed++;
} catch (_err) {
// Fail open — a single bad dir must not block install/uninstall.
}
}
// Prune the old skills dir if now empty — leaves the configHome clean.
// Never remove a non-empty container (user may keep other content there).
try {
if (fs.existsSync(oldSkillsDir) && fs.readdirSync(oldSkillsDir).length === 0) {
fs.rmdirSync(oldSkillsDir);
}
} catch (_err) {
// best-effort container cleanup
}
return removed;
}
/**
* When a runtime's skills kind declares an alternate `home` (split-home move),
* return the now-stale configDir-rooted skills location that installs before the
* move used; null when no move is in effect (no home override, or home resolves
* to the same path). Descriptor-driven — no per-runtime hardcoding.
* @returns {string|null}
*/
function _resolveMovedSkillsOldDir(runtime, targetDir, scope) {
try {
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
if (skillsKind && skillsKind.home) {
const oldDir = path.join(targetDir, skillsKind.destSubpath);
const newDir = path.join(skillsKind.home, skillsKind.destSubpath);
if (path.resolve(oldDir) !== path.resolve(newDir)) return oldDir;
}
} catch (_e) {
// No migration when the layout can't resolve — never block on this.
}
return null;
}
/**
* Generate the MSD config block for Codex config.toml.
*
* #2406 — standalone per-agent TOMLs (written by installCodexConfig to
* `$CODEX_HOME/agents/<name>.toml`) are auto-discovered by Codex and are the
* SOLE canonical registration source for each role. This block therefore no
* longer emits `[agents.<name>]` role tables that point `config_file` back at
* those same standalone TOMLs — that was a second, redundant declaration of
* the same role in one config layer, and Codex logged "Ignoring malformed
* agent role definition: duplicate agent role name" once per agent as a
* result. Only the bare `[agents]` dispatch-tuning scalar table is emitted
* here; role name/description/model/reasoning-effort/sandbox settings remain
* fully discoverable through the standalone TOML alone.
* @param {Array<{name: string, description: string}>} _agents unused — kept
* in the signature for call-site compatibility (installCodexConfig and
* existing tests still pass it positionally); per-agent role tables are no
* longer generated from it.
* @param {string} [_targetDir] unused — the standalone-TOML `config_file`
* path it used to resolve is no longer emitted here; kept for the same
* call-site-compatibility reason as `_agents`.
*/
function generateCodexConfigBlock(_agents, _targetDir) {
const lines = [
MSD_CODEX_MARKER,
'',
];
// ADR-1239 upgrade 2 / #2088 — explicit dispatch tuning. Pin `max_depth` on the
// `[agents]` (AgentsToml) table rather than relying on codex-cli's implicit
// default, realizing the negotiated `dispatch.maxDepth: 1` axis. This bare
// `[agents]` scalar table is validated by validateCodexConfigSchema, which
// permits a known-scalar-only `[agents]`.
lines.push('[agents]');
lines.push(`max_depth = ${MSD_CODEX_AGENTS_MAX_DEPTH}`);
lines.push('');
return lines.join('\n');
}
/**
* Extract a user's pre-existing AgentsToml scalar assignments from a bare
* `[agents]` table — every known scalar EXCEPT `max_depth` (which MSD manages
* and always re-emits as 1). Returned as raw `key = value` line strings so
* mergeCodexConfig can PRESERVE them in the managed block instead of silently
* dropping the user's tuning when the bare `[agents]` table is purged (#2088
* review finding: the loosened validator declares such a table legitimate, so
* install must not destroy it). Only the first bare `[agents]` section is read;
* `[agents.<name>]` role tables are ignored. Fail-open → [].
* @returns {string[]}
*/
function extractCodexUserAgentsScalars(content) {
const preserved = [];
let section;
try {
section = getTomlTableSections(content).find((s) => !s.array && s.path === 'agents');
} catch (_e) {
return preserved;
}
if (!section) return preserved;
const body = content.slice(section.headerEnd, section.end);
for (const record of getTomlLineRecords(body)) {
if (record.startsInMultilineString || record.tableHeader) continue;
const trimmed = record.text.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (!record.keySegments || record.keySegments.length !== 1) continue;
const key = record.keySegments[0];
if (key === 'max_depth') continue; // MSD-managed — MSD's value wins.
if (!CODEX_AGENTS_TOML_SCALAR_KEYS.has(key)) continue;
preserved.push(trimmed);
}
return preserved;
}
/**
* Splice preserved user AgentsToml scalar lines into the managed MSD config
* block, immediately after the `[agents]` header and before MSD's `max_depth`
* line. Operates on the pre-EOL-normalization block (LF joins), matching only
* the bare `[agents]` header (never `[agents.<name>]`). Returns the block
* unchanged when there is nothing to preserve or the anchor is absent.
*/
function spliceCodexAgentsScalars(block, scalarLines) {
if (!scalarLines || scalarLines.length === 0) return block;
return block.replace(/(\n\[agents\]\n)(max_depth = )/, `$1${scalarLines.join('\n')}\n$2`);
}
/**
* Strip any managed MSD agent sections from a TOML string.
*
* Used by the uninstall path (`stripMsdFromCodexConfig`). Removes only what MSD
* owns; user-authored `[agents.<name>]` and `[[agents]]` entries are preserved
* so uninstall returns the file to its pre-MSD shape.
*
* Handles BOTH shapes so reinstall self-heals configs from all MSD versions:
* - Current (#2727): `[agents.msd-*]` struct tables (Codex 0.120.0+).
* - Legacy (#2645): `[[agents]]` array-of-tables whose `name = "msd-*"`.
*
* A section runs from its header to the next `[` header or EOF.
*/
function stripCodexMsdAgentSections(content) {
// Use the TOML-aware section parser so we never absorb adjacent user-authored
// tables — even if their headers are indented or otherwise oddly placed.
const sections = getTomlTableSections(content).filter((section) => {
// Current `[agents.msd-<name>]` struct tables (#2727, Codex 0.120.0+).
if (!section.array && /^agents\.msd-/.test(section.path)) {
return true;
}
// MSD's managed `[agents]` scalar block (ADR-1239 upgrade 2 / #2088 — the
// `max_depth` dispatch-tuning table). Install purges any pre-existing bare
// `[agents]` and writes its own, so a known-scalar-only bare `[agents]` is
// MSD-owned; strip it on uninstall. (The marker path already removes it via
// the marker-to-EOF cut; this covers the no-marker fallback.)
if (!section.array && section.path === 'agents') {
const body = content.slice(section.headerEnd, section.end);
return codexBareAgentsHasOnlyKnownScalars(body);
}
// Legacy `[[agents]]` array-of-tables (#2645) — only strip blocks whose
// `name = "msd-..."`, preserving user-authored [[agents]] entries.
if (section.array && section.path === 'agents') {
const body = content.slice(section.headerEnd, section.end);
const nameMatch = body.match(/^[ \t]*name[ \t]*=[ \t]*["']([^"']+)["']/m);
return Boolean(nameMatch && /^msd-/.test(nameMatch[1]));
}
return false;
});
return removeContentRanges(
content,
sections.map(({ start, end }) => ({ start, end })),
);
}
/**
* Strip MSD sections from Codex config.toml content.
* Returns cleaned content, or null if file would be empty.
*/
function stripMsdFromCodexConfig(content) {
const eol = detectLineEnding(content);
const markerIndex = content.indexOf(MSD_CODEX_MARKER);
const codexHooksOwnership = getManagedCodexHooksOwnership(content);
if (markerIndex !== -1) {
// Has MSD marker — remove everything from marker to EOF. First recover the
// user's own AgentsToml scalars (max_threads etc.) that install folded into
// the managed [agents] block (#2088), so a full install→uninstall cycle
// round-trips the user's tuning. MSD-managed max_depth is dropped.
const preservedScalars = extractCodexUserAgentsScalars(content.slice(markerIndex));
let before = content.substring(0, markerIndex);
before = stripCodexHooksFeatureAssignments(before, codexHooksOwnership);
// Also strip MSD-injected feature keys above the marker (Case 3 inject)
before = before.replace(/^multi_agent\s*=\s*true\s*(?:\r?\n)?/m, '');
before = before.replace(/^default_mode_request_user_input\s*=\s*true\s*(?:\r?\n)?/m, '');
before = before.replace(/^\[features\]\s*\n(?=\[|$)/m, '');
before = before.replace(/^\[agents\]\s*\n(?=\[|$)/m, '');
before = before.replace(/^(?:\r?\n)+/, '').trimEnd();
if (preservedScalars.length > 0) {
before = (before ? before + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol);
}
if (!before) return null;
return before + eol;
}
// No marker but may have MSD-injected feature keys
let cleaned = content;
cleaned = stripCodexHooksFeatureAssignments(cleaned, codexHooksOwnership);
cleaned = cleaned.replace(/^multi_agent\s*=\s*true\s*(?:\r?\n)?/m, '');
cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*(?:\r?\n)?/m, '');
// #2088: recover the user's own AgentsToml scalars before the [agents] table is
// stripped, so they survive uninstall even in the no-marker fallback path.
const preservedScalars = extractCodexUserAgentsScalars(cleaned);
// Remove [agents.msd-*] sections + the managed known-scalar [agents] table.
cleaned = stripCodexMsdAgentSections(cleaned);
// Remove [features] section if now empty (only header, no keys before next section)
cleaned = cleaned.replace(/^\[features\]\s*\n(?=\[|$)/m, '');
// Remove [agents] section if now empty
cleaned = cleaned.replace(/^\[agents\]\s*\n(?=\[|$)/m, '');
cleaned = cleaned.replace(/^(?:\r?\n)+/, '').trimEnd();
if (preservedScalars.length > 0) {
cleaned = (cleaned ? cleaned + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol);
}
if (!cleaned) return null;
return cleaned + eol;
}
function detectLineEnding(content) {
const firstNewlineIndex = content.indexOf('\n');
if (firstNewlineIndex === -1) {
return '\n';
}
return firstNewlineIndex > 0 && content[firstNewlineIndex - 1] === '\r' ? '\r\n' : '\n';
}
function splitTomlLines(content) {
const lines = [];
let start = 0;
while (start < content.length) {
const newlineIndex = content.indexOf('\n', start);
if (newlineIndex === -1) {
lines.push({
start,
end: content.length,
text: content.slice(start),
eol: '',
});
break;
}
const hasCr = newlineIndex > start && content[newlineIndex - 1] === '\r';
const end = hasCr ? newlineIndex - 1 : newlineIndex;
lines.push({
start,
end,
text: content.slice(start, end),
eol: hasCr ? '\r\n' : '\n',
});
start = newlineIndex + 1;
}
return lines;
}
function findTomlCommentStart(line) {
let i = 0;
let multilineState = null;
while (i < line.length) {
if (multilineState === 'literal') {
const closeIndex = line.indexOf('\'\'\'', i);
if (closeIndex === -1) {
return -1;
}
i = closeIndex + 3;
multilineState = null;
continue;
}
if (multilineState === 'basic') {
const closeIndex = findMultilineBasicStringClose(line, i);
if (closeIndex === -1) {
return -1;
}
i = closeIndex + 3;
multilineState = null;
continue;
}
const ch = line[i];
if (ch === '#') {
return i;
}
if (ch === '\'') {
if (line.startsWith('\'\'\'', i)) {
multilineState = 'literal';
i += 3;
continue;
}
const close = line.indexOf('\'', i + 1);
if (close === -1) return -1;
i = close + 1;
continue;
}
if (ch === '"') {
if (line.startsWith('"""', i)) {
multilineState = 'basic';
i += 3;
continue;
}
i += 1;
while (i < line.length) {
if (line[i] === '\\') {
i += 2;
continue;
}
if (line[i] === '"') {
i += 1;
break;
}
i += 1;
}
continue;
}
i += 1;
}
return -1;
}
function isEscapedInBasicString(line, index) {
let slashCount = 0;
let cursor = index - 1;
while (cursor >= 0 && line[cursor] === '\\') {
slashCount += 1;
cursor -= 1;
}
return slashCount % 2 === 1;
}
function findMultilineBasicStringClose(line, startIndex) {
let searchIndex = startIndex;
while (searchIndex < line.length) {
const closeIndex = line.indexOf('"""', searchIndex);
if (closeIndex === -1) {
return -1;
}
if (!isEscapedInBasicString(line, closeIndex)) {
return closeIndex;
}
searchIndex = closeIndex + 1;
}
return -1;
}
function advanceTomlMultilineStringState(line, multilineState) {
let i = 0;
let state = multilineState;
while (i < line.length) {
if (state === 'literal') {
const closeIndex = line.indexOf('\'\'\'', i);
if (closeIndex === -1) {
return state;
}
i = closeIndex + 3;
state = null;
continue;
}
if (state === 'basic') {
const closeIndex = findMultilineBasicStringClose(line, i);
if (closeIndex === -1) {
return state;
}
i = closeIndex + 3;
state = null;
continue;
}
const ch = line[i];
if (ch === '#') {
return state;
}
if (ch === '\'') {
if (line.startsWith('\'\'\'', i)) {
state = 'literal';
i += 3;
continue;
}
const close = line.indexOf('\'', i + 1);
if (close === -1) {
return state;
}
i = close + 1;
continue;
}
if (ch === '"') {
if (line.startsWith('"""', i)) {
state = 'basic';
i += 3;
continue;
}
i += 1;
while (i < line.length) {
if (line[i] === '\\') {
i += 2;
continue;
}
if (line[i] === '"') {
i += 1;
break;
}
i += 1;
}
continue;
}
i += 1;
}
return state;
}
function parseTomlBracketHeader(line, array) {
let i = 0;
while (i < line.length && /\s/.test(line[i])) {
i += 1;
}
const open = array ? '[[' : '[';
const close = array ? ']]' : ']';
if (!line.startsWith(open, i)) {
return null;
}
i += open.length;
const start = i;
while (i < line.length) {
if (line[i] === '\'' || line[i] === '"') {
const quote = line[i];
i += 1;
while (i < line.length) {
if (quote === '"' && line[i] === '\\') {
i += 2;
continue;
}
if (line[i] === quote) {
i += 1;
break;
}
i += 1;
}
continue;
}
if (line.startsWith(close, i)) {
const rawPath = line.slice(start, i).trim();
const segments = parseTomlKeyPath(rawPath);
if (!segments) {
return null;
}
i += close.length;
while (i < line.length && /\s/.test(line[i])) {
i += 1;
}
if (i < line.length && line[i] !== '#') {
return null;
}
return { path: segments.join('.'), segments, array };
}
if (line[i] === '#' || line[i] === '\r' || line[i] === '\n') {
return null;
}
i += 1;
}
return null;
}
function parseTomlTableHeader(line) {
return parseTomlBracketHeader(line, true) || parseTomlBracketHeader(line, false);
}
function findTomlAssignmentEquals(line) {
let i = 0;
while (i < line.length) {
const ch = line[i];
if (ch === '#') {
return -1;
}
if (ch === '\'') {
i += 1;
while (i < line.length) {
if (line[i] === '\'') {
i += 1;
break;
}
i += 1;
}
continue;
}
if (ch === '"') {
i += 1;
while (i < line.length) {
if (line[i] === '\\') {
i += 2;
continue;
}
if (line[i] === '"') {
i += 1;
break;
}
i += 1;
}
continue;
}
if (ch === '=') {
return i;
}
i += 1;
}
return -1;
}
function parseTomlKeyPath(keyText) {
const segments = [];
let i = 0;
while (i < keyText.length) {
while (i < keyText.length && /\s/.test(keyText[i])) {
i += 1;
}
if (i >= keyText.length) {
break;
}
if (keyText[i] === '\'' || keyText[i] === '"') {
const quote = keyText[i];
let segment = '';
let closed = false;
i += 1;
while (i < keyText.length) {
if (quote === '"' && keyText[i] === '\\') {
if (i + 1 >= keyText.length) {
return null;
}
segment += keyText[i + 1];
i += 2;
continue;
}
if (keyText[i] === quote) {
i += 1;
closed = true;
break;
}
segment += keyText[i];
i += 1;
}
if (!closed) {
return null;
}
segments.push(segment);
} else {
const match = keyText.slice(i).match(/^[A-Za-z0-9_-]+/);
if (!match) {
return null;
}
segments.push(match[0]);
i += match[0].length;
}
while (i < keyText.length && /\s/.test(keyText[i])) {
i += 1;
}
if (i >= keyText.length) {
break;
}
if (keyText[i] !== '.') {
return null;
}
i += 1;
}
return segments.length > 0 ? segments : null;
}
function parseTomlKey(line) {
const header = parseTomlTableHeader(line);
if (header) {
return null;
}
const equalsIndex = findTomlAssignmentEquals(line);
if (equalsIndex === -1) {
return null;
}
const raw = line.slice(0, equalsIndex).trim();
const segments = parseTomlKeyPath(raw);
if (!segments) {
return null;
}
return { raw, segments };
}
function getTomlLineRecords(content) {
const lines = splitTomlLines(content);
const records = [];
let currentTablePath = null;
let multilineState = null;
for (const line of lines) {
const startsInMultilineString = multilineState !== null;
const record = {
...line,
startsInMultilineString,
tablePath: currentTablePath,
tableHeader: null,
keySegments: null,
};
if (!startsInMultilineString) {
const header = parseTomlTableHeader(line.text);
if (header) {
record.tableHeader = header;
currentTablePath = header.path;
} else {
const key = parseTomlKey(line.text);
record.keySegments = key ? key.segments : null;
record.keyRaw = key ? key.raw : null;
}
}
multilineState = advanceTomlMultilineStringState(line.text, multilineState);
records.push(record);
}
return records;
}
function getTomlTableSections(content) {
const headerLines = getTomlLineRecords(content).filter((record) => record.tableHeader);
return headerLines.map((record, index) => ({
path: record.tableHeader.path,
// segments preserves the true parsed key count so callers that need to
// distinguish a 2-segment path like hooks."before.tool" from a 3-segment
// path like hooks.SessionStart.hooks can do so without splitting on dots
// (which misclassifies quoted key names that contain dot characters).
segments: record.tableHeader.segments,
array: record.tableHeader.array,
start: record.start,
headerEnd: record.end + record.eol.length,
end: index + 1 < headerLines.length ? headerLines[index + 1].start : content.length,
}));
}
function collapseTomlBlankLines(content) {
const eol = detectLineEnding(content);
return content.replace(/(?:\r?\n){3,}/g, eol + eol);
}
function removeContentRanges(content, ranges) {
const normalizedRanges = ranges
.filter((range) => range && range.start < range.end)
.sort((a, b) => a.start - b.start);
if (normalizedRanges.length === 0) {
return content;
}
const mergedRanges = [{ ...normalizedRanges[0] }];
for (let i = 1; i < normalizedRanges.length; i += 1) {
const current = normalizedRanges[i];
const previous = mergedRanges[mergedRanges.length - 1];
if (current.start <= previous.end) {
previous.end = Math.max(previous.end, current.end);
continue;
}
mergedRanges.push({ ...current });
}
let cleaned = '';
let cursor = 0;
for (const range of mergedRanges) {
cleaned += content.slice(cursor, range.start);
cursor = range.end;
}
cleaned += content.slice(cursor);
return cleaned;
}
function stripCodexHooksFeatureAssignments(content, ownership = null) {
const lineRecords = getTomlLineRecords(content);
const tableSections = getTomlTableSections(content);
const removalRanges = [];
const featuresSection = tableSections.find((section) => !section.array && section.path === 'features');
const shouldStripSectionKey = ownership === 'section' || ownership === 'all';
const shouldStripRootDottedKey = ownership === 'root_dotted' || ownership === 'all';
if (featuresSection && shouldStripSectionKey) {
const sectionRecords = lineRecords.filter((record) =>
!record.tableHeader &&
record.start >= featuresSection.headerEnd &&
record.end + record.eol.length <= featuresSection.end
);
const codexHookRecords = sectionRecords.filter((record) =>
!record.startsInMultilineString &&
record.keySegments &&
record.keySegments.length === 1 &&
isCodexHooksFeatureKey(record.keySegments[0])
);
for (const record of codexHookRecords) {
removalRanges.push({
start: record.start,
end: findTomlAssignmentBlockEnd(content, record),
});
}
if (codexHookRecords.length > 0) {
const removedStarts = new Set(codexHookRecords.map((record) => record.start));
const hasRemainingContent = sectionRecords.some((record) => {
if (removedStarts.has(record.start)) {
return false;
}
const trimmed = record.text.trim();
return trimmed !== '' && !trimmed.startsWith('#');
});
const hasRemainingComments = sectionRecords.some((record) => {
if (removedStarts.has(record.start)) {
return false;
}
return record.text.trim().startsWith('#');
});
if (!hasRemainingContent && !hasRemainingComments) {
removalRanges.push({
start: featuresSection.start,
end: featuresSection.end,
});
}
}
}
if (shouldStripRootDottedKey) {
const rootCodexHookRecords = lineRecords.filter((record) =>
!record.tableHeader &&
!record.startsInMultilineString &&
record.tablePath === null &&
record.keySegments &&
record.keySegments.length === 2 &&
record.keySegments[0] === 'features' &&
isCodexHooksFeatureKey(record.keySegments[1])
);
for (const record of rootCodexHookRecords) {
removalRanges.push({
start: record.start,
end: findTomlAssignmentBlockEnd(content, record),
});
}
}
return removeContentRanges(content, removalRanges);
}
function getManagedCodexHooksOwnership(content) {
const markerIndex = content.indexOf(MSD_CODEX_MARKER);
if (markerIndex === -1) {
return null;
}
const afterMarker = content.slice(markerIndex + MSD_CODEX_MARKER.length);
const match = afterMarker.match(/^\r?\n# MSD codex_hooks ownership: (section|root_dotted)\r?\n/);
return match ? match[1] : null;
}
function setManagedCodexHooksOwnership(content, ownership) {
const markerIndex = content.indexOf(MSD_CODEX_MARKER);
if (markerIndex === -1) {
return content;
}
const eol = detectLineEnding(content);
const markerEnd = markerIndex + MSD_CODEX_MARKER.length;
const afterMarker = content.slice(markerEnd);
const normalizedAfterMarker = afterMarker.replace(
/^\r?\n# MSD codex_hooks ownership: (?:section|root_dotted)\r?\n/,
eol
);
if (!ownership) {
return content.slice(0, markerEnd) + normalizedAfterMarker;
}
const remainder = normalizedAfterMarker.replace(/^\r?\n/, '');
return content.slice(0, markerEnd) +
eol +
`${MSD_CODEX_HOOKS_OWNERSHIP_PREFIX}${ownership}${eol}` +
remainder;
}
function isLegacyMsdAgentsSection(body) {
const lineRecords = getTomlLineRecords(body);
const legacyKeys = new Set(['max_threads', 'max_depth']);
let sawLegacyKey = false;
for (const record of lineRecords) {
if (record.startsInMultilineString) {
return false;
}
if (record.tableHeader) {
return false;
}
const trimmed = record.text.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
if (!record.keySegments || record.keySegments.length !== 1 || !legacyKeys.has(record.keySegments[0])) {
return false;
}
sawLegacyKey = true;
}
return sawLegacyKey;
}
function stripLeakedMsdCodexSections(content) {
// Defensive precedence (#2760): we own the `agents` namespace under our
// managed `msd-*` names, and the legacy bare-table and sequence forms
// (`[agents]`, `[[agents]]`) are invalid in the current Codex schema —
// they trigger "invalid type: ..., expected struct AgentsToml" and break
// every Codex CLI invocation. They MUST never coexist with the new
// `[agents.<name>]` struct format we now emit, so install-time always
// purges them regardless of MSD marker presence. Users who had legitimate
// user-authored `[[agents]]` entries before are already broken on Codex
// ≥0.124 — purging is the only path to a loadable config.
const leakedSections = getTomlTableSections(content)
.filter((section) => {
// Legacy [agents.msd-<name>] map tables (pre-#2645).
if (!section.array && section.path.startsWith('agents.msd-')) return true;
// ANY bare [agents] single-bracket table — invalid in current Codex
// schema, always purged at install time (#2760). Previously gated
// on `isLegacyMsdAgentsSection`, which missed bare tables holding
// arbitrary user keys (`default = "..."`, etc.) that still produce
// the AgentsToml type error.
if (!section.array && section.path === 'agents') return true;
// ANY [[agents]] array-of-tables — invalid in current Codex schema,
// always purged at install time (#2760). Previously gated on
// `name = "msd-..."` which preserved user-authored entries that are
// themselves rejected by Codex 0.124+.
if (section.array && section.path === 'agents') return true;
return false;
});
if (leakedSections.length === 0) {
return content;
}
let cleaned = '';
let cursor = 0;
for (const section of leakedSections) {
cleaned += content.slice(cursor, section.start);
cursor = section.end;
}
cleaned += content.slice(cursor);
return collapseTomlBlankLines(cleaned);
}
/**
* Strip MSD-managed legacy Codex hook blocks from a config.toml string
* using the TOML AST already used elsewhere in this file
* (`getTomlTableSections` + `removeContentRanges`). The earlier regex-based
* implementation required a precise key order, exact single-space padding
* around `=`, and exactly one blank line between Shape 4's parent/child
* tables — any deviation (an extra blank line, key reorder, an added
* `timeout` key, `event="SessionStart"` without spaces) silently leaked the
* stale block, sometimes corrupting the file by leaving orphaned key=value
* lines outside any table.
*
* The structural approach: find every `hooks*` table whose body contains a
* `command = "...msd-(check-update|update-check).js"` value, remove its
* exact byte range, and additionally remove any orphaned parent
* `[[hooks.SessionStart]]` whose body becomes empty as a result (Shape 4).
* The leading `# MSD Hooks` header line is swallowed by extending the
* removal range backward through any single preceding comment line.
*
* Pure function, exported for test coverage. Returns the input unchanged
* if no MSD-managed hook section is present.
*/
function stripStaleMsdHookBlocks(configContent) {
const sections = getTomlTableSections(configContent);
const lineRecords = getTomlLineRecords(configContent);
const hookSections = sections.filter(
(s) => s.path === 'hooks' || s.path.startsWith('hooks.')
);
if (hookSections.length === 0) {
return configContent;
}
// A section is MSD-managed if any structural `command` key inside its
// body parses to a string whose basename matches `msd-(check-update|
// update-check).js`. The TOML line parser already classified each line's
// `keySegments`, so we never inspect raw text — this handles arbitrary
// whitespace, key reordering, and additional keys robustly.
function sectionHasStaleCommand(section) {
const records = lineRecords.filter(
(r) => !r.startsInMultilineString
&& !r.tableHeader
&& r.start >= section.headerEnd
&& r.end + r.eol.length <= section.end
&& r.keySegments
&& r.keySegments.length === 1
&& r.keySegments[0] === 'command'
);
for (const record of records) {
const equalsIndex = findTomlAssignmentEquals(record.text);
if (equalsIndex === -1) continue;
let parsed;
try {
parsed = parseTomlValue(record.text, equalsIndex + 1);
} catch {
continue;
}
if (typeof parsed.value !== 'string') continue;
if (isManagedHookCommand(parsed.value, {
surface: 'codex-toml',
includeLegacyAliases: true,
})) {
return true;
}
}
return false;
}
const stale = new Set(hookSections.filter(sectionHasStaleCommand));
if (stale.size === 0) {
return configContent;
}
// Shape 4: a `[[hooks.SessionStart]]` event-table whose body is empty and
// whose immediately following section is a stale child handler table
// (`[[hooks.SessionStart.hooks]]`) becomes orphaned once the child is
// stripped. Detect emptiness via line records — no key/value lines and no
// non-blank, non-comment text between this section's header and the next.
function sectionBodyHasContent(section) {
return lineRecords.some(
(r) => !r.startsInMultilineString
&& !r.tableHeader
&& r.start >= section.headerEnd
&& r.end + r.eol.length <= section.end
&& r.text.trim() !== ''
&& !r.text.trim().startsWith('#')
);
}
for (let i = 0; i < sections.length; i += 1) {
const parent = sections[i];
if (stale.has(parent)) continue;
if (!parent.array || parent.path !== 'hooks.SessionStart') continue;
if (sectionBodyHasContent(parent)) continue;
const next = sections[i + 1];
if (next && stale.has(next) && next.path.startsWith('hooks.SessionStart.')) {
stale.add(parent);
}
}
// Each removal range starts at the table header. If the immediately
// preceding line is the MSD marker comment `# MSD Hooks` (and is not part
// of an already-removed section), extend the range backward to swallow it
// — preserves cleanliness on round-trip strip+rewrite.
const ranges = [];
for (const section of stale) {
let start = section.start;
const headerLineIdx = lineRecords.findIndex((r) => r.start === section.start);
const prev = headerLineIdx > 0 ? lineRecords[headerLineIdx - 1] : null;
if (prev && !prev.startsInMultilineString && prev.text.trim() === '# MSD Hooks') {
start = prev.start;
}
ranges.push({ start, end: section.end });
}
return collapseTomlBlankLines(removeContentRanges(configContent, ranges));
}
/**
* Migrate legacy Codex [hooks] map format to [[hooks]] array-of-tables format.
*
* Codex 0.124.0 changed from the old map-style hooks config:
* [hooks]
* [hooks.shell]
* command = "..."
*
* to the new array-of-tables format. #2760 CR5 finding 3 — emit the
* namespaced AoT shape directly so a mixed flat + namespaced layout never
* arises post-install:
* [[hooks.shell]]
* command = "..."
*
* This function detects any non-array hooks sections in the config and
* converts them to the namespaced `[[hooks.<TYPE>]]` array-of-tables form,
* preserving all key-value pairs and user comments. Bare [hooks] container
* sections (no key-value content) are dropped. User-authored AoT entries are
* left untouched.
*
* Returns the migrated content, or the original content unchanged if no
* legacy hooks sections were found.
*/
function migrateCodexHooksMapFormat(content) {
const sections = getTomlTableSections(content);
// Find all non-array hooks sections: bare [hooks] container or [hooks.TYPE] event tables.
// Use section.segments (parsed key count) rather than section.path.startsWith() so that
// nested handler tables like [hooks.SessionStart.hooks] (3 segments) are not mistakenly
// included and re-emitted as an event named "SessionStart.hooks".
// Exclude hooks.state and hooks.state.* — these are Codex's persistent hook-trust
// namespace (Codex CLI 0.130.0+) and use regular-table shape, never AoT.
const legacyMapSections = sections.filter(
(section) => !section.array && (
section.path === 'hooks' ||
(section.path.startsWith('hooks.') && section.segments.length === 2 &&
section.path !== 'hooks.state' && !section.path.startsWith('hooks.state.'))
)
);
// Find flat [[hooks]] array-of-tables entries (path === 'hooks', array === true).
// These are incompatible with [[hooks.<EVENT>]] namespaced form — both cannot
// coexist in the same TOML file because `hooks` cannot be simultaneously an
// array and a table. Migrate each flat entry to [[hooks.<EVENT>]] form using
// the `event` key as the event name.
const flatAotSections = sections.filter(
(section) => section.array && section.path === 'hooks'
);
// Find [[hooks.TYPE]] namespaced AoT entries that carry handler fields
// (command, type, timeout, statusMessage) at event-entry level but have no
// [[hooks.TYPE.hooks]] sub-table. This is the pre-#2773 single-block shape
// that Codex 0.124.0+ rejects. Promote them to the two-level nested form.
// Entries that already have a [[hooks.TYPE.hooks]] sub-table are left untouched.
// Matcher-only entries (no handler fields) are intentionally valid and skipped.
const STALE_HANDLER_FIELD_PATTERN = /^\s*(?:command|type|timeout|statusMessage)\s*=/m;
const staleNamespacedAotSections = sections.filter((section) => {
if (!section.array) return false;
if (!section.path.startsWith('hooks.')) return false;
// [[hooks.TYPE.hooks]] sub-tables have 3 parsed segments — skip them.
// Use section.segments (true parsed key count) rather than splitting
// section.path on '.', which misclassifies quoted event names that contain
// dots (e.g. [[hooks."before.tool"]] has segments ['hooks','before.tool']
// but path 'hooks.before.tool' would split into 3 parts).
if (section.segments.length !== 2) return false;
// Must carry at least one handler field at event-entry level.
const body = content.slice(section.headerEnd, section.end);
if (!STALE_HANDLER_FIELD_PATTERN.test(body)) return false;
// Don't migrate when the nested [[hooks.TYPE.hooks]] sub-table already exists.
const subPath = section.path + '.hooks';
return !sections.some((s) => s.array && s.path === subPath);
});
if (legacyMapSections.length === 0 && flatAotSections.length === 0 && staleNamespacedAotSections.length === 0) {
return content;
}
const eol = detectLineEnding(content);
// Helper: parse a hooks body into event-level and handler-level entries,
// returning { eventEntries, handlerEntries, hasExplicitType }.
// Event-level keys: matcher. Everything else is handler-level.
// The `event` key (used in flat [[hooks]] blocks) is consumed as the type
// name and excluded from both levels.
const EVENT_LEVEL_KEYS = new Set(['matcher']);
function parseHooksBody(body, skipKeys = new Set()) {
const bodyLines = body.split(/\r?\n/);
const eventEntries = [];
const handlerEntries = [];
let hasExplicitType = false;
for (const line of bodyLines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
// Use parseTomlKey so hyphenated keys (e.g. status-message) and quoted
// keys are recognised — the old /^([\w.]+)\s*=/ regex silently dropped them.
const parsed = parseTomlKey(trimmed);
if (!parsed) continue;
// Hook body keys are always single-segment; use segments[0] for the name.
const key = parsed.segments[0];
if (skipKeys.has(key)) continue;
if (key === 'type') {
hasExplicitType = true;
handlerEntries.push(trimmed);
} else if (EVENT_LEVEL_KEYS.has(key)) {
eventEntries.push(trimmed);
} else {
handlerEntries.push(trimmed);
}
}
return { eventEntries, handlerEntries, hasExplicitType };
}
// TOML key quoting: bare keys may only contain [A-Za-z0-9_-]. Event names
// containing spaces, dots, or other punctuation must be wrapped in double-
// quoted TOML strings with backslash and double-quote characters escaped.
// Using raw event names in [[hooks.${type}]] headers produces invalid TOML
// for any non-bare-key character (e.g. "Before Tool" → [[hooks.Before Tool]]).
function tomlBareKey(key) {
if (/^[A-Za-z0-9_-]+$/.test(key)) return key;
return '"' + key.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
function buildNestedBlock(type, body, skipKeys = new Set()) {
const quotedType = tomlBareKey(type);
const { eventEntries, handlerEntries, hasExplicitType } = parseHooksBody(body, skipKeys);
const eventBody = eventEntries.length > 0 ? eventEntries.join(eol) + eol : '';
// If no handler fields were found (e.g. matcher-only entry), do not synthesise
// an empty [[hooks.TYPE.hooks]] block — that would produce structurally valid
// TOML but semantically broken output (a handler entry with no command).
if (handlerEntries.length === 0) {
return `[[hooks.${quotedType}]]${eol}${eventBody}`;
}
if (!hasExplicitType) handlerEntries.unshift('type = "command"');
const handlerBody = handlerEntries.join(eol) + eol;
return `[[hooks.${quotedType}]]${eol}${eventBody}${eol}[[hooks.${quotedType}.hooks]]${eol}${handlerBody}`;
}
// Extract the event name from a flat [[hooks]] section body.
// Returns null if no `event` key is found, if the value is an empty string, or if
// the quoting is unrecognised. Both TOML double-quoted ("...") and single-quoted
// ('...') strings are accepted. An empty event string (event = "" or event = '')
// is explicitly rejected — it cannot be meaningfully namespaced and is left untouched.
function extractFlatHookEventName(body) {
const TOML_EVENT_CAPTURE = /^\s*event\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')/m;
const m = body.match(TOML_EVENT_CAPTURE);
if (!m) return null;
const name = (m[1] ?? m[2] ?? '').trim();
return name || null;
}
const migratedFlatAotSections = flatAotSections.filter((section) => {
const body = content.slice(section.headerEnd, section.end);
return extractFlatHookEventName(body) !== null;
});
const legacyHooksSections = [...legacyMapSections, ...migratedFlatAotSections, ...staleNamespacedAotSections];
// Remove all legacy hooks sections from the content
let result = removeContentRanges(
content,
legacyHooksSections.map(({ start, end }) => ({ start, end })),
);
result = collapseTomlBlankLines(result);
// Map-format blocks ([hooks.TYPE]) are inserted at the position of the first
// remaining table section (preserving their relative placement in the file).
// Flat AoT blocks ([[hooks]] with event = "...") are always APPENDED because
// flat [[hooks]] entries only appear at the END of a TOML file (AoT cannot
// precede a regular table), and inserting before the first table would push
// them above [features] / [model] etc., corrupting relative ordering.
const mapOnlyBlocks = legacyMapSections
.filter((s) => s.path !== 'hooks') // skip bare [hooks] container
.map((s) => {
const body = content.slice(s.headerEnd, s.end);
// #3346: when the legacy `[hooks.<X>]` body declares `event = "..."`,
// prefer that as the event-name leaf key. The path segment <X> may be
// a `<file>:<event>:<line>:<col>` location identifier (Codex pre-AoT
// wrote those as table keys), which is not a valid leaf event name —
// emitting it verbatim produces a TOML key chain Codex 0.124.0+ rejects.
const bodyEvent = extractFlatHookEventName(body);
const type = bodyEvent !== null ? bodyEvent : s.path.slice('hooks.'.length);
const skipKeys = bodyEvent !== null ? new Set(['event']) : new Set();
return buildNestedBlock(type, body, skipKeys);
});
// Stale namespaced AoT blocks: [[hooks.TYPE]] entries with handler fields at
// event-entry level (no .hooks sub-table). Treated like map-format blocks —
// inserted before the first remaining table section.
const staleNamespacedAotBlocks = staleNamespacedAotSections.map((s) => {
const body = content.slice(s.headerEnd, s.end);
// #3346: see note in mapOnlyBlocks — body `event = "..."` wins over the
// raw path segment when both are present.
const bodyEvent = extractFlatHookEventName(body);
const type = bodyEvent !== null ? bodyEvent : s.path.slice('hooks.'.length);
const skipKeys = bodyEvent !== null ? new Set(['event']) : new Set();
return buildNestedBlock(type, body, skipKeys);
});
const flatAotBlocks = migratedFlatAotSections.map((s) => {
const body = content.slice(s.headerEnd, s.end);
const eventName = extractFlatHookEventName(body);
if (!eventName) return '';
return buildNestedBlock(eventName, body, new Set(['event']));
}).filter(Boolean);
// Insert map-format and stale-namespaced-AoT conversions before the first
// remaining table section (both share the same placement strategy).
const allMapStyleBlocks = [...mapOnlyBlocks, ...staleNamespacedAotBlocks];
if (allMapStyleBlocks.length > 0) {
const insertionText = allMapStyleBlocks.join('');
const remainingSections = getTomlTableSections(result);
if (remainingSections.length > 0) {
const firstTable = remainingSections[0];
const before = result.slice(0, firstTable.start);
const after = result.slice(firstTable.start);
const needsLeadingGap = before.length > 0 && !before.endsWith(eol + eol);
const needsTrailingGap = after.length > 0 && !insertionText.endsWith(eol + eol);
result = before +
(needsLeadingGap ? eol : '') +
insertionText +
(needsTrailingGap ? eol : '') +
after;
} else {
const needsGap = result.length > 0 && !result.endsWith(eol + eol);
result = result + (needsGap ? eol : '') + insertionText;
}
}
// Insert flat-AoT conversions before the MSD managed marker (if present) so
// the migrated user hooks stay in the "user" portion of the file and are not
// swept away when stripMsdFromCodexConfig strips from the marker to EOF.
// If no marker exists, append at the end of the file.
if (flatAotBlocks.length > 0) {
const insertionText = flatAotBlocks.join('');
const markerIdx = result.indexOf(MSD_CODEX_MARKER);
if (markerIdx !== -1) {
const before = result.slice(0, markerIdx).trimEnd();
const after = result.slice(markerIdx);
result = before + eol + eol + insertionText + eol + after;
} else {
const needsGap = result.length > 0 && !result.endsWith(eol + eol);
result = result + (needsGap ? eol : '') + insertionText;
}
}
return result;
}
/**
* Detect whether the user already uses the namespaced AoT hooks form
* (`[[hooks.<EVENT>]]`) for the given event in the config. When true,
* the MSD-managed hook block must be emitted in the same shape so it
* coexists cleanly — mixing `[[hooks]]` (flat) with `[[hooks.SessionStart]]`
* (namespaced) in the same file confuses round-trip writers and can
* produce a config that Codex rejects (#2760, defect 3).
*/
function hasUserNamespacedAotHooks(content, event) {
const sections = getTomlTableSections(content);
return sections.some(
(section) => section.array && section.path === `hooks.${event}`
);
}
/**
* Parse a TOML value RHS expression starting at index `i` of `text`.
* Returns { value, end } on success or throws on parse failure.
*
* Supports the value forms MSD emits or that real Codex configs commonly use:
* - basic strings ("…" with simple escapes)
* - literal strings ('…')
* - booleans (true / false)
* - integers (optional sign, decimal digits)
* - inline arrays of the above
* - inline tables { k = v, … }
*
* This is intentionally not a complete TOML implementation — it is the
* minimal value grammar required to validate Codex config structure and to
* back behavioral assertions in tests (#2760).
*/
function parseTomlValue(text, i) {
// Skip leading whitespace.
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) {
i += 1;
}
if (i >= text.length) {
throw new Error('expected value, got end of input');
}
const ch = text[i];
// Basic string
if (ch === '"') {
if (text.startsWith('"""', i)) {
const close = findMultilineBasicStringClose(text, i + 3);
if (close === -1) {
throw new Error('unterminated multi-line basic string');
}
const raw = text.slice(i + 3, close);
return { value: raw.replace(/^\r?\n/, ''), end: close + 3 };
}
let j = i + 1;
let out = '';
while (j < text.length) {
const c = text[j];
if (c === '\\') {
const next = text[j + 1];
if (next === 'n') { out += '\n'; j += 2; continue; }
if (next === 't') { out += '\t'; j += 2; continue; }
if (next === 'r') { out += '\r'; j += 2; continue; }
if (next === '\\') { out += '\\'; j += 2; continue; }
if (next === '"') { out += '"'; j += 2; continue; }
if (next === '/') { out += '/'; j += 2; continue; }
// Pass-through unrecognized escape (Codex/MSD don't use these).
out += next === undefined ? '' : next;
j += 2;
continue;
}
if (c === '"') {
return { value: out, end: j + 1 };
}
out += c;
j += 1;
}
throw new Error('unterminated basic string');
}
// Literal string
if (ch === '\'') {
if (text.startsWith('\'\'\'', i)) {
const close = text.indexOf('\'\'\'', i + 3);
if (close === -1) throw new Error('unterminated multi-line literal string');
return { value: text.slice(i + 3, close).replace(/^\r?\n/, ''), end: close + 3 };
}
const close = text.indexOf('\'', i + 1);
if (close === -1) throw new Error('unterminated literal string');
return { value: text.slice(i + 1, close), end: close + 1 };
}
// Boolean
if (text.startsWith('true', i) && !/[A-Za-z0-9_-]/.test(text[i + 4] || '')) {
return { value: true, end: i + 4 };
}
if (text.startsWith('false', i) && !/[A-Za-z0-9_-]/.test(text[i + 5] || '')) {
return { value: false, end: i + 5 };
}
// Inline array
if (ch === '[') {
const arr = [];
let j = i + 1;
while (true) {
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
if (j >= text.length) throw new Error('unterminated inline array');
if (text[j] === ']') return { value: arr, end: j + 1 };
if (text[j] === '#') {
const nl = text.indexOf('\n', j);
j = nl === -1 ? text.length : nl + 1;
continue;
}
const parsed = parseTomlValue(text, j);
arr.push(parsed.value);
j = parsed.end;
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
if (j < text.length && text[j] === ',') {
j += 1;
continue;
}
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
if (text[j] === ']') return { value: arr, end: j + 1 };
throw new Error(`expected , or ] in inline array at offset ${j}`);
}
}
// Inline table
if (ch === '{') {
const obj = {};
let j = i + 1;
while (true) {
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
if (text[j] === '}') return { value: obj, end: j + 1 };
const keyMatch = text.slice(j).match(/^([A-Za-z0-9_-]+|"[^"]*"|'[^']*')\s*=\s*/);
if (!keyMatch) throw new Error(`expected key in inline table at offset ${j}`);
let rawKey = keyMatch[1];
if ((rawKey.startsWith('"') && rawKey.endsWith('"')) || (rawKey.startsWith('\'') && rawKey.endsWith('\''))) {
rawKey = rawKey.slice(1, -1);
}
j += keyMatch[0].length;
const parsed = parseTomlValue(text, j);
obj[rawKey] = parsed.value;
j = parsed.end;
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
if (text[j] === ',') { j += 1; continue; }
if (text[j] === '}') return { value: obj, end: j + 1 };
throw new Error(`expected , or } in inline table at offset ${j}`);
}
}
// Number — integer or TOML 1.0 float. (#2760 CR4 finding 3 required explicit
// rejection of floats; #3245 inverts that: Codex CLI's serde schema requires
// f64 for tool_timeout_sec / startup_timeout_sec, so integers are what Codex
// rejects. Accept TOML floats and store as JS Number.)
//
// Still rejected: date/time literals (`-`, `:`, `T`, `Z` after integer prefix)
// and hex/oct/bin literals (`0x`, `0o`, `0b` — `x`, `o`, `b` fall through to
// the unsupported-value throw below because the integer-part pattern won't match `x`).
// TOML 1.0 §2: underscores in numeric literals are only allowed BETWEEN
// digits (each underscore must have a digit on both sides). The pre-check
// regex uses (?:_?\d)* rather than [\d_]* so `1__0`, `1_.0`, and `1._0`
// are rejected before normalization silently hides them.
//
// TOML 1.0 §2 (integer part): the integer part of a number must follow
// decimal-integer rules — no leading zeros except the value 0 itself.
// `01`, `00`, `01.5`, `00e2`, `+01`, `-01` are therefore all invalid.
// The pre-check and float regexes use (0|[1-9](?:_?\d)*) for the integer
// part so that `01` and `00` are rejected (k021 sibling rule).
const numMatch = text.slice(i).match(/^[+-]?(0|[1-9](?:_?\d)*)/);
if (numMatch) {
const afterInt = text[i + numMatch[0].length];
// Reject date/time separators that cannot be part of a float.
if (afterInt !== undefined && /[:\-TZ]/.test(afterInt)) {
throw new Error(
`unsupported TOML value at offset ${i}: dates and times are not supported (got ${text.slice(i, i + 20)})`
);
}
// Accept float: optional decimal part, optional exponent part.
// Each segment uses (?:_?\d)* so underscores are only between digits.
// Integer part uses (0|[1-9](?:_?\d)*) to reject leading zeros per TOML 1.0.
const floatMatch = text.slice(i).match(
/^[+-]?(0|[1-9](?:_?\d)*)(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?/
);
const raw = floatMatch ? floatMatch[0] : numMatch[0];
const normalized = raw.replace(/_/g, '');
const n = Number(normalized);
if (!Number.isFinite(n)) throw new Error(`invalid number: ${raw}`);
return { value: n, end: i + raw.length };
}
throw new Error(`unsupported value at offset ${i}: ${text.slice(i, i + 20)}`);
}
/**
* Parse TOML content into a JavaScript object. Throws on malformed input.
*
* Handles `[table]`, `[[array.of.tables]]`, dotted key paths, and the value
* forms supported by parseTomlValue. Sufficient for validating Codex config
* structure and for behavioral test assertions in #2760 — not a general
* TOML implementation.
*/
function parseTomlToObject(content) {
const root = {};
const records = getTomlLineRecords(content);
// Tracks the *object* (not path) that subsequent key=value lines target.
let currentTable = root;
// #2760 CR5 finding 2 — track shape and definition status of every path so
// we can reject duplicate header redeclarations, shape mismatches, and
// duplicate keys per real TOML 1.0 semantics. Without this, walkPath
// silently reuses existing tables and assignment overwrites existing keys —
// a real TOML parser would refuse the file.
//
// pathShape: dotted path -> 'table' | 'array' | 'inline_parent' | 'key'
// - 'table' — declared via [a.b]
// - 'array' — declared via [[a.b]] (path is the array itself; each
// element is its own implicit table)
// - 'inline_parent' — created implicitly while walking parents
// - 'key' — assigned a scalar value
// declaredHeaders: set of dotted paths explicitly declared via [hdr] (not
// [[arr]]) — used to reject duplicate [a] / [a] sections.
// tableKeys: dotted-path -> Set<string> of keys assigned in that exact
// table instance. For [[arr]] elements we use a per-element marker.
const pathShape = new Map();
const declaredHeaders = new Set();
const tableKeys = new Map();
// currentTableId — string identifier for the current table instance, used
// as the key into tableKeys so that key uniqueness is per-table-instance
// (each [[arr]] element gets its own id).
let currentTableId = '__root__';
pathShape.set('__root__', 'table');
tableKeys.set('__root__', new Set());
function ensureKeySet(id) {
if (!tableKeys.has(id)) tableKeys.set(id, new Set());
return tableKeys.get(id);
}
function walkPath(segments, { creatingArrayElement = false } = {}) {
let node = root;
const parents = segments.slice(0, -1);
const last = segments[segments.length - 1];
for (let p = 0; p < parents.length; p += 1) {
const seg = parents[p];
const partialPath = parents.slice(0, p + 1).join('.');
if (node[seg] === undefined) {
node[seg] = {};
if (!pathShape.has(partialPath)) {
pathShape.set(partialPath, 'inline_parent');
}
} else if (Array.isArray(node[seg])) {
// Walk into the latest element of an array-of-tables.
node = node[seg][node[seg].length - 1];
continue;
} else if (typeof node[seg] !== 'object' || node[seg] === null) {
throw new Error(`path segment ${seg} is not a table`);
}
node = node[seg];
}
const fullPath = segments.join('.');
if (creatingArrayElement) {
const existingShape = pathShape.get(fullPath);
if (node[last] === undefined) {
node[last] = [];
pathShape.set(fullPath, 'array');
} else if (!Array.isArray(node[last])) {
throw new Error(
`duplicate or shape-mismatched table header at ${fullPath}: ` +
`cannot redefine as array of tables (previously seen as ${existingShape || 'table'})`
);
} else if (existingShape && existingShape !== 'array') {
throw new Error(
`duplicate or shape-mismatched table header at ${fullPath}: ` +
`previously seen as ${existingShape}, cannot extend as array of tables`
);
}
const elem = {};
node[last].push(elem);
const elemId = `${fullPath}[${node[last].length - 1}]`;
pathShape.set(elemId, 'array_element');
tableKeys.set(elemId, new Set());
currentTableId = elemId;
return elem;
}
// Plain [table] header.
if (node[last] === undefined) {
node[last] = {};
pathShape.set(fullPath, 'table');
declaredHeaders.add(fullPath);
tableKeys.set(fullPath, new Set());
} else if (Array.isArray(node[last])) {
throw new Error(
`duplicate or shape-mismatched table header at ${fullPath}: ` +
`previously declared as array of tables ([[${fullPath}]]), cannot redeclare as table ([${fullPath}])`
);
} else if (typeof node[last] !== 'object') {
throw new Error(`cannot redefine ${fullPath} as table`);
} else if (declaredHeaders.has(fullPath)) {
throw new Error(
`duplicate or shape-mismatched table header at ${fullPath}: ` +
`[${fullPath}] declared more than once`
);
} else {
// Implicitly created earlier (e.g., as a parent path); first explicit
// declaration is allowed.
pathShape.set(fullPath, 'table');
declaredHeaders.add(fullPath);
if (!tableKeys.has(fullPath)) tableKeys.set(fullPath, new Set());
}
currentTableId = fullPath;
return node[last];
}
for (let idx = 0; idx < records.length; idx += 1) {
const rec = records[idx];
if (rec.startsInMultilineString) continue;
if (rec.tableHeader) {
const segs = rec.tableHeader.segments;
currentTable = walkPath(segs, { creatingArrayElement: rec.tableHeader.array });
continue;
}
const trimmed = rec.text.trim();
if (trimmed === '' || trimmed.startsWith('#')) continue;
const equalsIndex = findTomlAssignmentEquals(rec.text);
if (equalsIndex === -1) continue;
const keyText = rec.text.slice(0, equalsIndex).trim();
const segments = parseTomlKeyPath(keyText);
if (!segments) {
throw new Error(`invalid TOML key on line ${idx + 1}: ${rec.text}`);
}
// Value RHS may span multiple lines (inline arrays, multi-line strings,
// inline tables). Parse from the absolute content offset right after `=`.
const valueStartAbs = rec.start + equalsIndex + 1;
const parsed = parseTomlValue(content, valueStartAbs);
// #2760 CR4 finding 3 — verify the full RHS was consumed. Anything other
// than whitespace + optional # comment between parsed.end and the next
// newline (or EOF) means the parser silently accepted a prefix and
// dropped trailing bytes. Reject so malformed TOML cannot slip past
// "parse before commit" guarantees.
let scan = parsed.end;
while (scan < content.length && (content[scan] === ' ' || content[scan] === '\t')) {
scan += 1;
}
if (scan < content.length && content[scan] !== '\n' && content[scan] !== '\r' && content[scan] !== '#') {
const lineEnd = content.indexOf('\n', scan);
const trailing = content.slice(scan, lineEnd === -1 ? content.length : lineEnd);
throw new Error(
`trailing bytes after value on line ${idx + 1}: ${JSON.stringify(trailing)}`
);
}
// Place value into currentTable under dotted key.
// #2760 CR5 finding 2 — reject duplicate keys per real TOML 1.0. Track
// the dotted key against the current table instance id; an exact repeat
// throws.
let target = currentTable;
for (let s = 0; s < segments.length - 1; s += 1) {
const seg = segments[s];
if (target[seg] === undefined) target[seg] = {};
else if (typeof target[seg] !== 'object' || Array.isArray(target[seg])) {
throw new Error(`cannot descend into non-table key ${seg}`);
}
target = target[seg];
}
const finalKey = segments[segments.length - 1];
const dottedKey = segments.join('.');
const keySet = ensureKeySet(currentTableId);
if (keySet.has(dottedKey) || Object.prototype.hasOwnProperty.call(target, finalKey)) {
throw new Error(
`duplicate key ${dottedKey} in ${currentTableId === '__root__' ? 'root table' : currentTableId}`
);
}
keySet.add(dottedKey);
target[finalKey] = parsed.value;
}
return root;
}
/**
* Validate that the post-install config.toml matches Codex's expected schema
* (#2760, fix 3). Returns { ok: true } on success, or { ok: false, reason }
* with a human-readable explanation of the offending section.
*
* Strategy: parse the bytes into a structured object first — malformed TOML
* fails validation immediately rather than slipping past a header-only scan.
* Then enforce the schema-shape rules against the parsed structure.
*
* Schema rules enforced:
* - File MUST parse as TOML (no syntax errors).
* - `agents` MUST be a struct table (`[agents.<name>]`) — never a bare
* table value or an array of tables.
* - `hooks.<Event>` MUST be an array of tables when present (Codex ≥0.124
* rejects bare `[hooks.<Event>]` single-bracket maps).
*/
/**
* True when a bare `[agents]` table body contains ONLY known AgentsToml scalar
* keys (CODEX_AGENTS_TOML_SCALAR_KEYS) — i.e. it is a valid AgentsToml struct
* that Codex's `deny_unknown_fields` will accept, not the break-causing form
* (#2760) that carries an unknown key. Comments and blank lines are ignored; an
* empty body is trivially valid. Mirrors isLegacyMsdAgentsSection's line scan.
*/
function codexBareAgentsHasOnlyKnownScalars(body) {
const lineRecords = getTomlLineRecords(body);
for (const record of lineRecords) {
// Conservative reject of anything not positively a single known-scalar
// assignment. A multiline-string value cannot be a valid AgentsToml scalar
// (max_threads/max_depth/job_max_runtime_seconds are integers,
// interrupt_message is a bool — none are strings), so codex would reject it
// too; rejecting here is correct, not a false negative.
if (record.startsInMultilineString) return false;
if (record.tableHeader) return false;
const trimmed = record.text.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (!record.keySegments || record.keySegments.length !== 1 ||
!CODEX_AGENTS_TOML_SCALAR_KEYS.has(record.keySegments[0])) {
return false;
}
}
return true;
}
function validateCodexConfigSchema(content) {
let parsed;
try {
parsed = parseTomlToObject(content);
} catch (e) {
return {
ok: false,
reason: `TOML parse failed: ${e.message}`,
};
}
// Header-shape check: arrays-of-tables are visible in the parsed structure
// (as Array values) but bare-vs-struct distinction for `[agents]` requires
// looking at section headers too — `[agents]` with `default = "x"` parses
// to `{ agents: { default: 'x' } }`, indistinguishable from
// `[agents.foo]` writing into the same shape. Use header sections to
// disambiguate.
const sections = getTomlTableSections(content);
for (const section of sections) {
if (section.array && section.path === 'agents') {
return {
ok: false,
reason: '[[agents]] sequence form is invalid in current Codex schema (expected [agents.<name>] struct form)',
};
}
if (!section.array && section.path === 'agents') {
// #2760 rejected ALL bare `[agents]` tables because a bare table holding a
// non-AgentsToml key (`default = "x"`, a role name, etc.) triggers Codex's
// "invalid type: ..., expected struct AgentsToml" and breaks every CLI
// invocation. But a bare `[agents]` whose keys are all valid AgentsToml
// scalars (max_depth/max_threads/...) IS a valid struct — that is exactly
// MSD's managed `max_depth` dispatch-tuning block (ADR-1239 upgrade 2 /
// #2088), and a user's own scalar tuning. Permit known-scalar-only; still
// reject any bare `[agents]` carrying an unknown key.
const body = content.slice(section.headerEnd, section.end);
if (!codexBareAgentsHasOnlyKnownScalars(body)) {
return {
ok: false,
reason: 'bare [agents] table with a non-AgentsToml key is invalid in current Codex schema (expected [agents.<name>] struct form, or only AgentsToml scalars like max_depth/max_threads)',
};
}
}
// hooks.state.* is Codex's persistent hook-trust namespace (added in
// Codex CLI 0.130.0). It uses regular-table shape, NOT array-of-tables.
// [[hooks.state]] or [[hooks.state.<key>]] (AoT) is invalid; reject it.
if (section.array && (section.path === 'hooks.state' || section.path.startsWith('hooks.state.'))) {
return {
ok: false,
reason: `[[${section.path}]] is invalid; hooks.state namespace must use regular tables`,
};
}
// All other hooks.* paths (event handlers like hooks.SessionStart) require
// AoT shape — bare [hooks.<Event>] (single-bracket) is invalid.
if (!section.array && section.path.startsWith('hooks.') &&
section.path !== 'hooks.state' && !section.path.startsWith('hooks.state.')) {
return {
ok: false,
reason: `bare [${section.path}] table is invalid in current Codex schema (expected [[${section.path}]] array-of-tables)`,
};
}
}
// Structural confirmation against parsed object: any present hooks.<Event>
// must be an array, and flat top-level [[hooks]] (parsed as Array on root)
// is rejected — Codex 0.124.0+ requires [[hooks.<Event>]] namespaced form.
if (parsed.hooks !== undefined) {
if (Array.isArray(parsed.hooks)) {
return {
ok: false,
reason: 'flat [[hooks]] array-of-tables is invalid in Codex 0.124.0+ (expected [[hooks.<Event>]] namespaced form)',
};
}
if (typeof parsed.hooks === 'object' && parsed.hooks !== null) {
for (const [event, value] of Object.entries(parsed.hooks)) {
// hooks.state is Codex's persistent hook-trust namespace — a regular
// object (table), not an array of event-handler tables.
// Reject AoT shape (Array) and scalar forms; only plain objects are valid.
if (event === 'state') {
if (Array.isArray(value)) {
return {
ok: false,
reason: `hooks.state must be a regular table/object, got array-of-tables`,
};
}
if (typeof value !== 'object' || value === null) {
return {
ok: false,
reason: `hooks.state must be a regular table/object, got ${typeof value}`,
};
}
continue;
}
// Skip the nested .hooks sub-array — it lives under hooks.<Event>[n].hooks
// and is validated separately below.
if (!Array.isArray(value)) {
return {
ok: false,
reason: `hooks.${event} must be an array of tables, got ${typeof value}`,
};
}
// Each entry in hooks.<Event> must either be a matcher-only filter (no
// handler fields) or carry a .hooks sub-array of handler tables.
// Entries with handler fields (command, type, timeout, statusMessage) at
// event-entry level but without a .hooks sub-table are the pre-#2773
// single-block shape that Codex 0.124.0+ rejects. migrateCodexHooksMapFormat
// converts these before validation runs; their presence here means migration
// failed to cover this entry — fail loudly rather than pass a broken config.
const HANDLER_FIELD_NAMES = new Set(['command', 'type', 'timeout', 'statusMessage']);
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
if (entry.hooks === undefined) {
const strayKey = Object.keys(entry).find((k) => HANDLER_FIELD_NAMES.has(k));
if (strayKey) {
return {
ok: false,
reason: `hooks.${event}[] entry has handler field "${strayKey}" at event-entry level; ` +
`Codex 0.124.0+ requires handler fields nested under [[hooks.${event}.hooks]]`,
};
}
continue;
}
if (!Array.isArray(entry.hooks)) {
return {
ok: false,
reason: `hooks.${event}[].hooks must be an array of handler tables, got ${typeof entry.hooks}`,
};
}
for (const handler of entry.hooks) {
if (handler && typeof handler === 'object' && handler.type !== undefined) {
if (handler.type !== 'command') {
return {
ok: false,
reason: `hooks.${event}[].hooks[].type must be "command", got "${handler.type}"`,
};
}
}
}
}
}
}
}
return { ok: true };
}
function normalizeCodexHooksLine(line, key) {
const leadingWhitespace = line.match(/^\s*/)[0];
const commentStart = findTomlCommentStart(line);
const comment = commentStart === -1 ? '' : line.slice(commentStart);
return `${leadingWhitespace}${key} = true${comment ? ` ${comment}` : ''}`;
}
function findTomlAssignmentBlockEnd(content, record) {
const equalsIndex = findTomlAssignmentEquals(record.text);
if (equalsIndex === -1) {
return record.end + record.eol.length;
}
let i = record.start + equalsIndex + 1;
let arrayDepth = 0;
let inlineTableDepth = 0;
while (i < content.length) {
if (content.startsWith('\'\'\'', i)) {
const closeIndex = content.indexOf('\'\'\'', i + 3);
if (closeIndex === -1) {
return content.length;
}
i = closeIndex + 3;
continue;
}
if (content.startsWith('"""', i)) {
const closeIndex = findMultilineBasicStringClose(content, i + 3);
if (closeIndex === -1) {
return content.length;
}
i = closeIndex + 3;
continue;
}
const ch = content[i];
if (ch === '\'') {
i += 1;
while (i < content.length) {
if (content[i] === '\'') {
i += 1;
break;
}
i += 1;
}
continue;
}
if (ch === '"') {
i += 1;
while (i < content.length) {
if (content[i] === '\\') {
i += 2;
continue;
}
if (content[i] === '"') {
i += 1;
break;
}
i += 1;
}
continue;
}
if (ch === '[') {
arrayDepth += 1;
i += 1;
continue;
}
if (ch === ']') {
if (arrayDepth > 0) {
arrayDepth -= 1;
}
i += 1;
continue;
}
if (ch === '{') {
inlineTableDepth += 1;
i += 1;
continue;
}
if (ch === '}') {
if (inlineTableDepth > 0) {
inlineTableDepth -= 1;
}
i += 1;
continue;
}
if (ch === '#') {
while (i < content.length && content[i] !== '\n') {
i += 1;
}
continue;
}
if (ch === '\n' && arrayDepth === 0 && inlineTableDepth === 0) {
return i + 1;
}
i += 1;
}
return content.length;
}
function rewriteTomlKeyLines(content, matches, key) {
if (matches.length === 0) {
return content;
}
let rewritten = '';
let cursor = 0;
matches.forEach((match, index) => {
rewritten += content.slice(cursor, match.start);
if (index === 0) {
const blockEnd = findTomlAssignmentBlockEnd(content, match);
const blockEol = blockEnd > 0 && content[blockEnd - 1] === '\n'
? (blockEnd > 1 && content[blockEnd - 2] === '\r' ? '\r\n' : '\n')
: '';
// Preserve the existing key when one is present on the line
// (`match.keyRaw`). This respects user ownership: a user-authored
// `codex_hooks = true` line stays as `codex_hooks = true` even
// though `hooks` is the canonical key in current Codex (#3566).
// Codex's own `legacy_key` alias mechanism in codex-rs handles the
// backward compat at the runtime layer. Migration to canonical is
// a fresh-insert-only operation in ensureCodexHooksFeature.
rewritten += normalizeCodexHooksLine(match.text, match.keyRaw || key) + blockEol;
cursor = blockEnd;
return;
}
cursor = findTomlAssignmentBlockEnd(content, match);
});
rewritten += content.slice(cursor);
return rewritten;
}
// atomicWriteFileSync and __atomicWrittenTmps are now owned by the
// runtime-hooks-surface module and imported here so both install.js's
// direct config.toml writes and the module's Cursor/Codex hooks.json
// writes share the SAME tracking Set. _cleanTmpFiles() below reads
// hooksSurface.__atomicWrittenTmps to scope cleanup to installer-owned
// temps only.
const atomicWriteFileSync = hooksSurface.atomicWriteFileSync;
const __atomicWrittenTmps = hooksSurface.__atomicWrittenTmps;
/**
* Merge MSD config block into an existing or new config.toml.
* Three cases: new file, existing with MSD marker, existing without marker.
*
* All writes go through atomicWriteFileSync so a mid-write failure leaves
* the original config.toml untouched (#2760 fix 4).
*/
/**
* Split TOML content into its leading TOP-LEVEL key lines and everything from
* the first table header onward (#3610).
*
* Top-level keys were file-scoped before a merge. The regenerated MSD block
* opens with a table header (`[agents]`, #2088/ADR-1239 upgrade 2), so placing
* that block ABOVE surviving top-level keys re-scopes them into `[agents]` —
* `validateCodexConfigSchema` then correctly rejects the merged file and the
* install aborts. Hoisting the keys above the block preserves their scope.
*
* Table headers inside multiline strings do not start the "rest" region (the
* record parser already excludes them via startsInMultilineString).
*/
function splitTopLevelKeys(content) {
for (const record of getTomlLineRecords(content)) {
if (record.tableHeader && !record.startsInMultilineString) {
return {
topLevel: content.slice(0, record.start).trim(),
rest: content.slice(record.start).trim(),
};
}
}
return { topLevel: content.trim(), rest: '' };
}
function mergeCodexConfig(configPath, msdBlock) {
// Case 1: No config.toml — create fresh
if (!fs.existsSync(configPath)) {
atomicWriteFileSync(configPath, msdBlock + '\n');
return;
}
const existing = fs.readFileSync(configPath, 'utf8');
const eol = detectLineEnding(existing);
// #2088 review: the bare `[agents]` table is purged below (Case 2/3 via
// stripLeakedMsdCodexSections) to keep a single managed `[agents]`. Preserve
// the user's own AgentsToml scalar tuning (max_threads, job_max_runtime_seconds,
// interrupt_message — everything except MSD-managed max_depth) by re-emitting
// it inside the managed block, so install never silently drops it.
const mergedMsdBlock = spliceCodexAgentsScalars(msdBlock, extractCodexUserAgentsScalars(existing));
const normalizedMsdBlock = mergedMsdBlock.replace(/\r?\n/g, eol);
const markerIndex = existing.indexOf(MSD_CODEX_MARKER);
// Case 2: Has MSD marker — preserve user content on BOTH sides, regenerate the MSD block.
//
// #2940: the marker delimits where MSD's OWN block begins, NOT where every post-marker byte
// is MSD-owned. A fresh install writes the MSD block as the file's entire content, so any
// settings the user or Codex CLI later adds ([model], [mcp_servers.*], [profiles.*]) land
// AFTER the block. The previous truncate-to-marker logic discarded that trailing region on
// every update, destroying user config. The fix routes the trailing region through the
// existing AST-based `stripLeakedMsdCodexSections`, which removes MSD's own managed/leaked
// sections (the bare [agents] table MSD regenerates, legacy [agents.msd-*], [[agents]])
// while preserving genuine user TOML — so #2406's de-dup still holds AND user content survives.
if (markerIndex !== -1) {
let before = existing.substring(0, markerIndex).trimEnd();
if (before) {
// Strip any MSD-managed sections that leaked above the marker from previous installs
before = stripLeakedMsdCodexSections(before).trimEnd();
}
// Capture and preserve genuine user content AFTER the MSD-managed region. The whole
// post-marker region is passed through stripLeakedMsdCodexSections: MSD's own previously-
// emitted [agents] table (regenerated above as normalizedMsdBlock) and any leaked sections
// are removed, while user tables ([model], [mcp_servers.*], [profiles.*]) are kept. The
// marker comment line itself (and the optional codex_hooks ownership line right under it)
// is MSD-owned and is stripped from the trailing region so it is not duplicated alongside
// the freshly regenerated block.
const rawAfter = existing.substring(markerIndex);
const markerStripped = rawAfter
.replace(MSD_CODEX_MARKER, '')
.replace(/^\r?\n# MSD codex_hooks ownership: (?:section|root_dotted)\r?\n/, '');
const afterUser = stripLeakedMsdCodexSections(markerStripped).trim();
// #3610: top-level keys that survived BELOW the marker were file-scoped
// before this merge; the regenerated block opens with the `[agents]` table
// header, so they must be hoisted to FILE scope or TOML re-scopes them
// into a table. File scope means BEFORE the first table header of the
// pre-marker region too — appending them after a pre-marker table (the
// default real-world layout: user tables precede the marker) would merely
// capture them into THAT table instead of [agents], and the schema
// validator is blind to non-agents tables.
const beforeSplit = before ? splitTopLevelKeys(before) : { topLevel: '', rest: '' };
const { topLevel: afterTopLevel, rest: afterTables } = splitTopLevelKeys(afterUser);
const parts = [];
const topParts = [];
if (beforeSplit.topLevel) topParts.push(beforeSplit.topLevel);
if (afterTopLevel) topParts.push(afterTopLevel);
if (topParts.length > 0) parts.push(topParts.join(eol + eol));
if (beforeSplit.rest) parts.push(beforeSplit.rest);
parts.push(normalizedMsdBlock);
if (afterTables) parts.push(afterTables);
atomicWriteFileSync(configPath, parts.join(eol + eol) + eol);
return;
}
// Case 3: No marker — append MSD block
let content = stripLeakedMsdCodexSections(existing).trimEnd();
if (content) {
content = content + eol + eol + normalizedMsdBlock + eol;
} else {
content = normalizedMsdBlock + eol;
}
atomicWriteFileSync(configPath, content);
}
/**
* Repair config.toml files corrupted by pre-#1346 MSD installs.
* Non-boolean keys (e.g. model = "gpt-5.4") that ended up under [features]
* are relocated before the [features] header so Codex can parse them correctly.
* Returns the content unchanged if no trapped keys are found.
*/
function repairTrappedFeaturesKeys(content) {
const eol = detectLineEnding(content);
const lineRecords = getTomlLineRecords(content);
const featuresSection = getTomlTableSections(content)
.find((section) => !section.array && section.path === 'features');
if (!featuresSection) {
return content;
}
// Find non-boolean key-value lines inside [features] that don't belong there.
// Boolean keys (codex_hooks, multi_agent, etc.) are legitimate feature flags.
const trappedLines = lineRecords.filter((record) => {
if (record.tableHeader || record.startsInMultilineString) return false;
if (record.tablePath !== 'features') return false;
if (record.start < featuresSection.headerEnd) return false;
if (record.end + record.eol.length > featuresSection.end) return false;
if (!record.keySegments || record.keySegments.length === 0) return false;
// Check if the value is a boolean — if so, it belongs under [features]
const equalsIndex = findTomlAssignmentEquals(record.text);
if (equalsIndex === -1) return false;
const commentStart = findTomlCommentStart(record.text);
const valueText = record.text
.slice(equalsIndex + 1, commentStart === -1 ? record.text.length : commentStart)
.trim();
if (valueText === 'true' || valueText === 'false') return false;
// Skip values that start a multiline string — they may legitimately live
// under [features] and spanning multiple lines makes relocation unsafe.
if (valueText.startsWith("'''") || valueText.startsWith('"""')) return false;
// Non-boolean value — this key is trapped
return true;
});
if (trappedLines.length === 0) {
return content;
}
// Build the relocated text block from trapped lines
const relocatedText = trappedLines.map((r) => r.text).join(eol) + eol;
// Remove trapped lines from their current positions (with their EOLs)
const removalRanges = trappedLines.map((r) => ({
start: r.start,
end: r.end + r.eol.length,
}));
let cleaned = removeContentRanges(content, removalRanges);
// Collapse any runs of 3+ blank lines left behind
cleaned = collapseTomlBlankLines(cleaned);
// Re-locate the [features] header in the cleaned content
const cleanedRecords = getTomlLineRecords(cleaned);
const cleanedFeaturesHeader = cleanedRecords.find(
(r) => r.tableHeader && r.tableHeader.path === 'features' && !r.tableHeader.array
);
if (!cleanedFeaturesHeader) {
return cleaned;
}
// Insert relocated keys before [features]
const before = cleaned.slice(0, cleanedFeaturesHeader.start);
const after = cleaned.slice(cleanedFeaturesHeader.start);
const needsGap = before.length > 0 && !before.endsWith(eol + eol);
const trailingGap = after.length > 0 && !relocatedText.endsWith(eol + eol) ? eol : '';
return before + (needsGap ? eol : '') + relocatedText + trailingGap + after;
}
function ensureCodexHooksFeature(configContent) {
const eol = detectLineEnding(configContent);
const lineRecords = getTomlLineRecords(configContent);
const featuresSection = getTomlTableSections(configContent)
.find((section) => !section.array && section.path === 'features');
if (featuresSection) {
const sectionLines = lineRecords
.filter((record) =>
!record.tableHeader &&
!record.startsInMultilineString &&
record.tablePath === 'features' &&
record.start >= featuresSection.headerEnd &&
record.end + record.eol.length <= featuresSection.end &&
record.keySegments &&
record.keySegments.length === 1 &&
isCodexHooksFeatureKey(record.keySegments[0])
);
if (sectionLines.length > 0) {
// Rewrite to canonical key — this migrates legacy `codex_hooks` to
// `hooks` in-place on every reinstall. If the file already has the
// canonical key the rewrite is a no-op shape-wise (same key, same
// value). The rewriteTomlKeyLines helper preserves indentation,
// trailing comments, and ownership-marker positioning, and always
// emits the caller-supplied canonical key (#3566).
const rewritten = rewriteTomlKeyLines(configContent, sectionLines, CODEX_HOOKS_FEATURE_KEY);
return {
content: repairTrappedFeaturesKeys(rewritten),
ownership: null,
};
}
const sectionBody = configContent.slice(featuresSection.headerEnd, featuresSection.end);
const needsSeparator = sectionBody.length > 0 && !sectionBody.endsWith('\n') && !sectionBody.endsWith('\r\n');
const insertPrefix = sectionBody.length === 0 && featuresSection.headerEnd === configContent.length ? eol : '';
const insertText = `${insertPrefix}${needsSeparator ? eol : ''}${CODEX_HOOKS_FEATURE_KEY} = true${eol}`;
const merged = configContent.slice(0, featuresSection.end) + insertText + configContent.slice(featuresSection.end);
return {
content: repairTrappedFeaturesKeys(merged),
ownership: 'section',
};
}
const rootFeatureLines = lineRecords
.filter((record) =>
!record.tableHeader &&
!record.startsInMultilineString &&
record.tablePath === null &&
record.keySegments &&
record.keySegments[0] === 'features'
);
const rootCodexHooksLines = rootFeatureLines
.filter((record) => record.keySegments.length === 2 && isCodexHooksFeatureKey(record.keySegments[1]));
if (rootCodexHooksLines.length > 0) {
return {
content: rewriteTomlKeyLines(configContent, rootCodexHooksLines, `features.${CODEX_HOOKS_FEATURE_KEY}`),
ownership: null,
};
}
const rootFeaturesValueLines = rootFeatureLines
.filter((record) => record.keySegments.length === 1);
if (rootFeaturesValueLines.length > 0) {
return { content: configContent, ownership: null };
}
if (rootFeatureLines.length > 0) {
const lastFeatureLine = rootFeatureLines[rootFeatureLines.length - 1];
const insertAt = findTomlAssignmentBlockEnd(configContent, lastFeatureLine);
const prefix = insertAt > 0 && configContent[insertAt - 1] === '\n' ? '' : eol;
return {
content: configContent.slice(0, insertAt) +
`${prefix}features.${CODEX_HOOKS_FEATURE_KEY} = true${eol}` +
configContent.slice(insertAt),
ownership: 'root_dotted',
};
}
const featuresBlock = `[features]${eol}${CODEX_HOOKS_FEATURE_KEY} = true${eol}`;
if (!configContent) {
return { content: featuresBlock, ownership: 'section' };
}
// Insert [features] before the first table header, preserving bare top-level keys.
// Prepending would trap them under [features] where Codex expects only booleans (#1202).
const firstTableHeader = lineRecords.find(r => r.tableHeader);
if (firstTableHeader) {
const before = configContent.slice(0, firstTableHeader.start);
const after = configContent.slice(firstTableHeader.start);
const needsGap = before.length > 0 && !before.endsWith(eol + eol);
return {
content: before + (needsGap ? eol : '') + featuresBlock + eol + after,
ownership: 'section',
};
}
// No table headers — append [features] after top-level keys
const needsGap = configContent.length > 0 && !configContent.endsWith(eol + eol);
return { content: configContent + (needsGap ? eol : '') + featuresBlock, ownership: 'section' };
}
function hasEnabledCodexHooksFeature(configContent) {
const lineRecords = getTomlLineRecords(configContent);
return lineRecords.some((record) => {
if (record.tableHeader || record.startsInMultilineString || !record.keySegments) {
return false;
}
const isSectionKey = record.tablePath === 'features' &&
record.keySegments.length === 1 &&
isCodexHooksFeatureKey(record.keySegments[0]);
const isRootDottedKey = record.tablePath === null &&
record.keySegments.length === 2 &&
record.keySegments[0] === 'features' &&
isCodexHooksFeatureKey(record.keySegments[1]);
if (!isSectionKey && !isRootDottedKey) {
return false;
}
const equalsIndex = findTomlAssignmentEquals(record.text);
if (equalsIndex === -1) {
return false;
}
const commentStart = findTomlCommentStart(record.text);
const valueText = record.text.slice(equalsIndex + 1, commentStart === -1 ? record.text.length : commentStart).trim();
return valueText === 'true';
});
}
// ── Cursor hooks.json reconciler (issue #777) ────────────────────────────────
//
// Cursor v2.4+ supports a hooks.json lifecycle hook system. MSD registers two
// managed command hooks:
// sessionStart → msd-cursor-session-start.js (context injection)
// postToolUse → msd-cursor-post-tool.js (STATE.md update monitor)
//
// hooks.json schema:
// { "version": 1, "hooks": { "<event>": [ { "type": "command", "command": "<path>" } ] } }
//
// Location:
// Global: ~/.cursor/hooks.json
// Local: <project-root>/.cursor/hooks.json
//
// MSD entries are identified by a top-level `"msd-managed": true` field on
// each hook entry. Non-MSD entries are preserved. The reconciler is idempotent
// (safe to re-run) and preserves user-owned entries in the file.
//
// References: https://cursor.com/docs/hooks
// #2876: buildCursorHookEntry, isManagedCursorHookEntry, and
// reconcileCursorHooksJson used to be re-bound here as one-line delegates to
// the equivalent hooksSurface.* implementations. None had an install.js
// internal caller or an export consumer (tests import all three directly from
// msd-core/bin/lib/runtime-hooks-surface.cjs), so the retired bindings were
// dead code with no reachable body — removed rather than kept as unreachable
// wrappers.
/**
* #777 — Write MSD-managed Cursor lifecycle hooks into <targetDir>/hooks.json.
*
* Both managed hook scripts (msd-cursor-session-start.js, msd-cursor-post-tool.js)
* are copied from the MSD hooks/ source to <targetDir>/hooks/ first, so the
* hooks.json entries never reference a script that wasn't installed.
*
* @param {string} targetDir - The Cursor config dir (global: ~/.cursor; local: .cursor)
* @param {string} src - The MSD install source root (for copying hook scripts)
* @param {{ absoluteRunner?: string|null }} opts
* @returns {{ hooksJsonPath: string, changed: boolean }}
*/
function writeCursorHooksJson(targetDir, src, opts) {
return hooksSurface.writeCursorHooksJson(targetDir, src, opts);
}
/**
* Remove all MSD-managed Cursor lifecycle hook entries from hooks.json.
* User-owned entries are preserved. If the file becomes empty, it is removed.
*
* @param {string} targetDir - The Cursor config dir
* @returns {{ changed: boolean }}
*/
function removeCursorHooksJson(targetDir) {
return hooksSurface.removeCursorHooksJson(targetDir);
}
/**
* Generate config.toml and per-agent .toml files for Codex.
* Reads agent .md files from source, extracts metadata, writes .toml configs.
*/
/**
* #2834: Write ~/.msd/defaults.json for non-Claude runtimes — sets
* resolve_model_ids="omit" (so resolveModelInternal() returns '' instead of
* Claude aliases the runtime can't resolve) and runtime=<runtime> (so
* resolveRuntime() resolves correctly out of the box). MUST be called BEFORE
* installCodexConfig (or any other step that reads defaults.json at generation
* time), so a clean first install produces correctly-model-routed agent TOMLs.
* No-op for Claude runtimes (Claude is the resolveRuntime fallback + has native
* model aliases). Preserves an explicit `true` opt-in and existing values.
*/
function writeNonClaudeDefaults(runtime) {
if (_hostBehaviors(runtime).nativeModelAliases || process.env.MSD_TEST_MODE) return;
const msdDir = path.join(os.homedir(), '.msd');
const defaultsPath = path.join(msdDir, 'defaults.json');
let releaseLock = null;
try {
fs.mkdirSync(msdDir, { recursive: true });
// defaults.json is machine-global — every runtime and project on the box
// reads it. Serialize the read-modify-write so two concurrent installs
// cannot lose each other's key, and apply both mutations in ONE atomic
// write so a crash cannot leave the file truncated (the read path swallows
// parse errors and treats a corrupt file as absent, which would silently
// degrade model resolution everywhere until repaired by hand).
releaseLock = acquireInstallMigrationLock(msdDir);
let defaults = {};
try { defaults = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')); } catch { /* new file */ }
if (defaults === null || typeof defaults !== 'object' || Array.isArray(defaults)) {
defaults = {};
}
const applied = [];
// Three-valued domain: false/absent → aliases; true → full IDs; "omit" → ''.
const existing = defaults.resolve_model_ids;
const shouldDefaultToOmit = existing !== true && existing !== 'omit';
if (shouldDefaultToOmit) {
defaults.resolve_model_ids = 'omit';
applied.push(`Set resolve_model_ids: "omit" in ~/.msd/defaults.json`);
}
// #2395: persist runtime for non-Claude runtimes.
if (defaults.runtime === undefined || defaults.runtime === null || defaults.runtime === '') {
defaults.runtime = runtime;
applied.push(`Set runtime: "${runtime}" in ~/.msd/defaults.json`);
}
if (applied.length > 0) {
atomicWriteFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n', 'utf8');
for (const message of applied) console.log(` ${green}✓${reset} ${message}`);
}
} catch (e) {
console.log(` ${yellow}⚠${reset} Could not write ~/.msd/defaults.json: ${e.message}`);
} finally {
if (releaseLock) {
try {
releaseLock();
} catch (releaseError) {
// A leaked lock blocks the next install, so surface it rather than
// swallowing; the stale-lock reaper clears it once this pid exits.
console.log(` ${yellow}⚠${reset} Could not release the ~/.msd install lock: ${releaseError.message}`);
}
}
}
}
function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-sandbox') {
// ADR-1239 Phase B write-confinement: every Codex config write stays under targetDir.
const configPath = assertDestWithinConfigHome(targetDir, 'config.toml');
const agentsTomlDir = assertDestWithinConfigHome(targetDir, 'agents');
const resolvedTargetRoot = path.resolve(targetDir);
// Symlink-escape guard (parity with _copyStaged / copyWithPathReplacement): the
// lexical gate above does not resolve symlinks, so a pre-existing config.toml or
// agents/ symlink could redirect writes outside targetDir. Reject those.
// #2393: honor MSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
const symlinkOptIn = isSymlinkedDestOptIn();
if (
hasExistingSymlinkBetween(resolvedTargetRoot, configPath, { allowOptInFollow: symlinkOptIn }) ||
hasExistingSymlinkBetween(resolvedTargetRoot, path.resolve(agentsTomlDir), { allowOptInFollow: symlinkOptIn })
) {
throw new Error(
`installCodexConfig: a Codex config path under "${targetDir}" contains a symlink the install root does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with MSD_ALLOW_SYMLINKED_DEST=1.`,
);
}
fs.mkdirSync(agentsTomlDir, { recursive: true });
// #3897 rung 3 (CAUSE B fix) — validateCodexSandboxHolds is deliberately
// NOT called here. The "no stale holds" invariant is a REPO invariant about
// the canonical roster in `agents/`, not a property of whatever directory
// an install happens to read from: a partial or synthetic `agentsSrc` (a
// test fixture, a `--config-dir` subset) legitimately contains only a few
// agents, and a held role simply absent from THIS source dir must be
// inert, not fatal. Throwing here also masked unrelated failures further
// down this same loop (e.g. the name-injection/path-escape guard on
// `agentTomlPath` below), since this check ran first and unconditionally.
// The invariant is still enforced — as a test over the real `agents/`
// roster (tests/codex-config.test.cjs T24/T25) — just never on this
// runtime path. See src/codex-agent-toml.cts's `validateCodexSandboxHolds`
// docblock for the full rationale.
//
// #3897 security review F2 (assessed post-F1-fix, verified by execution —
// not asserted): before F1's fix, the "runtime detector removed" gap here
// was real — a rename (case a) or a sibling `name:` clobber (case b) could
// reach this loop and land a held role's `.toml` at `workspace-write` with
// nothing here to catch it. After F1 (`deriveCodexSandboxMode` now decides
// over BOTH the filename stem and the resolved frontmatter `name:`, most
// restrictive wins), both cases were re-run end-to-end through this exact
// function and the EMITTED ARTIFACT for both is `read-only` — the
// dangerous condition no longer produces a wrong artifact, it produces the
// SAFE one. A detector guarding a now-fail-safe condition is not
// load-bearing, and restoring a throw here would re-break the legitimate
// partial-source-dir case CAUSE B removed it for (see above). Regression
// coverage for both cases lives in `tests/codex-config.test.cjs` (F1(a)
// rename / F1(b) sibling-clobber rows), asserted on the emitted `.toml`'s
// `sandbox_mode`, not on the derivation's return value.
const agentEntries = fs.readdirSync(agentsSrc).filter(f => f.startsWith('msd-') && f.endsWith('.md'));
const agents = [];
// Compute the Codex MSD install path (absolute, so subagents with empty $HOME work — #820)
const codexMsdPath = `${path.resolve(targetDir, 'msd-core').replace(/\\/g, '/')}/`;
for (const file of agentEntries) {
const agentTomlSourcePath = path.join(agentsSrc, file);
let content = fs.readFileSync(agentTomlSourcePath, 'utf8');
// #2995 (epic #1671 Phase 6.4): Codex embeds each agent's prompt into a
// per-agent `.toml`, reading the source .md independently of the inline
// agent loop — a separate emission path that must strip msd:section
// markers too, or a marked agent ships its markers inside the TOML.
// Found by the exhaustive per-runtime emission sweep in
// tests/agent-fragments-emission.install.test.cjs, not by call-graph
// analysis, which is why that guard is behavioral rather than structural.
content = composeWorkflow(content, { sourcePath: agentTomlSourcePath });
// Replace full .claude/msd-core prefix so path resolves to the Codex
// MSD install before generic .claude → .codex conversion rewrites it.
content = content.replace(/~\/\.claude\/msd-core\//g, codexMsdPath);
content = content.replace(/\$HOME\/\.claude\/msd-core\//g, codexMsdPath);
// Route TOML emit through the same full Claude→Codex conversion pipeline
// used on the `.md` emit path (#2639). Covers: slash-command rewrites,
// $ARGUMENTS → {{MSD_ARGS}}, /clear removal, anchored and bare .claude/
// paths, .claudeignore → .codexignore, and standalone "Claude" /
// CLAUDE.md neutralization via neutralizeAgentReferences(..., 'AGENTS.md').
content = convertClaudeToCodexMarkdown(content);
const { frontmatter } = extractFrontmatterAndBody(content);
// #3897 security review F1 (blocker, post-merge): this loop used to key
// the sandbox/hold decision off ONLY the filename stem while the emitted
// `.toml`'s OUTPUT PATH below is keyed off `name` (frontmatter-derived,
// attacker-editable) — so a renamed source file, or a sibling file whose
// `name:` collides with a held role, could make the decided identity and
// the landed artifact disagree, widening a held role's own file to
// `workspace-write`. `generateCodexAgentToml` (below) now derives
// `sandbox_mode` over BOTH the filename stem it is given AND the
// frontmatter `name:` it resolves internally, taking the most
// restrictive result — this loop no longer needs to choose one identity
// for that call; see `deriveCodexSandboxMode`'s doc in
// `codex-agent-toml.cts` for the resolution.
const fileStem = file.replace(/\.md$/, '');
const name = extractFrontmatterField(frontmatter, 'name') || fileStem;
const description = extractFrontmatterField(frontmatter, 'description') || '';
agents.push({ name, description: toSingleLine(description) });
// Pass model overrides from both per-project `.planning/config.json` and
// `~/.msd/defaults.json` (project wins on conflict) so Codex TOML files
// embed the configured model — Codex cannot receive model inline (#2256).
// Previously only the global file was read, which silently dropped the
// per-project override the reporter had set for msd-codebase-mapper.
// #2517 — also pass the runtime-aware tier resolver so profile tiers can
// resolve to Codex-native model IDs + reasoning_effort when `runtime: "codex"`
// is set in defaults.json.
const modelOverrides = readMsdEffectiveModelOverrides(targetDir);
// Pass `targetDir` so per-project .planning/config.json wins over global
// ~/.msd/defaults.json — without this, the PR's headline claim that
// setting runtime in the project config reaches the Codex emit path is
// false (review finding #1).
const runtimeResolver = readMsdRuntimeProfileResolver(targetDir);
// #443 — pass unified effort config so model_reasoning_effort in the .toml
// follows the same config-driven precedence as the Claude .md effort key.
const effortCfg = readMsdEffectiveEffortConfig(targetDir);
// Pass `fileStem`; `generateCodexAgentToml` itself additionally resolves
// and folds in the frontmatter `name:` for the sandbox decision (F1
// above) — this call site does not need to pass `name` explicitly.
const tomlContent = generateCodexAgentToml(fileStem, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier);
// Confine the per-agent write to the agents/ dir itself: a crafted agent
// `name` containing path separators must not escape agents/ (which would let
// it clobber config.toml or write elsewhere under the configHome).
const agentTomlPath = assertDestWithinConfigHome(agentsTomlDir, `${name}.toml`);
if (hasExistingSymlinkBetween(resolvedTargetRoot, agentTomlPath, { allowOptInFollow: symlinkOptIn })) {
throw new Error(
`installCodexConfig: agent toml path "${agentTomlPath}" contains a symlink the install root does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with MSD_ALLOW_SYMLINKED_DEST=1.`,
);
}
fs.writeFileSync(agentTomlPath, tomlContent);
}
const msdBlock = generateCodexConfigBlock(agents, targetDir);
mergeCodexConfig(configPath, msdBlock);
return agents.length;
}
/**
* Runtime-neutral agent name and instruction file replacement.
* Used by ALL non-Claude runtime converters to avoid Claude-specific
* references in workflow prompts, agent definitions, and documentation.
*
* Replaces:
* - Standalone "Claude" (agent name) → "the agent"
* Preserves: "Claude Code" (product), "Claude Opus/Sonnet/Haiku" (models),
* "claude-" (prefixes), "CLAUDE.md" (handled separately)
* - "CLAUDE.md" → runtime-appropriate instruction file
* - "Do NOT load full AGENTS.md" → removed (harmful for AGENTS.md runtimes)
*
* @param {string} content - File content to neutralize
* @param {string} instructionFile - Runtime's instruction file ('AGENTS.md', 'GEMINI.md', etc.)
* @returns {string} Content with runtime-neutral references
*/
function neutralizeAgentReferences(content, instructionFile) {
let c = content;
// Replace standalone "Claude" (the agent) but preserve product/model names.
// Negative lookahead avoids: Claude Code, Claude Opus/Sonnet/Haiku, Claude native, Claude-based
c = c.replace(/\bClaude(?! Code| Opus| Sonnet| Haiku| native| based|-)\b(?!\.md)/g, 'the agent');
// Replace CLAUDE.md with runtime-appropriate instruction file
if (instructionFile) {
c = c.replace(/CLAUDE\.md/g, instructionFile);
}
// Remove instructions that conflict with AGENTS.md-based runtimes
c = c.replace(/Do NOT load full `AGENTS\.md` files[^\n]*/g, '');
return c;
}
function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOverride = null } = {}) {
// Replace tool name references in content (applies to all files)
let convertedContent = filterRuntimeNotesForTarget(content, 'opencode');
convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill');
convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite');
// Replace /msd-command colon variant with /msd-command for opencode (flat command structure)
convertedContent = convertedContent.replace(/\/msd:/g, '/msd-');
// Replace ~/.claude and $HOME/.claude with OpenCode's config location
convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/opencode');
convertedContent = convertedContent.replace(/\$HOME\/\.claude\b/g, '$HOME/.config/opencode');
// Replace general-purpose subagent type with OpenCode's equivalent "general"
convertedContent = convertedContent.replace(/subagent_type="general-purpose"/g, 'subagent_type="general"');
// Runtime-neutral agent name replacement (#766)
convertedContent = neutralizeAgentReferences(convertedContent, 'AGENTS.md');
// Check if content has frontmatter
if (!convertedContent.startsWith('---')) {
return convertedContent;
}
// Find the end of frontmatter
const endIndex = convertedContent.indexOf('---', 3);
if (endIndex === -1) {
return convertedContent;
}
const frontmatter = convertedContent.substring(3, endIndex).trim();
const body = convertedContent.substring(endIndex + 3);
// Parse frontmatter line by line (simple YAML parsing)
const lines = frontmatter.split('\n');
const newLines = [];
let inAllowedTools = false;
let inSkippedArray = false;
const allowedTools = [];
for (const line of lines) {
const trimmed = line.trim();
// For agents: skip commented-out lines (e.g. hooks blocks)
if (isAgent && trimmed.startsWith('#')) {
continue;
}
// Detect start of allowed-tools array
if (trimmed.startsWith('allowed-tools:')) {
inAllowedTools = true;
continue;
}
// Detect inline tools: field (comma-separated string)
if (trimmed.startsWith('tools:')) {
if (isAgent) {
// Agents: strip tools entirely (not supported in OpenCode agent frontmatter)
inSkippedArray = true;
continue;
}
const toolsValue = trimmed.substring(6).trim();
if (toolsValue) {
// Parse comma-separated tools
const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t);
allowedTools.push(...tools);
}
continue;
}
// For agents: strip skills:, color:, memory:, maxTurns:, permissionMode:, disallowedTools:
if (isAgent && /^(skills|color|memory|maxTurns|permissionMode|disallowedTools):/.test(trimmed)) {
inSkippedArray = true;
continue;
}
// Skip continuation lines of a stripped array/object field
if (inSkippedArray) {
if (trimmed.startsWith('- ') || trimmed.startsWith('#') || /^\s/.test(line)) {
continue;
}
inSkippedArray = false;
}
// For commands: remove name: field (opencode uses filename for command name)
// For agents: keep name: (required by OpenCode agents)
if (!isAgent && trimmed.startsWith('name:')) {
continue;
}
// Strip model: field — OpenCode doesn't support Claude Code model aliases
// like 'haiku', 'sonnet', 'opus', or 'inherit'. Omitting lets OpenCode use
// its configured default model. See #1156.
if (trimmed.startsWith('model:')) {
continue;
}
// Convert color names to hex for opencode (commands only; agents strip color above)
if (trimmed.startsWith('color:')) {
const colorValue = trimmed.substring(6).trim().toLowerCase();
const hexColor = colorNameToHex[colorValue];
if (hexColor) {
newLines.push(`color: "${hexColor}"`);
} else if (colorValue.startsWith('#')) {
// Validate hex color format (#RGB or #RRGGBB)
if (/^#[0-9a-f]{3}$|^#[0-9a-f]{6}$/i.test(colorValue)) {
// Already hex and valid, keep as is
newLines.push(line);
}
// Skip invalid hex colors
}
// Skip unknown color names
continue;
}
// Collect allowed-tools items
if (inAllowedTools) {
if (trimmed.startsWith('- ')) {
allowedTools.push(trimmed.substring(2).trim());
continue;
} else if (trimmed && !trimmed.startsWith('-')) {
// End of array, new field started
inAllowedTools = false;
}
}
// Keep other fields
if (!inAllowedTools) {
newLines.push(line);
}
}
// For agents: add required OpenCode agent fields
// Note: Do NOT add 'model: inherit' — OpenCode does not recognize the 'inherit'
// keyword and throws ProviderModelNotFoundError. Omitting model: lets OpenCode
// use its default model for subagents. See #1156.
if (isAgent) {
newLines.push('mode: subagent');
// Embed model override from ~/.msd/defaults.json so model_overrides is
// respected on OpenCode (which uses static agent frontmatter, not inline
// Task() model parameters). See #2256.
if (modelOverride) {
newLines.push(`model: ${modelOverride}`);
}
}
// For commands: add tools object if we had allowed-tools or tools
if (!isAgent && allowedTools.length > 0) {
newLines.push('tools:');
for (const tool of allowedTools) {
newLines.push(` ${convertToolName(tool)}: true`);
}
}
// Rebuild frontmatter (body already has tool names converted)
const newFrontmatter = newLines.join('\n').trim();
return `---\n${newFrontmatter}\n---${body}`;
}
// convertClaudeCommandToOpencodeFamilySkill, convertClaudeCommandToOpencodeSkill:
// moved to src/install-engine.cts (ADR-1239 Phase B).
// #2876 found no install.js internal caller for the latter (tests import
// it directly from msd-core/bin/lib/install-engine.cjs) and retired the
// destructured binding above.
// applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B).
// #2876 found no install.js internal caller (never had one either — it was
// never part of this module's export surface) and retired the destructured
// binding above.
//
// copyFlattenedCommands (OpenCode flattened command/ writer): moved to
// src/install-engine.cts as installOpencodeFamilyCommands (ADR-1239 / #2087).
// OpenCode installs now route through installRuntimeArtifacts's
// combinedFamilyInstall path (installOpencodeFamilyArtifacts) instead of the
// bespoke inline block that used to call this function.
function listCodexSkillNames(skillsDir, prefix = 'msd-') {
if (!fs.existsSync(skillsDir)) return [];
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
return entries
.filter(entry => entry.isDirectory() && entry.name.startsWith(prefix))
.filter(entry => fs.existsSync(path.join(skillsDir, entry.name, 'SKILL.md')))
.map(entry => entry.name)
.sort();
}
/**
* Generic skills install helper used by all copyCommandsAs*Skills shims.
*
* Recursively walks srcDir, applies converter to each .md file (mirroring the
* old per-function recurse() bodies), applies runtime content rewrites
* (path + branding), and writes each skill as <prefix>-<stem>/SKILL.md under
* skillsDir. Replaces the ~50-line recursion bodies in the 9 old functions.
*
* @param {string} srcDir source commands directory
* @param {string} skillsDir destination skills directory
* @param {string} prefix skill name prefix without trailing dash (e.g. 'msd')
* @param {string} pathPrefix trailing-slash path prefix for content rewrites
* @param {string} runtime canonical runtime ID for rewrite table
* @param {Function} converter wrapped converter (content, skillName) → string
*/
/**
* Copy Claude commands as Claude skills — one folder per skill with SKILL.md.
* Claude Code 2.1.88+ uses skills/xxx/SKILL.md instead of commands/msd/xxx.md.
* Branding rewrites are applied via
* applyRuntimeContentRewritesInPlace inside _copyCommandsAsSkillsViaConverter.
* @param {string} srcDir - Source commands directory
* @param {string} skillsDir - Target skills directory
* @param {string} prefix - Skill name prefix (e.g. 'msd')
* @param {string} pathPrefix - Path prefix for file references
* @param {string} runtime - Target runtime
* @param {boolean} isGlobal - Whether this is a global install (unused; kept for compat)
*/
/**
* Recursively install MSD commands as Antigravity skills.
* Each command becomes a skill-name/ folder containing SKILL.md.
* @param {string} srcDir - Source commands directory
* @param {string} skillsDir - Target skills directory
* @param {string} prefix - Skill name prefix (e.g. 'msd')
* @param {boolean} isGlobal - Whether this is a global install
*/
// USER_OWNED_ARTIFACTS, _snapshotDir,
// installRuntimeArtifacts, installOpencodeFamilySkills, uninstallRuntimeArtifacts:
// ALL moved to src/install-engine.cts (ADR-1239 Phase B). Imported from
// installEngine above. _copyStaged, _removeMsdEntries,
// _runLegacyInstallMigrations, _runLegacyUninstallCleanup, and _restoreDir
// moved there too but #2876 found no install.js
// internal caller for any of them and retired their destructured bindings.
// ---------------------------------------------------------------------------
// Phase 2 — Layout-driven install/uninstall orchestrators (moved to engine)
// _applyRuntimeRewrites / _stampNonClaudeRuntimeDefaults remain here for
// call sites in copyWithPathReplacement (not moved).
// ---------------------------------------------------------------------------
const _applyRuntimeRewrites = runtimeArtifactConversion._applyRuntimeRewrites;
const _stampNonClaudeRuntimeDefaults = runtimeArtifactConversion._stampNonClaudeRuntimeDefaults;
/**
* Data-driven dispatch table for copyWithPathReplacement (ADR-1239 Phase B).
* Keyed by runtime id. Each entry declares ONLY what that runtime does differently.
* The DEFAULT (no entry, or entry with no md/js key) = identity transform after
* the uniform steps — covers claude, zcode, etc.
*
* Entry shape:
* mdSkipGenericRewrite?: boolean — skip the ~/.claude/ rewrite block (antigravity)
* md?: (content, ctx) => string — per-runtime .md transform
* mdReattributeAfter?: boolean — re-run processAttribution after md() (antigravity)
* mdTomlRenameOnCommand?: boolean — when isCommand, rename dest .md → .toml
* (unused since the gemini runtime was removed, #1928;
* kept as generic dispatch infra for a future TOML-command runtime)
* js?: (content, ctx) => string — per-runtime .cjs/.js transform (absent = plain copyFileSync)
*
* ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName, runtime }
*/
const RUNTIME_CONTENT_DISPATCH = {
opencode: {
md: (content) => convertClaudeToOpencodeFrontmatter(content),
},
codex: {
md: (content) => convertClaudeToCodexMarkdown(content),
},
antigravity: {
mdSkipGenericRewrite: true,
md: (content, ctx) => convertClaudeToAntigravityContent(content, ctx.isGlobal),
mdReattributeAfter: true,
js: (content, ctx) => convertClaudeToAntigravityContent(content, ctx.isGlobal),
},
cursor: {
md: (content) => convertClaudeToCursorMarkdown(content),
js: (content) => {
content = content.replace(/msd:/gi, 'msd-');
content = content.replace(/\.claude\/skills\//g, '.cursor/skills/');
content = content.replace(/CLAUDE\.md/g, '.cursor/rules/');
content = content.replace(/\bClaude Code\b/g, 'Cursor');
return content;
},
},
};
/**
* Recursively copy directory, replacing paths in .md files
* Deletes existing destDir first to remove orphaned files from previous versions
* @param {string} srcDir - Source directory
* @param {string} destDir - Destination directory
* @param {string} pathPrefix - Path prefix for file references
* @param {string} runtime - Target runtime ('claude', 'opencode', 'codex')
* @param {boolean} isCommand - Whether the source is a command directory
* @param {boolean} isGlobal - Whether the install is global
*/
function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false, isGlobal = false, confinementRoot) {
const dirName = getDirName(runtime);
// ADR-1239 Phase B write-confinement: refuse to wipe/write a destDir that
// escapes the caller-declared install root. Runs BEFORE the rmSync below so a
// crafted destDir can never delete or write outside confinementRoot.
if (confinementRoot === undefined) {
throw new Error(
'copyWithPathReplacement: confinementRoot is required to confine writes to the install root — refusing to write',
);
}
const resolvedConfinementRoot = path.resolve(confinementRoot);
const resolvedDestDir = assertDestWithinConfigHome(confinementRoot, destDir);
// #2393: honor MSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
if (hasExistingSymlinkBetween(resolvedConfinementRoot, resolvedDestDir, { allowOptInFollow: isSymlinkedDestOptIn() })) {
throw new Error(
`copyWithPathReplacement: destDir "${destDir}" contains a symlink the install root "${confinementRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with MSD_ALLOW_SYMLINKED_DEST=1.`,
);
}
// Use the validated absolute path for all writes below so the gate validates
// exactly what is written (a relative destDir would otherwise resolve to cwd).
destDir = resolvedDestDir;
// Clean install: remove existing destination to prevent orphaned files
if (fs.existsSync(destDir)) {
fs.rmSync(destDir, { recursive: true });
}
fs.mkdirSync(destDir, { recursive: true });
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// #3333: srcPath was enumerated by readdirSync above, but a filesystem is not
// transactional — the file it named can vanish between listing and this read
// (a concurrent process, or another test in this suite writing/cleaning up a
// fixture inside this same real directory). Treat "gone by the time we get
// here" as benign and skip it, never a fatal crash of the whole install.
if (!entry.isDirectory() && !fs.existsSync(srcPath)) {
continue;
}
if (entry.isDirectory()) {
copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal, confinementRoot);
} else if (entry.name.endsWith('.md')) {
const dispatch = RUNTIME_CONTENT_DISPATCH[runtime] || {};
const ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName: entry.name, runtime };
// Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths
// Skip generic replacement for Antigravity — its converter handles all paths
let content = fs.readFileSync(srcPath, 'utf8');
// #2930 (epic #1671 Phase 3): strip `<!-- msd:section -->` markers
// BEFORE any per-runtime rewrite so a `.claude/` -> `.cursor/` regex
// (or any other converter below) never reaches inside a marker
// attribute and corrupts it. composeWorkflow is a no-op (byte-identical
// return) for the 88+ workflows and every non-workflow .md that carries
// no markers, and for a malformed marker it throws loudly naming
// srcPath — never emit a half-composed workflow.
//
// Scoped to msd-core/workflows/ ONLY (two independent reviewers,
// chore/2930): copyWithPathReplacement is the emit path for every .md
// under msd-core/, skills/, and commands/ (see the three call sites),
// not just workflows. A doc that merely DOCUMENTS the marker syntax
// with an unfenced example (docs/reference/workflow-fragments.md is
// the live instance of this class, though not under the install tree
// today) would otherwise get silently mis-parsed as a real marker and
// that line lossily dropped — a file class issue #2930 never scoped
// to. Path is normalized UNCONDITIONALLY (backslash paths arrive on
// Linux too — CONTEXT.md path-separator rule) and checked as a
// path-segment match so the recursive descent (srcPath may be several
// directory levels below msd-core/workflows/) is still caught.
//
// #3072: the scoping predicate itself now lives in ONE place —
// shouldCompose (src/mcp-catalog.cts, imported above) — rather than
// being re-declared inline here. The MCP served catalog calls the
// SAME function to decide what it composes vs serves verbatim, so this
// install path and the catalog can never independently drift on what
// gets composed (ADR-1671:309, DEFECT.GENERATIVE-FIX; the parity gate
// is tests/mcp-catalog-parity.test.cjs). shouldCompose normalizes with
// the identical unconditional `.replace(/\\/g, '/')` internally, so
// this call is behavior-preserving byte-for-behavior with the regex it
// replaces.
if (shouldCompose(srcPath)) {
content = composeWorkflow(content, { sourcePath: srcPath });
}
content = filterRuntimeNotesForTarget(content, runtime);
if (!dispatch.mdSkipGenericRewrite) {
// #4377: with a project-relative prefix, mask `${VAR:-default}` shell
// defaults out of the substitutions below and restore them after. The
// runtime launcher snippet probes msd-tools through a chain of those
// (`${CLAUDE_CONFIG_DIR:-$HOME/.claude}/msd-core/bin/...`, one per
// runtime); they are shell word expansions, not markdown @ includes,
// and a relative value there resolves against the shell's cwd instead
// of the project. Swapping an include that points at the wrong
// checkout for a path that points at nothing is not a fix, and the
// launcher already probes `$(git rev-parse --show-toplevel)/.claude`
// first, so the multi-worktree case is handled before these defaults
// are ever reached. The shared helper is the single owner of the
// balanced masking grammar used by this path and the rewrite engine.
const rewriteGenericPaths = (body) => {
content = body;
const globalClaudeRegex = /~\/\.claude\//g;
const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
const localClaudeRegex = /\.\/\.claude\//g;
content = content.replace(globalClaudeRegex, pathPrefix);
content = content.replace(globalClaudeHomeRegex, pathPrefix);
content = content.replace(localClaudeRegex, `./${dirName}/`);
// #3544 review (Finding 1 fallout): guarded with the SAME
// negative-lookahead convention already used at ~:2859-2860 below
// ("preserve .claude-plugin and .claudeignore"). A naive `\b` here
// is satisfied by ANY non-word character, including '-' — so for a
// --config-dir whose name EXTENDS '.claude' (e.g. '.claude-work',
// pathPrefix '$HOME/.claude-work/'), this pass re-matched the
// '$HOME/.claude' PREFIX of its own slash-form output (lines above)
// and re-appended the full prefix, corrupting every emitted path to
// '$HOME/.claude-work-work/...'. Harmless no-op for the literal
// default '.claude' (self-replace with an identical string), which
// is why this went undetected until a non-default config-dir name
// was exercised.
content = content.replace(/~\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
content = content.replace(/\$HOME\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
// #3544: restore @-file-reference lines to the tilde form Claude Code
// actually expands — the SAME correction #3133 already applies to
// skill/command bodies via _applyRuntimeRewrites's 'claude' case (see
// restoreClaudeGlobalAtRefTilde's doc comment in
// runtime-artifact-conversion.cts). This is the msd-core/ spec-tree
// emit path, which never had it: every @~/.claude/msd-core/… include
// in a global install's workflows/references tree silently resolved
// to nothing (54 includes across 22 files on a live install).
if (_hostBehaviors(runtime).ownsClaudePaths) {
content = runtimeArtifactConversion._restoreClaudeGlobalAtRefTilde(content, pathPrefix);
}
return content;
};
content = runtimeArtifactConversion._isRelativePathPrefix(pathPrefix)
? runtimeArtifactConversion._withShellDefaultsPreserved(content, rewriteGenericPaths)
: rewriteGenericPaths(content);
}
content = processAttribution(content, getCommitAttribution(runtime));
// #1521: stamp the workflow runtime-resolution block so every non-Claude
// install resolves its own runtime identity and defaults use_worktrees=false.
// copyWithPathReplacement is the emit path for msd-core/workflows/*.md;
// _applyRuntimeRewrites is NOT invoked here, so this is what makes the fix
// live in real installs (it is a no-op for files without those lines).
if (!_hostBehaviors(runtime).authorsCanonicalWorkflow) {
content = _stampNonClaudeRuntimeDefaults(content, runtime);
}
// #3683 — normalize /msd:<cmd> → /msd-<cmd> in any body passing through
// copyWithPathReplacement for runtimes that register commands under the
// hyphen form; normalizeAgentBodyForRuntime self-gates on
// shouldNormalizeHyphenNamespaceInAgentBody(runtime) and is a no-op for
// colon-canonical / self-converting runtimes.
content = normalizeAgentBodyForRuntime(content, runtime, readMsdCommandNames());
// Apply per-runtime .md converter (if any)
if (dispatch.md) content = dispatch.md(content, ctx);
// Re-run attribution after converter for runtimes that need it (antigravity)
if (dispatch.mdReattributeAfter) content = processAttribution(content, getCommitAttribution(runtime));
// Rename .md → .toml for command files (unused since gemini removal, #1928)
const finalPath = (dispatch.mdTomlRenameOnCommand && isCommand) ? destPath.replace(/\.md$/, '.toml') : destPath;
fs.writeFileSync(finalPath, content);
} else if (entry.name.endsWith('.cjs') || entry.name.endsWith('.js')) {
const dispatch = RUNTIME_CONTENT_DISPATCH[runtime] || {};
if (dispatch.js) {
const ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName: entry.name, runtime };
let content = fs.readFileSync(srcPath, 'utf8');
content = dispatch.js(content, ctx);
fs.writeFileSync(destPath, content);
} else {
fs.copyFileSync(srcPath, destPath);
}
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Clean up orphaned hook registrations from settings.json
*/
function cleanupOrphanedHooks(settings) {
const orphanedHookPatterns = [
'msd-notify.sh', // Removed in v1.6.x
'hooks/statusline.js', // Renamed to msd-statusline.js in v1.9.0
'msd-intel-index.js', // Removed in v1.9.2
'msd-intel-session.js', // Removed in v1.9.2
'msd-intel-prune.js', // Removed in v1.9.2
];
let cleanedHooks = false;
// Check all hook event types (Stop, SessionStart, etc.)
if (settings.hooks) {
for (const eventType of Object.keys(settings.hooks)) {
const hookEntries = settings.hooks[eventType];
if (Array.isArray(hookEntries)) {
// Filter out entries that contain orphaned hooks
const filtered = hookEntries.filter(entry => {
if (entry.hooks && Array.isArray(entry.hooks)) {
// Check if any hook in this entry matches orphaned patterns
const hasOrphaned = entry.hooks.some(h =>
h.command && orphanedHookPatterns.some(pattern => h.command.includes(pattern))
);
if (hasOrphaned) {
cleanedHooks = true;
return false; // Remove this entry
}
}
return true; // Keep this entry
});
settings.hooks[eventType] = filtered;
}
}
}
if (cleanedHooks) {
console.log(` ${green}✓${reset} Removed orphaned hook registrations`);
}
// Fix #330: Update statusLine if it points to old MSD statusline.js path
// Only match the specific old MSD path pattern (hooks/statusline.js),
// not third-party statusline scripts that happen to contain 'statusline.js'
if (settings.statusLine && settings.statusLine.command &&
/hooks[\/\\]statusline\.js/.test(settings.statusLine.command)) {
settings.statusLine.command = settings.statusLine.command.replace(
/hooks([\/\\])statusline\.js/,
'hooks$1msd-statusline.js'
);
console.log(` ${green}✓${reset} Updated statusline path (hooks/statusline.js → hooks/msd-statusline.js)`);
}
return settings;
}
/**
* Validate hook field requirements to prevent silent settings.json rejection.
*
* Claude Code validates the entire settings file with a strict Zod schema.
* If ANY hook has an invalid schema (e.g., type: "agent" missing "prompt"),
* the ENTIRE settings.json is silently discarded — disabling all plugins,
* env vars, and other configuration.
*
* This defensive check removes invalid hook entries and cleans up empty
* event arrays to prevent this. It validates:
* - agent hooks require a "prompt" field
* - command hooks require a "command" field
* - entries must have a valid "hooks" array (non-array/missing is removed)
*
* @param {object} settings - The settings object (mutated in place)
* @returns {object} The same settings object
*/
function validateHookFields(settings) {
if (!settings.hooks || typeof settings.hooks !== 'object') return settings;
let fixedHooks = false;
const emptyKeys = [];
for (const [eventType, hookEntries] of Object.entries(settings.hooks)) {
if (!Array.isArray(hookEntries)) continue;
// Pass 1: validate each entry, building a new array without mutation
const validated = [];
for (const entry of hookEntries) {
// Entries without a hooks sub-array are structurally invalid — remove them
if (!entry.hooks || !Array.isArray(entry.hooks)) {
fixedHooks = true;
continue;
}
// Filter invalid hooks within the entry
const validHooks = entry.hooks.filter(h => {
if (h.type === 'agent' && !h.prompt) {
fixedHooks = true;
return false;
}
if (h.type === 'command' && !h.command) {
fixedHooks = true;
return false;
}
return true;
});
// Drop entries whose hooks are now empty
if (validHooks.length === 0) {
fixedHooks = true;
continue;
}
// Build a clean copy instead of mutating the original entry
validated.push({ ...entry, hooks: validHooks });
}
settings.hooks[eventType] = validated;
// Collect empty event arrays for removal (avoid delete during iteration)
if (validated.length === 0) {
emptyKeys.push(eventType);
fixedHooks = true;
}
}
// Pass 2: remove empty event arrays
for (const key of emptyKeys) {
delete settings.hooks[key];
}
if (fixedHooks) {
console.log(` ${green}✓${reset} Fixed invalid hook entries (prevents settings.json schema rejection)`);
}
return settings;
}
/**
* MSD hook filenames removed during uninstall.
* Module-level so tests can assert structurally instead of regex-parsing source
* (retires pending-migration-to-typed-ir on hooks-opt-in.test.cjs, per #455).
*
* Derived from _HOOKS_TO_COPY (scripts/build-hooks.js — the SAME single source
* of truth INSTALLED_HOOK_FILES uses for manifest-tracking above) instead of a
* separately hand-maintained literal array. The hand-maintained array had
* silently drifted out of sync with the install-time set — missing
* msd-check-update-worker.js, msd-ensure-canonical-path.js,
* managed-hooks-registry.cjs, msd-cursor-pre-tool.js, msd-cursor-stop.js,
* msd-cursor-subagent-start.js, msd-cursor-subagent-stop.js, and
* msd-worktree-path-guard.js — so every one of those files (and the hooks/ dir
* itself, via the non-empty-dir rmdir guard) was left behind on uninstall for
* every settings-json-hook runtime. `msd-check-update.cmd` is added on top: a
* Windows-only SessionStart shim generated at install time (not copied from
* hooks/dist/, so it is not in _HOOKS_TO_COPY).
*/
const MSD_UNINSTALL_HOOKS = [..._HOOKS_TO_COPY, 'msd-check-update.cmd'];
/**
* Uninstall MSD from the specified directory for a specific runtime
* Removes only MSD-specific files/directories, preserves user content
* @param {boolean} isGlobal - Whether to uninstall from global or local
* @param {string} runtime - Target runtime ('claude', 'opencode', 'codex', ...)
*/
function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
const { isOpencode, isCodex, isCursor } = runtimeFlags(runtime);
const dirName = getDirName(runtime);
// Get the target directory based on runtime and install type, mirroring the
// install() path resolution.
const targetDir = isGlobal
? getGlobalConfigDir(runtime, explicitConfigDir)
: path.join(process.cwd(), dirName);
const locationLabel = isGlobal
? targetDir.replace(os.homedir(), '~')
: targetDir.replace(process.cwd(), '.');
// runtimeLabel is now the single-source getRuntimeLabel lookup (ADR-1239
// Phase B / #1679) — collapses the prior 15-line assignment chain.
const runtimeLabel = getRuntimeLabel(runtime);
console.log(` Uninstalling MSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
// Check if target directory exists
if (!fs.existsSync(targetDir)) {
console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
console.log(` Nothing to uninstall.\n`);
return;
}
let removedCount = 0;
// #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user
// artifact orphaned by a PRIOR uninstall run that died between staging and
// its own restore/discard, BEFORE this run's own preserve steps (sites 2,
// 3, 5 below) stage anything new. Uninstall's own msd-core/ removal and
// legacy-commands cleanup are exactly as crash-exposed as install's —
// without this, an orphan from a crashed uninstall is recoverable only if
// the user later re-installs.
// #2875 defect fix: DEGRADE, never abort uninstall, when the staging root
// itself cannot be resolved — skip this recovery pass rather than throw
// out of uninstall() before it does anything.
{
const _uninstallEntryStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
if (_uninstallEntryStagingRoot !== null) {
recoverOrphanedUserArtifacts(_uninstallEntryStagingRoot, targetDir);
}
}
// Remove profile marker so a clean reinstall defaults to full surface.
try {
fs.unlinkSync(path.join(targetDir, '.msd-profile'));
removedCount++;
} catch {}
// 1. Remove MSD commands/skills (layout-driven)
// #2870: scope id resolved ONCE here and reused below (was two independent
// isGlobal-derived re-derivations). Routed through the Install Scope
// Module (src/install-scope.cts) when the capability registry is
// available; degrades to the plain id on failure (unknown/non-installable
// runtime, broken bundle) so this function's scope-id uses — which never
// depended on registry availability before this migration — keep working
// exactly as they did pre-migration.
const _uninstallScopeId = isGlobal ? 'global' : 'local';
const _resolvedUninstallScope = _resolveScopeSafe(_uninstallScopeId, runtime);
const scope = _resolvedUninstallScope ? _resolvedUninstallScope.id : _uninstallScopeId;
// ADR-1239 / #2086: drive uninstall through the public Host-Integration Interface.
// Fail-open to the engine directly if the composed-registry adapter can't load.
const _uninstallAdapter = _runtimeAdapter(runtime);
if (_uninstallAdapter) {
_uninstallAdapter.uninstall({ configDir: targetDir, scope });
} else {
uninstallRuntimeArtifacts(runtime, targetDir, scope);
}
removedCount++;
// ADR-1239 split-home migration: the adapter/plan uninstall targets the new
// `home` location (e.g. Codex → ~/.agents/skills). A user who installed
// BEFORE the move and never reinstalled still has managed msd-* skill dirs at
// the old configDir-rooted location (~/.codex/skills) — remove those too so
// uninstall leaves nothing behind. User-owned content is preserved.
{
const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope);
if (_movedOldSkillsDir) {
const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'msd-');
if (migrated > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${migrated} legacy skill dir(s) from ${_movedOldSkillsDir}`);
}
}
}
// 1a. Non-layout Codex side-effects: agent .toml files, config.toml sections, hooks.json
if (_hostBehaviors(runtime).tomlConfigInstall) {
const codexAgentsDir = path.join(targetDir, 'agents');
if (fs.existsSync(codexAgentsDir)) {
const tomlFiles = fs.readdirSync(codexAgentsDir);
let tomlCount = 0;
for (const file of tomlFiles) {
if (file.startsWith('msd-') && file.endsWith('.toml')) {
fs.unlinkSync(path.join(codexAgentsDir, file));
tomlCount++;
}
}
if (tomlCount > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${tomlCount} agent .toml configs`);
}
}
// Codex: clean MSD sections from config.toml
const codexConfigPath = path.join(targetDir, 'config.toml');
if (fs.existsSync(codexConfigPath)) {
const content = fs.readFileSync(codexConfigPath, 'utf8');
const cleaned = stripMsdFromCodexConfig(content);
if (cleaned === null) {
fs.unlinkSync(codexConfigPath);
removedCount++;
console.log(` ${green}✓${reset} Removed config.toml (was MSD-only)`);
} else if (cleaned !== content) {
fs.writeFileSync(codexConfigPath, cleaned);
removedCount++;
console.log(` ${green}✓${reset} Cleaned MSD sections from config.toml`);
}
}
const hooksJsonCleanup = removeCodexHooksJsonSessionStart(targetDir);
if (hooksJsonCleanup.changed) {
removedCount++;
console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`);
}
// #772/#2088: remove every managed Codex extended hook-event registration.
// Shares CODEX_EXTENDED_HOOK_EVENTS with the install loop — removal set ==
// registration set, so no managed event is ever orphaned.
for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) {
const eventCleanup = removeCodexHooksJsonEvent(targetDir, eventName);
if (eventCleanup.changed) {
removedCount++;
console.log(` ${green}✓${reset} Removed managed Codex ${eventName} hook from hooks.json`);
}
}
// #2586: uninstall's own symmetric half of the orphaned-script cleanup —
// same MSD-owned + unreferenced gate as the install-time call.
const uninstallMonitorCleanup = hooksSurface.cleanupOrphanedCodexContextMonitorScript(targetDir);
for (const deletedPath of uninstallMonitorCleanup.deleted) {
removedCount++;
console.log(` ${green}✓${reset} Removed orphaned Codex hook script (${path.basename(deletedPath)})`);
}
for (const warning of uninstallMonitorCleanup.warnings) {
console.warn(` ${yellow}⚠${reset} Could not remove orphaned Codex hook script ${warning.path}: ${warning.reason}`);
}
}
// 1b-cursor. Descriptor-driven hook-bus cleanup (ADR-1239 / #2089): remove
// MSD-managed hook entries from hooks.json and clean up the managed hook
// scripts. Gated by the hostBehaviors.hooksJsonSurface descriptor axis, not a
// hardcoded `isCursor` branch.
if (_hostBehaviors(runtime).hooksJsonSurface) {
const hooksJsonCleanup = removeCursorHooksJson(targetDir);
if (hooksJsonCleanup.changed) {
removedCount++;
console.log(` ${green}✓${reset} Removed MSD-managed Cursor hooks from hooks.json`);
}
// Remove all MSD-managed hook scripts (sessionStart, postToolUse, preToolUse,
// stop, subagentStart, subagentStop — AC4a, #2089).
const hooksDir = path.join(targetDir, 'hooks');
for (const script of MSD_CURSOR_HOOK_SCRIPTS) {
const p = path.join(hooksDir, script);
try {
if (fs.existsSync(p)) {
fs.unlinkSync(p);
removedCount++;
}
} catch { /* best-effort */ }
}
// Prune hooks/ if empty.
try {
if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) {
fs.rmdirSync(hooksDir);
}
} catch { /* best-effort */ }
}
// 1c. Claude local: remove flat msd-*.md commands from commands/ (current layout,
// #1367 fix). Also remove legacy commands/msd/ subdirectory from prior installs.
if (!isGlobal && _hostBehaviors(runtime).localInstallStyle === 'legacy-flat') {
const commandsDir = path.join(targetDir, 'commands');
// Remove flat msd-*.md files (current layout after #1367 fix)
if (fs.existsSync(commandsDir)) {
let removed = 0;
for (const f of fs.readdirSync(commandsDir)) {
if (f.startsWith('msd-') && f.endsWith('.md')) {
fs.rmSync(path.join(commandsDir, f), { force: true });
removed++;
}
}
if (removed > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${removed} flat msd-*.md commands from commands/`);
}
}
// Remove legacy commands/msd/ subdirectory if it still exists (pre-#1367 layout).
// Preserve user-owned dev-preferences.md if present (#1423 parity).
const legacyMsdCommandsDir = path.join(targetDir, 'commands', 'msd');
if (fs.existsSync(legacyMsdCommandsDir)) {
// Stage user-owned dev-preferences.md DURABLY before wiping (#2875 /
// #1874-F19 "site 7" — found by sweeping bin/install.js for the
// read-then-wipe-then-write PATTERN, not for preserveUserArtifacts'
// callers; this uninstall-path block open-coded the same round-trip).
// #2875 defect fix: DEGRADE, never abort uninstall, when the staging
// root cannot be resolved — skip this legacy-cleanup block entirely
// (leave the stale dir in place) rather than wipe without a durable
// backup for dev-preferences.md.
const _legacyMsdCommandsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
if (_legacyMsdCommandsStagingRoot !== null) {
const stagedDevPrefs = stageUserArtifacts(legacyMsdCommandsDir, ['dev-preferences.md'], _legacyMsdCommandsStagingRoot);
// Preserve the ORIGINAL truthy-content check exactly: an existing but
// EMPTY dev-preferences.md was (and still is) silently not restored.
const savedDevPrefs = stagedDevPrefs.names.includes('dev-preferences.md')
? fs.readFileSync(path.join(stagedDevPrefs.filesDir, 'dev-preferences.md'), 'utf8')
: null;
fs.rmSync(legacyMsdCommandsDir, { recursive: true });
removedCount++;
console.log(` ${green}✓${reset} Removed legacy commands/msd/`);
if (savedDevPrefs) {
try {
restoreStagedUserArtifacts(legacyMsdCommandsDir, stagedDevPrefs);
discardStagedUserArtifacts(stagedDevPrefs);
console.log(` ${green}✓${reset} Preserved commands/msd/dev-preferences.md`);
} catch (err) {
console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
}
} else {
// #2875 defect fix: an existing-but-EMPTY dev-preferences.md was (and
// still is) never restored — the original truthy-content check is
// preserved byte-for-byte above — but the staged batch was never
// discarded either, leaking a <configDir>/.msd-staging/ record
// forever and re-materializing the just-deleted file on a future
// install's orphan-recovery pass. Discard unconditionally when there
// is nothing to restore.
discardStagedUserArtifacts(stagedDevPrefs);
}
}
}
}
// 2. Remove msd-core directory
const msdDir = path.join(targetDir, 'msd-core');
if (fs.existsSync(msdDir)) {
// Stage user-generated files DURABLY to disk before wipe (#1423; #2875 /
// #1874-F19 "site 5" — this block open-coded its own preserve/restore
// instead of calling preserveUserArtifacts, which is why it was missed
// by the original symbol-search measurement).
// #2875 defect fix: this IS the core uninstall step (removing msd-core/)
// — unlike the optional legacy-cleanup blocks above, uninstall must
// still be able to proceed and actually remove msd-core/ even when the
// staging root cannot be resolved. Degrade by skipping ONLY the
// USER-PROFILE.md preserve/restore wrapper (warn), never the removal
// itself.
const _msdDirStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
if (_msdDirStagingRoot === null) {
console.warn(` ${yellow}!${reset} Skipping msd-core/USER-PROFILE.md preservation (staging unavailable) — it will be lost if present.`);
fs.rmSync(msdDir, { recursive: true });
removedCount++;
console.log(` ${green}✓${reset} Removed msd-core/`);
} else {
const stagedProfile = stageUserArtifacts(msdDir, USER_OWNED_ARTIFACTS, _msdDirStagingRoot);
// Preserve the ORIGINAL truthy-content check exactly: an existing but
// EMPTY USER-PROFILE.md was (and still is) silently not restored —
// matching prior behavior byte-for-byte rather than widening scope.
const preservedProfile = stagedProfile.names.includes('USER-PROFILE.md')
? fs.readFileSync(path.join(stagedProfile.filesDir, 'USER-PROFILE.md'), 'utf8')
: null;
fs.rmSync(msdDir, { recursive: true });
removedCount++;
console.log(` ${green}✓${reset} Removed msd-core/`);
// Restore user-generated files
if (preservedProfile) {
try {
restoreStagedUserArtifacts(msdDir, stagedProfile);
discardStagedUserArtifacts(stagedProfile);
console.log(` ${green}✓${reset} Preserved msd-core/USER-PROFILE.md`);
} catch (err) {
console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`);
}
} else {
// #2875 defect fix: same empty-file orphan leak as the legacy
// commands/msd/ site above — discard the staging batch regardless of
// whether the staged content was truthy.
discardStagedUserArtifacts(stagedProfile);
}
}
}
// 3. Remove MSD agents (msd-*.md files only)
const agentsDir = path.join(targetDir, 'agents');
if (fs.existsSync(agentsDir)) {
const files = fs.readdirSync(agentsDir);
let agentCount = 0;
for (const file of files) {
if (file.startsWith('msd-') && file.endsWith('.md')) {
fs.unlinkSync(path.join(agentsDir, file));
agentCount++;
}
}
if (agentCount > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${agentCount} MSD agents`);
}
}
// 4. Remove MSD hooks
// #3023: mirror the install site's descriptor-driven bundle dir name.
const hooksDir = path.join(targetDir, SHARED_HOOKS_DIR);
if (fs.existsSync(hooksDir)) {
let hookCount = 0;
for (const hook of MSD_UNINSTALL_HOOKS) {
const hookPath = path.join(hooksDir, hook);
if (fs.existsSync(hookPath)) {
fs.unlinkSync(hookPath);
hookCount++;
}
}
if (hookCount > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${hookCount} MSD hooks`);
}
// Remove only the MSD-managed files from hooks/lib/ (git-cmd.js + msd-graphify-rebuild.sh).
// hooks/lib/ lives inside the user's runtime hooks directory (shared space) and
// may contain user-owned custom helpers. We must not recursively delete the dir.
const hooksLibDir = path.join(hooksDir, 'lib');
if (fs.existsSync(hooksLibDir)) {
let removedLibFiles = 0;
for (const file of MSD_HOOK_LIB_FILES) {
const filePath = path.join(hooksLibDir, file);
try {
fs.unlinkSync(filePath);
removedLibFiles++;
} catch (_) {
// Ignore missing files (best effort, non-fatal)
}
}
// Only remove the directory itself if it is now empty (preserve any user files)
try {
fs.rmdirSync(hooksLibDir);
} catch (_) {
// Directory not empty or other error — leave it alone
}
if (removedLibFiles > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${removedLibFiles} hooks/lib/ helper(s)`);
}
}
// Retire the CommonJS marker staged into hooks/. hooks/ is shared space and
// is deliberately never rmdir'd here, so the marker must be removed
// explicitly or it would be left behind. Removed ONLY when it still carries
// MSD's exact content — a user-authored package.json is never deleted.
//
// #2717 reaches the runtimes that stage .js hooks via dedicated paths
// (cursor/codex); #2544 reaches the shared-bundle runtimes, whose
// marker this PR moves out of the config root and into hooks/. Both land in
// the same directory, so one guarded call covers both.
try {
if (hooksSurface.removeCommonJsMarkerIfMsdOwned(hooksDir)) {
removedCount++;
console.log(` ${green}✓${reset} Removed MSD hooks/package.json (CommonJS marker)`);
}
} catch { /* best-effort */ }
}
// 4z. Remove the native plugin adapter (#1914).
// Descriptor-driven via hostBehaviors.nativePlugin — covers every runtime
// that declares the block (OpenCode, ...), not just OpenCode. Only
// MSD's own plugin file is removed; the plugins/ dir is pruned only if it
// becomes empty, preserving any user-authored plugins for that host.
const _np = _hostBehaviors(runtime).nativePlugin;
if (_np) {
const pluginsDir = path.join(targetDir, _np.dir);
const pluginPath = path.join(pluginsDir, _np.file);
// Tracks whether MSD actually removed anything from pluginsDir. The rmdir
// below is gated on it: pruning a directory MSD never wrote to is the same
// "don't touch territory MSD didn't fill" violation this issue is about,
// just inverted — a user-created but empty plugin/ or extensions/ dir is
// theirs, and an uninstall that never removed anything has no business
// deleting it.
let removedFromPluginsDir = false;
if (fs.existsSync(pluginPath)) {
try {
fs.unlinkSync(pluginPath);
removedCount++;
removedFromPluginsDir = true;
console.log(` ${green}✓${reset} Removed native plugin adapter (${runtime})`);
} catch (_) { /* best-effort */ }
}
// #2544: the adapter's CommonJS marker sits beside it. Cleaned up OUTSIDE
// the adapter-exists guard above — a partial install (or a hand-deleted
// adapter) would otherwise strand MSD's marker forever and keep the dir
// from ever pruning. Conditioned on the adapter being GONE, though: if the
// unlink above failed, pulling the marker out from under a still-present
// CommonJS adapter would leave it unloadable. The exact content match
// still leaves any user-authored package.json in place.
if (!fs.existsSync(pluginPath) && removeCommonJsMarker(pluginsDir)) {
removedCount++;
removedFromPluginsDir = true;
console.log(` ${green}✓${reset} Removed MSD package.json from ${_np.dir}/`);
}
// Only prune a dir MSD emptied. Pre-fix this rmdir sat inside the
// adapter-exists guard, so it could never fire on a dir MSD had not
// written to; hoisting it out to catch the marker-only case must not
// silently widen it to "any empty plugin dir".
if (removedFromPluginsDir) {
try { fs.rmdirSync(pluginsDir); } catch (_) { /* not empty — user plugins present */ }
}
}
// 4a. Remove scripts/changeset/ and scripts/lib/ (#935)
// MSD-managed files only: enumerate the exact set the installer writes.
// Any file NOT in this set is user-owned and must survive uninstall.
// After removing MSD files, attempt to rmdir — if the directory is still
// non-empty (user has custom helpers) it stays; otherwise it goes cleanly.
// MSD_CHANGESET_FILES / MSD_SCRIPTS_LIB_FILES are module-scoped (#3184) so
// tests can assert their parity against the real directory contents.
const changesetUninstallDir = path.join(targetDir, 'scripts', 'changeset');
if (fs.existsSync(changesetUninstallDir)) {
let removedChangeset = 0;
for (const file of MSD_CHANGESET_FILES) {
const fp = path.join(changesetUninstallDir, file);
try { fs.unlinkSync(fp); removedChangeset++; } catch (_) { /* best-effort */ }
}
// Remove directory if empty after our cleanup
try { fs.rmdirSync(changesetUninstallDir); } catch (_) { /* Not empty — user content present */ }
if (removedChangeset > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed scripts/changeset/ MSD files`);
}
}
const scriptsLibUninstallDir = path.join(targetDir, 'scripts', 'lib');
if (fs.existsSync(scriptsLibUninstallDir)) {
let removedScriptsLib = 0;
for (const file of MSD_SCRIPTS_LIB_FILES) {
const fp = path.join(scriptsLibUninstallDir, file);
try { fs.unlinkSync(fp); removedScriptsLib++; } catch (_) { /* best-effort */ }
}
// Remove directory if empty after our cleanup
try { fs.rmdirSync(scriptsLibUninstallDir); } catch (_) { /* Not empty — user content present */ }
if (removedScriptsLib > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed scripts/lib/ MSD files`);
}
}
// Remove scripts/fix-slash-commands.cjs (#1223) — must come before the scripts/ rmdir
const fixSlashUninstallPath = path.join(targetDir, 'scripts', 'fix-slash-commands.cjs');
try { fs.unlinkSync(fixSlashUninstallPath); } catch (_) { /* best-effort */ }
// Remove the capability registry generator scripts (#1920) — before the scripts/ rmdir
for (const gen of ['gen-capability-registry.cjs', 'gen-loop-host-contract.cjs']) {
try { fs.unlinkSync(path.join(targetDir, 'scripts', gen)); } catch (_) { /* best-effort */ }
}
// If scripts/ dir is now empty, remove it too
const scriptsUninstallDir = path.join(targetDir, 'scripts');
if (fs.existsSync(scriptsUninstallDir)) {
try { fs.rmdirSync(scriptsUninstallDir); } catch (_) { /* Not empty — leave it */ }
}
// 5. Remove MSD package.json (CommonJS mode marker)
// Since #2544 the marker is staged into hooks/ (and the nativePlugin dir,
// handled at 4z above) rather than at targetDir. The targetDir removal is
// retained to retire the marker written by pre-#2544 installs — same exact
// content match as before, so a user-authored package.json is still never
// touched.
if (removeCommonJsMarker(targetDir)) {
removedCount++;
console.log(` ${green}✓${reset} Removed MSD package.json (pre-#2544 config-root marker)`);
}
// 6. Clean up settings.json (remove MSD hooks and statusline)
const settingsPath = path.join(targetDir, 'settings.json');
if (fs.existsSync(settingsPath)) {
let settings = readSettings(settingsPath);
if (settings === null) {
console.log(` ${yellow}i${reset} Skipping settings.json cleanup — file could not be parsed`);
settings = {}; // prevent downstream crashes, but don't write back
}
let settingsModified = false;
// Remove MSD statusline if it references our hook
if (settings.statusLine && settings.statusLine.command &&
settings.statusLine.command.includes('msd-statusline')) {
delete settings.statusLine;
settingsModified = true;
console.log(` ${green}✓${reset} Removed MSD statusline from settings`);
}
// Remove MSD hooks from settings — per-hook granularity to preserve
// user hooks that share an entry with a MSD hook (#1755 followup).
// Includes the 3 events added in #788 (SubagentStop, Stop,
// PreCompact, registered for Claude in #770), the 3 Antigravity-only
// events added in #776 (BeforeAgent, AfterAgent, BeforeModel), and the
// Claude-only FileChanged event added in #770 — safe to iterate for all
// runtimes; installs that don't register these events simply find no
// entries and skip.
for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'FileChanged']) {
if (settings.hooks && settings.hooks[eventName]) {
const before = JSON.stringify(settings.hooks[eventName]);
settings.hooks[eventName] = settings.hooks[eventName]
.map(entry => {
if (!entry || typeof entry !== 'object' || !Array.isArray(entry.hooks)) return entry;
// Filter out individual MSD hooks, keep user hooks
entry.hooks = entry.hooks.filter((h) => {
if (!h || typeof h.command !== 'string') return true;
return !isManagedHookCommand(h.command, {
surface: 'settings-json',
});
});
return entry.hooks.length > 0 ? entry : null;
})
.filter(Boolean);
if (JSON.stringify(settings.hooks[eventName]) !== before) {
settingsModified = true;
}
if (settings.hooks[eventName].length === 0) {
delete settings.hooks[eventName];
}
}
}
if (settingsModified) {
console.log(` ${green}✓${reset} Removed MSD hooks from settings`);
}
// Clean up empty hooks object
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
delete settings.hooks;
}
// #768 — Remove MSD-owned Claude permissions from settings.json.
// Applies only to Claude uninstalls. Filter only the exact MSD-owned entries
// to preserve any user-added allow/deny entries.
// Uses a local flag to avoid the shared `settingsModified` producing a false
// "Removed MSD permissions" message when only hooks/statusline changed.
if (_hostBehaviors(runtime).permissionsSchema === 'claude' && settings.permissions) {
let permissionsModified = false;
if (Array.isArray(settings.permissions.allow)) {
const before = settings.permissions.allow.length;
// #2278 — filter against the union of the current allow-rule forms
// AND the retired legacy forms, so uninstall still cleans up
// pre-fix installs that still carry the stale `Write(...)` entries.
settings.permissions.allow = settings.permissions.allow.filter(
(e) => !MSD_CLAUDE_ALLOW_PERMISSIONS.includes(e) && !MSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS.includes(e)
);
if (settings.permissions.allow.length !== before) {
permissionsModified = true;
// #4221: an array this filter emptied was MSD-only \u2014 remove the
// key rather than leave an empty array behind (Antigravity symmetry).
if (settings.permissions.allow.length === 0) {
delete settings.permissions.allow;
}
}
}
if (Array.isArray(settings.permissions.deny)) {
const before = settings.permissions.deny.length;
// #4221: the deny rules are retired, so this is a legacy-only filter.
settings.permissions.deny = settings.permissions.deny.filter(
(e) => !MSD_CLAUDE_LEGACY_DENY_PERMISSIONS.includes(e)
);
if (settings.permissions.deny.length !== before) {
permissionsModified = true;
if (settings.permissions.deny.length === 0) {
delete settings.permissions.deny;
}
}
}
if (permissionsModified && Object.keys(settings.permissions).length === 0) {
delete settings.permissions;
}
if (permissionsModified) {
settingsModified = true;
console.log(` ${green}✓${reset} Removed MSD permissions from settings.json`);
}
}
// #2096 Phase B Upgrade 1 — Remove MSD-owned Antigravity permissions.allow
// rules from settings.json. Symmetric to the Claude branch above: filters
// only the exact MSD-owned rule strings (regenerated from the current
// configDir) to preserve any user-added allow entries and all deny/ask.
if (resolveInstallPlan(runtime).finishPermissionWriter === 'antigravity' && settings.permissions) {
let antigravityPermissionsModified = false;
if (Array.isArray(settings.permissions.allow)) {
const msdRules = new Set(buildAntigravityAllowRules(targetDir));
const before = settings.permissions.allow.length;
settings.permissions.allow = settings.permissions.allow.filter((e) => !msdRules.has(e));
if (settings.permissions.allow.length !== before) {
antigravityPermissionsModified = true;
}
if (settings.permissions.allow.length === 0) {
delete settings.permissions.allow;
}
}
if (Object.keys(settings.permissions).length === 0) {
delete settings.permissions;
}
if (antigravityPermissionsModified) {
settingsModified = true;
console.log(` ${green}✓${reset} Removed MSD permissions from settings.json`);
}
}
if (settingsModified) {
writeSettings(settingsPath, settings);
removedCount++;
}
}
// 6. For OpenCode, clean up permissions from opencode.json or opencode.jsonc
if (resolveInstallPlan(runtime).finishPermissionWriter === 'opencode') {
const configPath = resolveOpencodeConfigPath(targetDir);
if (fs.existsSync(configPath)) {
try {
const config = parseJsonc(fs.readFileSync(configPath, 'utf8'));
let modified = false;
// Remove MSD permission entries
if (config.permission) {
for (const permType of ['read', 'external_directory']) {
if (config.permission[permType]) {
const keys = Object.keys(config.permission[permType]);
for (const key of keys) {
if (key.includes('msd-core')) {
delete config.permission[permType][key];
modified = true;
}
}
// Clean up empty objects
if (Object.keys(config.permission[permType]).length === 0) {
delete config.permission[permType];
}
}
}
if (Object.keys(config.permission).length === 0) {
delete config.permission;
}
}
if (modified) {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
removedCount++;
console.log(` ${green}✓${reset} Removed MSD permissions from ${path.basename(configPath)}`);
}
} catch (e) {
// Ignore JSON parse errors
}
}
}
// 8. For Antigravity, remove the MCP companion entry from mcp_config.json
// (#2096 Phase B Upgrade 2). Only the MSD-owned mcpServers.msd key is
// removed — any other user-configured MCP servers are preserved.
if (resolveInstallPlan(runtime).finishPermissionWriter === 'antigravity') {
const mcpConfigPath = path.join(targetDir, 'mcp_config.json');
if (fs.existsSync(mcpConfigPath)) {
try {
const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8'));
if (mcpConfig && typeof mcpConfig === 'object' && mcpConfig.mcpServers && mcpConfig.mcpServers.msd !== undefined) {
delete mcpConfig.mcpServers.msd;
if (Object.keys(mcpConfig.mcpServers).length === 0) {
delete mcpConfig.mcpServers;
}
fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
removedCount++;
console.log(` ${green}✓${reset} Removed MSD MCP companion server from mcp_config.json`);
}
} catch (e) {
// Ignore JSON parse errors
}
}
}
// Remove the file manifest that the installer wrote at install time.
// Without this step the metadata file persists after uninstall (#1908).
const manifestPath = path.join(targetDir, MANIFEST_NAME);
if (fs.existsSync(manifestPath)) {
fs.rmSync(manifestPath, { force: true });
removedCount++;
console.log(` ${green}✓${reset} Removed ${MANIFEST_NAME}`);
}
if (removedCount === 0) {
console.log(` ${yellow}⚠${reset} No MSD files found to remove.`);
}
console.log(`
${green}Done!${reset} MSD has been uninstalled from ${runtimeLabel}.
Your other files and settings have been preserved.
`);
}
/**
* Parse JSONC (JSON with Comments) by stripping comments and trailing commas.
* OpenCode supports JSONC format via jsonc-parser, so users may have comments.
* This is a lightweight inline parser to avoid adding dependencies.
*/
function parseJsonc(content) {
// Strip BOM if present
if (content.charCodeAt(0) === 0xFEFF) {
content = content.slice(1);
}
// Remove single-line and block comments while preserving strings
let result = '';
let inString = false;
let i = 0;
while (i < content.length) {
const char = content[i];
const next = content[i + 1];
if (inString) {
result += char;
// Handle escape sequences
if (char === '\\' && i + 1 < content.length) {
result += next;
i += 2;
continue;
}
if (char === '"') {
inString = false;
}
i++;
} else {
if (char === '"') {
inString = true;
result += char;
i++;
} else if (char === '/' && next === '/') {
// Skip single-line comment until end of line
while (i < content.length && content[i] !== '\n') {
i++;
}
} else if (char === '/' && next === '*') {
// Skip block comment
i += 2;
while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) {
i++;
}
i += 2; // Skip closing */
} else {
result += char;
i++;
}
}
}
// Remove trailing commas before } or ]
result = result.replace(/,(\s*[}\]])/g, '$1');
return JSON.parse(result);
}
/**
* Configure OpenCode permissions to allow reading MSD reference docs
* This prevents permission prompts when MSD accesses the msd-core directory
* @param {boolean} isGlobal - Whether this is a global or local install
* @param {string|null} configDir - Resolved config directory when already known
*/
function configureOpencodePermissions(isGlobal = true, configDir = null) {
// For local installs, use ./.opencode/
// For global installs, use ~/.config/opencode/
const opencodeConfigDir = configDir || (isGlobal
? getGlobalConfigDir('opencode', explicitConfigDir)
: path.join(process.cwd(), '.opencode'));
// Ensure config directory exists
fs.mkdirSync(opencodeConfigDir, { recursive: true });
const configPath = resolveOpencodeConfigPath(opencodeConfigDir);
// Read existing config or create empty object
let config = {};
if (fs.existsSync(configPath)) {
try {
const content = fs.readFileSync(configPath, 'utf8');
config = parseJsonc(content);
} catch (e) {
// Cannot parse - DO NOT overwrite user's config
const configFile = path.basename(configPath);
console.log(` ${yellow}⚠${reset} Could not parse ${configFile} - skipping permission config`);
console.log(` ${dim}Reason: ${e.message}${reset}`);
console.log(` ${dim}Your config was NOT modified. Fix the syntax manually if needed.${reset}`);
return;
}
}
// OpenCode also allows a top-level string permission like "allow".
// In that case, path-specific permission entries are unnecessary.
if (typeof config.permission === 'string') {
return;
}
// Ensure permission structure exists
if (!config.permission || typeof config.permission !== 'object') {
config.permission = {};
}
// Build the MSD path using the actual config directory
// Use ~ shorthand if it's in the default location, otherwise use full path
const defaultConfigDir = path.join(os.homedir(), '.config', 'opencode');
const msdPath = opencodeConfigDir === defaultConfigDir
? '~/.config/opencode/msd-core/*'
: `${opencodeConfigDir.replace(/\\/g, '/')}/msd-core/*`;
let modified = false;
// Configure read permission
if (!config.permission.read || typeof config.permission.read !== 'object') {
config.permission.read = {};
}
if (config.permission.read[msdPath] !== 'allow') {
config.permission.read[msdPath] = 'allow';
modified = true;
}
// Configure external_directory permission (the safety guard for paths outside)
if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
config.permission.external_directory = {};
}
if (config.permission.external_directory[msdPath] !== 'allow') {
config.permission.external_directory[msdPath] = 'allow';
modified = true;
}
// ADR-1239 Phase D / #1682 — register the companion MCP server (Phase 4) so
// OpenCode connects to MSD's command (point 1) + state-IO (point 5) surface
// with NO bespoke plugin. Idempotent + non-clobbering: only added when
// `mcp.msd` is absent (a user-defined `mcp.msd` is respected — Hyrum's Law).
// Local-stdio schema per OpenCode config (packages/core/src/config/mcp.ts).
// `-p @golem15/msd-core` resolves the `msd-mcp-server` bin from this package
// (bin name != package name) regardless of global-install state.
if (!config.mcp || typeof config.mcp !== 'object') {
config.mcp = {};
}
if (config.mcp.msd === undefined) {
config.mcp.msd = {
type: 'local',
command: ['npx', '-y', '-p', PACKAGE_NAME, 'msd-mcp-server'],
enabled: true,
};
modified = true;
}
if (!modified) {
return; // Already configured
}
// Write config back
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
console.log(` ${green}✓${reset} Configured read permission for MSD docs`);
}
/**
* Convert an absolute path to a `~`-relative form when it lives under the
* user's home directory (generalizes configureOpencodePermissions'
* single-default-dir shorthand to Antigravity's three probed sibling config
* dirs — antigravity/antigravity-ide/antigravity-cli under ~/.gemini — none of
* which is a single fixed "default").
*/
function toTildePosixPath(absPath) {
const posixPath = absPath.replace(/\\/g, '/');
const posixHome = os.homedir().replace(/\\/g, '/');
return posixPath === posixHome || posixPath.startsWith(`${posixHome}/`)
? `~${posixPath.slice(posixHome.length)}`
: posixPath;
}
/**
* Antigravity permission rule strings this installer contributes.
* Schema: antigravity.google/docs/cli/permissions — "action(target)" rule
* strings in permissions.{allow,deny,ask}, evaluated deny > ask > allow. MSD
* only ever contributes to `allow` — never deny/ask (those are user-owned risk
* decisions this installer has no business making).
*/
function buildAntigravityAllowRules(configDir) {
const msdPath = toTildePosixPath(configDir);
return [
`read_file(${msdPath}/msd-core/*)`,
`read_file(${msdPath}/agents/msd-*)`,
`read_file(${msdPath}/skills/msd-*)`,
`command(node ${msdPath}/hooks/*)`,
];
}
/**
* Configure Antigravity permissions to allow reading/executing MSD's installed
* tree without per-call approval prompts (#2096 Phase B Upgrade 1 — mirrors
* configureOpencodePermissions).
*
* Antigravity's permission schema (antigravity.google/docs/cli/permissions) is
* `{"permissions":{"allow":[...],"deny":[...],"ask":[...]}}`, living in the
* SAME settings.json MSD's own hook registration writes for this runtime
* (installSurface: 'settings-json', writesSharedSettings: true) — unlike
* OpenCode, which writes a separate native config file. This function
* re-reads the file (already containing MSD's hooks by the time finishInstall
* reaches this call) and only appends to permissions.allow.
*
* Non-destructive + idempotent: only `permissions.allow` is touched; an
* existing user permissions block (including any deny/ask entries, or
* unrelated allow entries) is preserved untouched.
*
* @param {boolean} isGlobal - Whether this is a global or local install
* @param {string|null} configDir - Resolved config directory when already known
*/
function configureAntigravityPermissions(isGlobal = true, configDir = null) {
// For local installs, use ./.agents/ (MSD's antigravity localConfigDir)
// For global installs, use the resolved ~/.gemini/antigravity{,-ide,-cli}
const antigravityConfigDir = configDir || (isGlobal
? getGlobalConfigDir('antigravity', explicitConfigDir)
: path.join(process.cwd(), '.agents'));
// Ensure config directory exists
fs.mkdirSync(antigravityConfigDir, { recursive: true });
const configPath = path.join(antigravityConfigDir, 'settings.json');
// Read existing settings.json (readSettings tolerates JSONC + missing file;
// returns null — and warns — only when the file exists but fails to parse).
const config = readSettings(configPath);
if (config === null) {
// Cannot parse — DO NOT overwrite user's config (readSettings already warned).
return;
}
// Ensure permission structure exists
if (!config.permissions || typeof config.permissions !== 'object' || Array.isArray(config.permissions)) {
config.permissions = {};
}
if (!Array.isArray(config.permissions.allow)) {
config.permissions.allow = [];
}
let modified = false;
for (const rule of buildAntigravityAllowRules(antigravityConfigDir)) {
if (!config.permissions.allow.includes(rule)) {
config.permissions.allow.push(rule);
modified = true;
}
}
if (!modified) {
return; // Already configured
}
writeSettings(configPath, config);
console.log(` ${green}✓${reset} Configured Antigravity permissions for MSD paths`);
}
/**
* Configure Antigravity's MCP companion server config (#2096 Phase B
* Upgrade 2).
*
* Antigravity CLI manages MCP servers via standalone `mcp_config.json`
* profiles rather than nesting them in settings.json (antigravity.google/docs/
* cli/gcli-migration: "Antigravity CLI uses standalone mcp_config.json
* profiles in ~/.gemini/config/ for global servers and .agents/mcp_config.json
* for workspace servers"). The raw schema for the Antigravity IDE surface
* itself is unpublished (docs are JS-rendered), so this follows the CLI's
* documented standalone-profile convention plus the standard Gemini/MCP
* `mcpServers` shape.
*
* BEST-EFFORT PATH CHOICE: rather than the CLI doc's separate `~/.gemini/config/`
* directory for global scope, this writes `<configDir>/mcp_config.json` — the
* SAME resolved configDir as settings.json (configureAntigravityPermissions) —
* because (1) MSD's own antigravity configDir resolution already varies
* per-user across three sibling dirs (antigravity/antigravity-ide/
* antigravity-cli — see resolveAntigravityGlobalDir), so a hardcoded separate
* shared path would not track that resolution, and (2) it matches the doc's
* OWN workspace-scope convention exactly (`.agents/mcp_config.json`, which IS
* MSD's local configDir for antigravity), keeping global/local symmetric and
* consistent with the configDir-relative convention every other MSD
* permission writer (opencode) already uses.
*
* Non-destructive + idempotent: only adds mcpServers.msd when entirely absent;
* any other user-configured mcpServers entries (or a user's OWN "msd" override)
* are preserved untouched (Hyrum's Law — mirrors OpenCode's config.mcp.msd guard).
*
* @param {boolean} isGlobal - Whether this is a global or local install
* @param {string|null} configDir - Resolved config directory when already known
*/
function configureAntigravityMcpConfig(isGlobal = true, configDir = null) {
const antigravityConfigDir = configDir || (isGlobal
? getGlobalConfigDir('antigravity', explicitConfigDir)
: path.join(process.cwd(), '.agents'));
fs.mkdirSync(antigravityConfigDir, { recursive: true });
const configPath = path.join(antigravityConfigDir, 'mcp_config.json');
let config = {};
if (fs.existsSync(configPath)) {
try {
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
} catch (e) {
// Cannot parse - DO NOT overwrite user's config
console.log(` ${yellow}⚠${reset} Could not parse mcp_config.json - skipping MCP companion config`);
console.log(` ${dim}Reason: ${e.message}${reset}`);
console.log(` ${dim}Your config was NOT modified. Fix the syntax manually if needed.${reset}`);
return;
}
}
if (!config.mcpServers || typeof config.mcpServers !== 'object' || Array.isArray(config.mcpServers)) {
config.mcpServers = {};
}
if (config.mcpServers.msd !== undefined) {
return; // Already configured (or a user-owned override) — never clobber.
}
config.mcpServers.msd = {
command: 'npx',
args: ['-y', '-p', PACKAGE_NAME, 'msd-mcp-server'],
};
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
console.log(` ${green}✓${reset} Configured Antigravity MCP companion server (msd)`);
}
/**
* Verify a directory exists and contains files
*/
function verifyInstalled(dirPath, description) {
if (!fs.existsSync(dirPath)) {
console.error(` ${yellow}✗${reset} Failed to install ${description}: directory not created`);
return false;
}
try {
const entries = fs.readdirSync(dirPath);
if (entries.length === 0) {
console.error(` ${yellow}✗${reset} Failed to install ${description}: directory is empty`);
return false;
}
} catch (e) {
console.error(` ${yellow}✗${reset} Failed to install ${description}: ${e.message}`);
return false;
}
return true;
}
/**
* Verify a file exists
*/
function verifyFileInstalled(filePath, description) {
if (!fs.existsSync(filePath)) {
console.error(` ${yellow}✗${reset} Failed to install ${description}: file not created`);
return false;
}
return true;
}
/**
* Install to the specified directory for a specific runtime
* @param {boolean} isGlobal - Whether to install globally or locally
* @param {string} runtime - Target runtime ('claude', 'opencode', 'codex')
*/
// ──────────────────────────────────────────────────────
// Local Patch Persistence
// ──────────────────────────────────────────────────────
const PATCHES_DIR_NAME = 'msd-local-patches';
const MANIFEST_NAME = 'msd-file-manifest.json';
/**
* Compute SHA256 hash of file contents
*/
function fileHash(filePath) {
const content = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* Recursively collect all files in dir with their hashes
*/
function generateManifest(dir, baseDir) {
if (!baseDir) baseDir = dir;
const manifest = {};
if (!fs.existsSync(dir)) return manifest;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
if (entry.isDirectory()) {
Object.assign(manifest, generateManifest(fullPath, baseDir));
} else {
manifest[relPath] = fileHash(fullPath);
}
}
return manifest;
}
function normalizeInstallRelativePath(relPath) {
if (typeof relPath !== 'string' || relPath.trim() === '' || relPath.includes('\0')) {
return null;
}
if (path.isAbsolute(relPath) || path.win32.isAbsolute(relPath)) {
return null;
}
const normalized = relPath.replace(/\\/g, '/');
const segments = normalized.split('/');
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
return null;
}
return segments.join('/');
}
function resolveInstallRelativePath(baseDir, relPath) {
const normalized = normalizeInstallRelativePath(relPath);
if (!normalized) return null;
const root = path.resolve(baseDir);
const fullPath = path.resolve(root, normalized);
if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
return null;
}
if (hasExistingSymlinkBetween(root, fullPath, { allowOptInFollow: isSymlinkedDestOptIn() })) {
return null;
}
return { relPath: normalized, fullPath };
}
// hasExistingSymlinkBetween: moved to src/install-engine.cts (ADR-1239 Phase B).
// Imported from installEngine above.
/**
* Write file manifest after installation for future modification detection
*/
function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
const { isOpencode, isCodex, isCursor } = runtimeFlags(runtime);
const msdDir = path.join(configDir, 'msd-core');
// #1367: Claude local now writes flat msd-*.md files at commands/ (not commands/msd/).
// Claude local uses flatCommandsDir instead for manifest recording.
const flatCommandsDir = path.join(configDir, 'commands');
const opencodeCommandDir = path.join(configDir, _hostBehaviors(runtime).flatCommandDir || 'command');
// ADR-1239 upgrade 3 (#2088): honor a skills-kind `home` override (e.g. Codex
// skills -> $HOME/.agents/skills instead of configDir/skills) via the same
// descriptor-driven helper used by the snapshot/rollback/verification paths,
// so the manifest records what's actually on disk. _resolveSkillsRootDir already
// resolves destSubpath — do not re-append anything.
// #2872 (ADR-2866 Phase 3): the scope used to pick the skills root and the
// scope RECORDED in the manifest are one value, resolved once. Two reads of
// `options.scope` could drift; one cannot.
const resolvedScope = options.scope === 'local' ? 'local' : 'global';
const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, resolvedScope);
// #3738: resolve the ACTUAL agents-install dir honoring an agents-kind `home`
// override (antigravity global → $HOME/.gemini/config/agents), mirroring
// _resolveSkillsRootDir for skills. Hardcoding configDir/agents left the
// manifest blind to the whole agents surface the moment the override landed —
// no drift detection, no patch backup. Falls back to <configDir>/agents.
const agentsDir = _kindDestDirSafe(runtime, configDir, resolvedScope, 'agents')
|| path.join(configDir, 'agents');
const manifest = {
// Schema version of this DOCUMENT (#2872) — distinct from `version`
// below, which is the MSD package version. Absent ⇒ a pre-#2872 (v1)
// manifest, which readInstallManifest still reads without error and
// without requiring a reinstall. Read from the Installer Migration
// Module rather than repeated as a second literal: the writer here and
// the reader's normalizeManifestVersion are two surfaces over one
// constant, and this repo's "generative fix divergence" class is exactly
// two such literals drifting apart.
manifestVersion: MANIFEST_SCHEMA_VERSION,
version: pkg.version,
timestamp: new Date().toISOString(),
mode: options.mode === 'minimal' ? 'minimal' : 'full',
// Recorded so an Installed Surface Resolver can answer "which surfaces
// are installed, at which scopes, for which runtimes" without re-deriving
// it from the directory it happened to be found in (#2872).
runtime,
scope: resolvedScope,
// #4377: a surface re-apply is a separate process and cannot rely on the
// installer's environment. Persist only a safe project-relative prefix.
relativeIncludePrefix: resolvedScope === 'local' && hasRelativeIncludes
? runtimeArtifactConversion._projectRelativePrefixFromProjectRoot(process.cwd(), configDir)
: undefined,
files: {},
};
const msdHashes = generateManifest(msdDir);
for (const [rel, hash] of Object.entries(msdHashes)) {
// Skip user-owned artifacts (e.g. USER-PROFILE.md). They are staged
// durably and restored across reinstalls (user-artifact-staging.cts,
// #2875) and must NOT be hashed into the manifest — otherwise
// saveLocalPatches() would flag every refresh as a "local patch"
// (bug #2771). Single source of truth: USER_OWNED_ARTIFACTS at top of file.
if (USER_OWNED_ARTIFACTS.includes(rel)) continue;
manifest.files['msd-core/' + rel] = hash;
}
// Record commands surface for runtimes that emit it:
// Claude local (#1367 fix): flat msd-<cmd>.md at commands/ level
// Manifest must reflect everything on disk so saveLocalPatches() can detect
// user edits and per-runtime minimal-mode assertions can read manifest.files.
// Claude local (#1367): flat msd-*.md files at commands/ level.
// Only claude local writes msd-*.md here; global installs don't emit commands,
// so this branch is a no-op for global (no matching files to find).
if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && fs.existsSync(flatCommandsDir)) {
for (const file of fs.readdirSync(flatCommandsDir)) {
if (file.startsWith('msd-') && file.endsWith('.md')) {
manifest.files['commands/' + file] = fileHash(path.join(flatCommandsDir, file));
}
}
}
if (_hostBehaviors(runtime).flatCommandDir && fs.existsSync(opencodeCommandDir)) {
// #2329: derive the manifest key prefix from the SAME descriptor value used
// to compute opencodeCommandDir above, instead of a separately-hardcoded
// literal — a divergence here would silently break the manifest even after
// the destSubpath descriptor is corrected (Generative Fix Divergence guard).
const flatCommandDirPrefix = _hostBehaviors(runtime).flatCommandDir || 'command';
for (const file of fs.readdirSync(opencodeCommandDir)) {
if (file.startsWith('msd-') && file.endsWith('.md')) {
manifest.files[flatCommandDirPrefix + '/' + file] = fileHash(path.join(opencodeCommandDir, file));
}
}
}
if (fs.existsSync(codexSkillsDir)) {
// All runtimes use the canonical 'msd-' prefix.
const skillListPrefix = 'msd-';
for (const skillName of listCodexSkillNames(codexSkillsDir, skillListPrefix)) {
const skillRoot = path.join(codexSkillsDir, skillName);
const skillHashes = generateManifest(skillRoot);
for (const [rel, hash] of Object.entries(skillHashes)) {
manifest.files[`skills/${skillName}/${rel}`] = hash;
}
}
}
if (fs.existsSync(agentsDir)) {
for (const file of fs.readdirSync(agentsDir)) {
if (file.startsWith('msd-') && (file.endsWith('.md') || file.endsWith('.toml'))) {
manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file));
}
}
}
// Track hook files so saveLocalPatches() can detect user modifications
// Hooks are only installed for runtimes that use settings.json (not Codex).
// Descriptor-driven (ADR-1239 / #2089): exclusions via
// hostBehaviors.skipSharedHooksInstall.
if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true) {
// #3023: manifest keys must track the bundle wherever the descriptor put it,
// or uninstall/saveLocalPatches silently orphan the tree.
const hooksDir = path.join(configDir, SHARED_HOOKS_DIR);
if (fs.existsSync(hooksDir)) {
// Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
// scripts/build-hooks.js) rather than a prefix/extension regex, so the
// manifest set is structurally identical to the build set. The old regex
// `file.startsWith('msd-') && (file.endsWith('.js') || file.endsWith('.sh'))`
// missed managed-hooks-registry.cjs (wrong prefix, .cjs extension), causing
// detect-custom-files to flag it as a perpetual false-positive custom file
// on every /msd-update. See #941.
for (const hook of INSTALLED_HOOK_FILES) {
const hookPath = path.join(hooksDir, hook);
if (fs.existsSync(hookPath)) {
manifest.files[SHARED_HOOKS_DIR + '/' + hook] = fileHash(hookPath);
}
}
// Track hooks/lib/ helpers so saveLocalPatches() can back up user edits
// to git-cmd.js (validate-commit classifier) and msd-graphify-rebuild.sh.
const hooksLibDir = path.join(hooksDir, 'lib');
if (fs.existsSync(hooksLibDir)) {
for (const file of fs.readdirSync(hooksLibDir)) {
if (MSD_HOOK_LIB_FILES.includes(file)) {
manifest.files[SHARED_HOOKS_DIR + '/lib/' + file] = fileHash(path.join(hooksLibDir, file));
}
}
}
}
}
// Track scripts/changeset/ and scripts/lib/ so saveLocalPatches() can detect drift
const changesetInstallDir = path.join(configDir, 'scripts', 'changeset');
if (fs.existsSync(changesetInstallDir)) {
for (const file of fs.readdirSync(changesetInstallDir)) {
if (file.endsWith('.cjs')) {
manifest.files['scripts/changeset/' + file] = fileHash(path.join(changesetInstallDir, file));
}
}
}
const scriptsLibInstallDir = path.join(configDir, 'scripts', 'lib');
if (fs.existsSync(scriptsLibInstallDir)) {
for (const file of fs.readdirSync(scriptsLibInstallDir)) {
if (file.endsWith('.cjs')) {
manifest.files['scripts/lib/' + file] = fileHash(path.join(scriptsLibInstallDir, file));
}
}
}
// Track scripts/fix-slash-commands.cjs (top-level scripts/ file, not covered by changeset/lib loops)
const fixSlashInstallPath = path.join(configDir, 'scripts', 'fix-slash-commands.cjs');
if (fs.existsSync(fixSlashInstallPath)) {
manifest.files['scripts/fix-slash-commands.cjs'] = fileHash(fixSlashInstallPath);
}
// Track the capability registry generator scripts (#1920) — top-level scripts/ files
// not covered by the changeset/lib loops.
for (const gen of ['gen-capability-registry.cjs', 'gen-loop-host-contract.cjs']) {
const genInstallPath = path.join(configDir, 'scripts', gen);
if (fs.existsSync(genInstallPath)) {
manifest.files['scripts/' + gen] = fileHash(genInstallPath);
}
}
// Track the OpenCode native plugin adapter (#1914) so update/drift detection
// and uninstall can account for it.
const _npM = _hostBehaviors(runtime).nativePlugin;
if (_npM) {
const pluginInstallPath = path.join(configDir, _npM.dir, _npM.file);
if (fs.existsSync(pluginInstallPath)) {
manifest.files[`${_npM.dir}/${_npM.file}`] = fileHash(pluginInstallPath);
}
}
fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
return manifest;
}
/**
* Populate msd-pristine/ with the transformed pristine versions of every
* `modified` file, derived from the current package's source tree by
* running the install transform pipeline (`copyWithPathReplacement`)
* into a tmp directory, then copying out only the relevant paths.
*
* Pristine semantically represents "what the install would write to
* configDir/<relPath> if the user had not modified it." This is what the
* /msd-reapply-patches Step 5 verifier (#2972) uses as the diff base
* for "user-added lines" — lines in the user's backup that are NOT in
* the pristine baseline. Without this dir, the verifier degrades to its
* over-broad fallback ("every significant backup line"), exactly the
* silent-success-on-lost-content failure mode #2969 was designed to
* prevent (#2998).
*
* Implementation note: we run the FULL transform pipeline against a tmp
* staging dir (one-time, only when modified.length > 0), then copy out
* just the modified paths. This re-uses the existing transform code
* exactly — pristine is byte-identical to what `copyWithPathReplacement`
* would have written under normal install. Cost: one extra full transform
* pass per install where local patches were detected; acceptable.
*/
function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathPrefix, isGlobal }) {
if (!modified || modified.length === 0) return 0;
// Modified paths come from manifest.files which can live under several
// install roots: msd-core/, commands/msd/, command/, skills/, agents/,
// hooks/, plus runtime-specific root files (#3004 CR). Stage every
// top-level dir that actually contains a modified path; root-level files
// are copied directly without the transform pipeline (they don't need
// path replacement).
const stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-pristine-stage-'));
let written = 0;
try {
const topLevels = new Set();
const safeModified = [];
for (const relPath of modified) {
const norm = normalizeInstallRelativePath(relPath);
if (!norm) continue;
safeModified.push(norm);
const slash = norm.indexOf('/');
topLevels.add(slash === -1 ? '' : norm.slice(0, slash));
}
for (const top of topLevels) {
if (top === '') {
// Root-level files — copy directly from package source. The transform
// pipeline is directory-oriented; root files don't need path-prefix
// substitution (they're not markdown content with embedded paths).
for (const relPath of safeModified) {
const norm = normalizeInstallRelativePath(relPath);
if (!norm) continue;
if (norm.includes('/')) continue;
const srcRef = resolveInstallRelativePath(packageSrc, norm);
const stagedRef = resolveInstallRelativePath(stageRoot, norm);
if (!srcRef || !stagedRef || !fs.existsSync(srcRef.fullPath)) continue;
const stagedFile = stagedRef.fullPath;
fs.mkdirSync(path.dirname(stagedFile), { recursive: true });
fs.copyFileSync(srcRef.fullPath, stagedFile);
}
continue;
}
const srcDir = path.join(packageSrc, top);
const stageDir = path.join(stageRoot, top);
if (!fs.existsSync(srcDir)) continue;
copyWithPathReplacement(srcDir, stageDir, pathPrefix, runtime, false, isGlobal, stageRoot);
}
for (const relPath of safeModified) {
// Only populate pristine for paths we successfully staged. If a path's
// source dir does not exist (obsolete manifest entry), skip silently
// rather than corrupting pristine with stale data.
const stagedRef = resolveInstallRelativePath(stageRoot, relPath);
const outRef = resolveInstallRelativePath(pristineDir, relPath);
if (!stagedRef || !outRef || !fs.existsSync(stagedRef.fullPath)) continue;
fs.mkdirSync(path.dirname(outRef.fullPath), { recursive: true });
fs.copyFileSync(stagedRef.fullPath, outRef.fullPath);
written++;
}
} finally {
try { fs.rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
}
return written;
}
/**
* #4145: recover a pristine baseline from a hash-matching orphan stored at an
* unexpected path under msd-pristine/ (e.g. without the msd-core/ prefix an
* earlier release's writer dropped).
*
* The preserve-check's strict join (pristineDir + manifest-keyed relPath)
* misses such snapshots, so they were pushed into regeneration from the
* incoming release — and when the file changed upstream, the candidate's hash
* could never satisfy the recorded outgoing hash, leaving the correct
* baseline permanently unconsumed and unpruned (the self-perpetuating state
* #4145 reports). Hash equality with pristine_hashes is the same authority
* the #3657 drift guard trusts, so an exact match cannot be the wrong
* baseline no matter where under msd-pristine/ it lives.
*
* Recovery = relocation: copy the orphan to the canonical manifest-keyed path
* (hash-verified after the copy) and remove the orphan only once the
* canonical copy is verified in place. Returns true when the canonical path
* ended up holding recorded-hash bytes. Never deletes anything it cannot
* vouch for by hash, and never consumes a path that is the canonical path of
* ANY manifest file (see canonicalSkip below) — only genuine orphans, which
* no strict-join reader ever consults, are eligible for removal.
*/
function recoverOrphanedPristine(pristineDir, relPath, recordedHash, canonicalSkip) {
if (!recordedHash) return false;
let orphanRel;
try {
// canonicalSkip = the normalized manifest keys: a file already sitting at
// any canonical path can never be (re-)adopted through the scan. Without
// this, two modified files sharing byte-identical outgoing content would
// repeatedly "rescue" each other's canonical away (relocate + delete at
// its home path) in alternating updates — bytes identical, state unstable.
// It also keeps drift (#3657) / stale (#3407) territory with the caller.
orphanRel = msdFindPristineByHash(pristineDir, recordedHash, canonicalSkip);
} catch {
return false;
}
if (!orphanRel) return false;
const outRef = resolveInstallRelativePath(pristineDir, relPath);
if (!outRef) return false;
try {
fs.mkdirSync(path.dirname(outRef.fullPath), { recursive: true });
fs.copyFileSync(path.join(pristineDir, orphanRel), outRef.fullPath);
// Verify the relocated copy before removing the orphan — only a
// hash-matching canonical counts as recovered.
if (fileHash(outRef.fullPath) !== recordedHash) {
try { fs.rmSync(outRef.fullPath, { force: true }); } catch { /* best-effort */ }
return false;
}
// Orphan removal is best-effort: the canonical copy is already verified,
// so a failed unlink leaves a harmless duplicate, never data loss.
try { fs.rmSync(path.join(pristineDir, orphanRel), { force: true }); } catch { /* best-effort */ }
return true;
} catch {
return false;
}
}
/**
* #4135: honest N-of-M accounting for msd-pristine/ baselines after an
* update. The #3407 promotion rule keeps only hash-validated candidates
* (byte-identical across the version span), so a multi-version update
* legitimately ends with near-zero baselines — the collapse itself is NOT a
* bug to hide; hiding it is. This renders the covered-of-total line the
* update output prints either way, so "1 of 13" can never present like a
* fully-covered run. Pure function (typed return) so tests lock the exact
* contract without matching console prose.
*/
function describeBaselineCoverage(totalModified, covered) {
const total = Math.max(0, totalModified);
const have = Math.min(Math.max(0, covered), total);
const uncovered = total - have;
return {
complete: uncovered === 0,
uncovered,
text: uncovered === 0
? `msd-pristine/ baselines cover ${have} of ${total} modified file(s)`
: `msd-pristine/ baselines cover ${have} of ${total} modified file(s) — ${uncovered} will be reported no_baseline by the reapply verifier`,
};
}
/**
* Detect user-modified MSD files by comparing against install manifest.
* Backs up modified files to msd-local-patches/ for reapply after update.
* Also saves pristine copies (from manifest) to msd-pristine/ to enable
* three-way merge during reapply-patches (pristine vs user vs new).
*
* The optional `pristineCtx` parameter (set by the install entry point)
* carries the source package root, runtime, pathPrefix, and isGlobal
* needed to populate msd-pristine/. If omitted (legacy callers), pristine
* stays empty — the verifier falls back to its over-broad heuristic, same
* behavior as before #2998.
*/
function saveLocalPatches(configDir, pristineCtx) {
const manifestPath = path.join(configDir, MANIFEST_NAME);
if (!fs.existsSync(manifestPath)) return [];
let manifest;
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return []; }
// Normalize legacy manifests written before #2771 fix: strip user-owned artifacts
// that were incorrectly recorded so refreshes don't surface false patches warnings.
if (manifest.files) {
for (const artifact of USER_OWNED_ARTIFACTS) {
delete manifest.files[`msd-core/${artifact}`];
}
}
const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
const pristineDir = path.join(configDir, 'msd-pristine');
const modified = [];
const pristineHashes = {};
// #4086: skills/ manifest keys may live OUTSIDE configDir at the runtime's
// ACTUAL skills root — codex global installs to $HOME/.agents/skills (the
// ADR-1239 skills-kind `home` override), which writeManifest() already
// hashes from via _resolveSkillsRootDir (#2088/#3738). Resolving every key
// against configDir alone made every skills/ key miss here, so user
// modifications to Codex skills were never hash-compared, never backed up,
// and were silently overwritten by the next update. configDir stays FIRST
// (Postel: runtimes whose skills genuinely live under configDir resolve
// byte-identically to before); the skills root is a fallback, only for keys
// under the SAME descriptor-driven manifest prefix writeManifest uses, and
// only when that root resolves outside configDir. Containment + symlink
// guards apply to the alternate root too (resolveInstallRelativePath).
const patchRuntime = (pristineCtx && pristineCtx.runtime) || manifest.runtime || null;
const patchScope = manifest.scope === 'local' ? 'local' : 'global';
let skillsRedirect = null;
if (patchRuntime) {
const skillsRoot = _resolveSkillsRootDir(patchRuntime, configDir, patchScope);
const resolvedConfig = path.resolve(configDir);
if (
skillsRoot &&
skillsRoot !== resolvedConfig &&
!skillsRoot.startsWith(resolvedConfig + path.sep)
) {
skillsRedirect = { root: skillsRoot, prefix: 'skills/' };
}
}
for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
const safeRef = resolveInstallRelativePath(configDir, relPath);
if (!safeRef) continue;
const { relPath: safeRelPath, fullPath } = safeRef;
let installedPath = fullPath;
if (!fs.existsSync(installedPath) && skillsRedirect && safeRelPath.startsWith(skillsRedirect.prefix)) {
const altRef = resolveInstallRelativePath(
skillsRedirect.root,
safeRelPath.slice(skillsRedirect.prefix.length)
);
if (altRef && fs.existsSync(altRef.fullPath)) {
installedPath = altRef.fullPath;
}
}
if (!fs.existsSync(installedPath)) continue;
const currentHash = fileHash(installedPath);
if (currentHash !== originalHash) {
// Back up the user's modified version
const backupRef = resolveInstallRelativePath(patchesDir, safeRelPath);
if (!backupRef) continue;
const backupPath = backupRef.fullPath;
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
fs.copyFileSync(installedPath, backupPath);
modified.push(safeRelPath);
pristineHashes[safeRelPath] = originalHash;
}
}
// Save pristine copies of modified files from the CURRENT install (before wipe).
// Pristine semantically represents "what the install would write to configDir
// if the user had not modified it" — used by /msd-reapply-patches Step 5
// (#2972) as the diff baseline for the user-added-lines computation. Without
// this dir the verifier degrades to its over-broad fallback heuristic (#2998).
if (modified.length > 0) {
const meta = {
backed_up_at: new Date().toISOString(),
from_version: manifest.version,
from_manifest_timestamp: manifest.timestamp,
files: modified,
pristine_hashes: {}
};
// Record the original (pristine) hash for each modified file
// This lets the reapply workflow verify reconstructed pristine files
for (const relPath of modified) {
meta.pristine_hashes[relPath] = pristineHashes[relPath];
}
fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified MSD file(s) — backed up to ' + PATCHES_DIR_NAME + '/');
for (const f of modified) {
console.log(' ' + dim + f + reset);
}
// #2998 / #3407: maintain msd-pristine/ as the diff baseline for the
// reapply-patches verifier (#2972).
//
// #3407 root-cause fix: the prior approach (#3004 CR) wiped msd-pristine/
// and re-populated it from pristineCtx.packageSrc (the NEW release source).
// For files that changed between the old and new release this wrote NEW-
// release bytes as the pristine baseline while backup-meta.json recorded
// OLD-release hashes — a hash mismatch that caused the #3657 verifier guard
// (OK_PRISTINE_DRIFT_DETECTED) to skip the baseline and fall back to over-
// broad mode on every upgrade.
//
// Correct approach: `msd-pristine/` is populated lazily by saveLocalPatches'
// regenerate branch (not by a separate install-time step); the fix works by
// induction across upgrades — each clean upgrade persists hash-validated
// entries for the next run. During this call we must PRESERVE entries whose
// hash matches originalHash, not overwrite them with new-release bytes.
//
// Per-file decision:
// - sha256(msd-pristine/X) === originalHash → correct; keep it
// - msd-pristine/X exists but hash mismatch → stale from a previous
// buggy run (#3407); remove so verifier falls back cleanly
// - msd-pristine/X absent → attempt hash-validated
// regeneration: generate candidate from new-release source; if
// sha256(candidate) === originalHash the file is identical between
// old and new releases so candidate bytes ARE the old-release pristine
// and can be used; discard otherwise (over-broad fallback)
if (pristineCtx) {
let preserved = 0;
// Track which relPaths had stale pristine entries (hash mismatch) that we
// removed. After the regeneration pass we compute `removed` = stale entries
// that could NOT be recovered (over-broad fallback applies to those only).
const stalePaths = new Set();
// Track which relPaths were successfully regenerated (from either missing or stale).
const regeneratedPaths = new Set();
// #4145: track which relPaths were recovered by relocating a hash-matching
// orphan (stored at an unexpected path, e.g. without the msd-core/ prefix).
const rescuedPaths = new Set();
// #4145: the set of paths that are SOME file's canonical pristine path
// (every normalized manifest key). The orphan scan must never consume
// these — see recoverOrphanedPristine.
const canonicalSkip = new Set(
Object.keys(manifest.files || {}).map((k) => normalizeInstallRelativePath(k)).filter(Boolean),
);
const missingPaths = [];
for (const relPath of modified) {
const outRef = resolveInstallRelativePath(pristineDir, relPath);
if (!outRef) continue;
const { fullPath: pristinePath } = outRef;
if (fs.existsSync(pristinePath)) {
try {
const onDiskHash = fileHash(pristinePath);
if (onDiskHash === pristineHashes[relPath]) {
preserved++;
continue; // correct old-release bytes already in place — keep them
}
} catch { /* read error — treat as mismatch */ }
// Hash mismatch or read error: stale pristine from a previous buggy
// run (#3407). Remove so verifier falls back to over-broad mode.
try { fs.rmSync(pristinePath, { force: true, recursive: true }); } catch { /* best-effort */ }
// Only count as removed if the file is actually gone post-removal.
if (!fs.existsSync(pristinePath)) {
stalePaths.add(relPath);
}
}
// #4145: canonical absent (or just removed as stale) — before falling
// into regeneration, try to recover the baseline from a hash-matching
// orphan elsewhere under msd-pristine/ and relocate it to the canonical
// path. This is the self-heal for snapshots an earlier release stored
// without the msd-core/ prefix: without it the state repeats forever
// (regeneration candidates from the incoming release can never satisfy
// the recorded outgoing hash when upstream changed the file).
if (recoverOrphanedPristine(pristineDir, relPath, pristineHashes[relPath], canonicalSkip)) {
rescuedPaths.add(relPath);
continue;
}
// File absent from msd-pristine/ (or just removed above as stale):
// attempt hash-validated regeneration from new-release source.
missingPaths.push(relPath);
}
// Regenerate missing entries into a temp dir, then validate each hash
// before promoting. Only files whose new-release generated bytes hash to
// originalHash are safe to use — they were unchanged between releases.
if (missingPaths.length > 0) {
let tempPristineDir = null;
try {
tempPristineDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-pristine-regen-'));
populatePristineDir({
packageSrc: pristineCtx.packageSrc,
pristineDir: tempPristineDir,
modified: missingPaths,
runtime: pristineCtx.runtime,
pathPrefix: pristineCtx.pathPrefix,
isGlobal: pristineCtx.isGlobal,
});
for (const relPath of missingPaths) {
const tempRef = resolveInstallRelativePath(tempPristineDir, relPath);
const outRef = resolveInstallRelativePath(pristineDir, relPath);
if (!tempRef || !outRef || !fs.existsSync(tempRef.fullPath)) continue;
try {
const candidateHash = fileHash(tempRef.fullPath);
if (candidateHash !== pristineHashes[relPath]) continue; // new-release differs — discard
fs.mkdirSync(path.dirname(outRef.fullPath), { recursive: true });
fs.copyFileSync(tempRef.fullPath, outRef.fullPath);
regeneratedPaths.add(relPath);
} catch { /* hash or copy error — skip; over-broad fallback applies */ }
}
} catch (err) {
// Match the pre-fix behavior: log a warning and continue (verifier falls back to over-broad mode for missing files).
console.warn(`msd-pristine regen skipped: ${err.message}`);
} finally {
if (tempPristineDir) {
try { fs.rmSync(tempPristineDir, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}
}
// `regenerated` = total files successfully regenerated (from missing OR stale).
const regenerated = regeneratedPaths.size;
// `rescued` = files recovered by relocating a hash-matching orphan to its
// canonical path (#4145) — distinct from preservation (canonical already
// correct) and regeneration (bytes re-derived from new-release source).
const rescued = rescuedPaths.size;
// `removed` = stale entries that were deleted and NOT subsequently regenerated.
// Entries that were stale-deleted but then successfully regenerated are counted
// only in `regenerated`; stale-deleted-then-orphan-rescued entries are counted
// only in `rescued` — the counts are non-overlapping.
const removed = [...stalePaths].filter(p => !regeneratedPaths.has(p) && !rescuedPaths.has(p)).length;
if (preserved > 0) {
console.log(' ' + green + '✓' + reset + ' Preserved ' + cyan + 'msd-pristine/' + reset + ' (' + preserved + ' file(s)) for three-way merge');
}
if (rescued > 0) {
console.log(' ' + green + '✓' + reset + ' Recovered ' + cyan + 'msd-pristine/' + reset + ' (' + rescued + ' file(s)) by recorded hash from a legacy-path snapshot and relocated them (#4145)');
}
if (regenerated > 0) {
console.log(' ' + green + '✓' + reset + ' Regenerated ' + cyan + 'msd-pristine/' + reset + ' (' + regenerated + ' file(s)) via hash-validated new-release source');
}
if (removed > 0) {
console.log(' ' + yellow + 'i' + reset + ' Removed ' + removed + ' stale msd-pristine/ snapshot(s); regenerated ' + regenerated + ' of those — falls back to over-broad verify heuristic for the rest');
}
// #4135: the honest N-of-M coverage line. Preserved/rescued/regenerated
// are disjoint buckets (see their accounting comments above), so their
// sum is exactly the files that ended this update with a hash-valid
// baseline. A partial result renders as an info line, not an error:
// the collapse is legitimate (#3407), hiding it was the bug.
const coverage = describeBaselineCoverage(modified.length, preserved + rescued + regenerated);
if (coverage.complete) {
console.log(' ' + green + '✓' + reset + ' ' + coverage.text);
} else {
console.log(' ' + yellow + 'i' + reset + ' ' + coverage.text);
}
}
}
return modified;
}
/**
* After install, report backed-up patches for user to reapply.
*/
function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) {
const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
const metaPath = path.join(patchesDir, 'backup-meta.json');
if (!fs.existsSync(metaPath)) return [];
let meta;
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
if (meta.files && meta.files.length > 0) {
const reapplyCommand = _hostBehaviors(runtime).reapplyCommand || '/msd-update --reapply';
console.log('');
console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
for (const f of meta.files) {
console.log(' ' + cyan + f + reset);
}
console.log('');
console.log(' Your modifications are saved in ' + cyan + PATCHES_DIR_NAME + '/' + reset);
console.log(' Run ' + cyan + reapplyCommand + reset + ' to merge them into the new version.');
console.log(' Or manually compare and merge the files.');
console.log('');
}
return meta.files || [];
}
function reportInstallerMigrationResult(result) {
const summary = summarizeInstallerMigrationResult(result);
if (!summary.hasReportableActions) return;
console.log(` ${green}✓${reset} Installer migrations`);
for (const row of summary.rows) {
const reason = row.reason ? ` — ${row.reason}` : '';
console.log(` ${row.label} ${dim}${row.relPath}${reset}${reason}`);
}
}
function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
const { isOpencode, isCodex, isCursor } = runtimeFlags(runtime);
const plan = resolveInstallPlan(runtime);
const dirName = getDirName(runtime);
const src = path.join(__dirname, '..');
// #3241 — the Codex resolver-model-omitted notice dedupes "at most once", but
// scoped per install() call rather than per process — each install() run gets
// its own fresh window so a second install (e.g. a second runtime, or a test
// re-running install()) can warn again if the same condition recurs.
_codexResolverModelOmittedWarned = false;
// #2870: scope id resolved ONCE here and reused at every use below (was 10
// independent isGlobal-derived re-derivations). Routed through the Install Scope Module (src/install-scope.cts)
// when the capability registry is available; degrades to the plain id on
// failure (unknown/non-installable runtime, broken bundle) so this
// function's plain scope-id uses — which never depended on registry
// availability before this migration — keep working exactly as they did
// pre-migration. `_installScope` (the full resolved value, not just the
// id) additionally backs the settingsFileByScope routing below.
const _installScopeId = isGlobal ? 'global' : 'local';
const _installScope = _resolveScopeSafe(_installScopeId, runtime);
// Reusable helper to copy hooks/lib/ (git-cmd.js + msd-graphify-rebuild.sh).
// Defined early so it is visible to both the main and Codex code paths.
// `allowlist` (when non-empty) restricts copying to the named top-level entries,
// keeping install scope aligned with MSD_HOOK_LIB_FILES (which uninstall/manifest manage).
const copyLibDir = (sDir, dDir, allowlist = []) => {
const allowed = allowlist.length > 0 ? new Set(allowlist) : null;
for (const entry of fs.readdirSync(sDir)) {
if (allowed && !allowed.has(entry)) continue;
const s = path.join(sDir, entry);
const d = path.join(dDir, entry);
let st;
try { st = fs.lstatSync(s); } catch (_) { continue; }
if (st.isSymbolicLink()) continue; // defense-in-depth
if (st.isDirectory()) {
fs.mkdirSync(d, { recursive: true });
copyLibDir(s, d);
} else if (entry.endsWith('.sh')) {
let content = fs.readFileSync(s, 'utf8');
content = content.replace(/\{\{MSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(d, content);
try { fs.chmodSync(d, 0o755); } catch (_) { /* Windows */ }
} else {
fs.copyFileSync(s, d);
if (entry.endsWith('.js')) {
try { fs.chmodSync(d, 0o755); } catch (_) { /* Windows */ }
}
}
}
};
// Get the target directory based on runtime and install type.
// #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/
// directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES
// but NOT auto-removed here; legacy .agent/ msd artifacts are recognized but not
// auto-removed on reinstall (dual-read fallback per issue #791 spec).
const targetDir = isGlobal
? getGlobalConfigDir(runtime, explicitConfigDir)
: path.join(process.cwd(), dirName);
// #3664: a --config-dir destination holding foreign agent files gets an
// explicit install-time warning — never a silent Claude-shaped emit.
if (isGlobal) {
warnIfForeignAgentDest(runtime, targetDir, _installScopeId, Boolean(explicitConfigDir));
}
// #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user
// artifact orphaned by a PRIOR install run that died between staging and
// its own restore/discard, BEFORE this run's own preserve step stages
// anything new. This is the production entry point every install() call
// reaches — the only place this phase's durability fix is complete rather
// than merely callable (40-design.md "The inertness trap this design must
// avoid" / #1879-F15). Runs for every runtime, ahead of both the
// layout-driven path's _runLegacyInstallMigrations (site 1, inside
// installRuntimeArtifacts) and this function's own mainline msd-core copy
// (site 4, below).
// #2875 defect fix: DEGRADE, never abort install, when the staging root
// itself cannot be resolved — skip this recovery pass rather than throw
// out of install() before it does anything.
{
const _installEntryStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
if (_installEntryStagingRoot !== null) {
recoverOrphanedUserArtifacts(_installEntryStagingRoot, targetDir);
}
}
const locationLabel = isGlobal
? targetDir.replace(os.homedir(), '~')
: targetDir.replace(process.cwd(), '.');
// Path prefix for file references in markdown content (e.g. msd-tools.cjs).
// Replaces $HOME/.claude/ or ~/.claude/ so the result is <pathPrefix>msd-core/bin/...
// For global installs: use $HOME/ so paths expand correctly inside double-quoted
// shell commands (~ does NOT expand inside double quotes, causing MODULE_NOT_FOUND).
// For local installs: use resolved absolute path (may be outside $HOME).
// Exception: OpenCode does not expand $HOME in @file references on any platform —
// `@$HOME/...` is treated as a literal path relative to the config dir, producing
// `command/$HOME/...` (file not found). Use the absolute path for OpenCode so
// @-references resolve correctly (#2376 Windows, #2831 macOS/Linux).
// msd update marker re-application (ADR-0010 Deviation 2):
// Resolve which profile to use for this runtime's install:
// 1. --minimal / --core-only → back-compat alias for the core profile
// 2. Explicit --profile=<name> → use it (overrides any marker)
// 3. Marker exists in targetDir → honor it (prevents silent expansion on update)
// 4. Else → 'full' (back-compat for fresh non-interactive installs)
//
// Multi-runtime disagreement: if installing across runtimes and their markers
// differ, the caller may use mostRestrictiveProfile() across the per-runtime
// results — here we resolve each runtime independently.
//
// ADR-857 phase 4c: ALL profiles (including core/minimal) use stageSkillsForProfile
// with the registry-aware _resolvedProfile so future tier:core capabilities are
// staged on core installs. The 'minimal' back-compat distinction is now ONLY the
// empty manifest (core profile has no transitive deps); the registry IS consulted.
// MINIMAL is intentionally the same skill set as the 'core' profile
// (MINIMAL_ALLOWLIST_SET === Set(PROFILES.core)) — it is NOT a separately curated
// subset. Any future tier:core capability therefore DOES belong in a minimal/core
// install. Using stageSkillsForProfile(_resolvedProfile) honors the registry while
// keeping the effective skill set identical to the prior stageSkillsForMode path
// until a tier:core capability is registered.
const _activeProfileName = hasMinimal
? 'core' // --minimal is a back-compat alias for the core profile; marker records 'core'
: resolveEffectiveProfile({
requestedProfileName: _requestedProfileName,
targetDir,
});
const _isCoreProfileAlias = _activeProfileName === 'core';
const _effectiveInstallMode = _isCoreProfileAlias ? 'minimal' : 'full';
// Load the manifest and compute resolved profile for named profiles.
// For --minimal/core: use an empty manifest (core profile has no transitive
// deps) to produce a resolvedProfile with the core skill set. For core/
// standard profiles, resolveProfile's `registry` arg IS consulted (via
// _capabilitySkillsForMode) so tier:core/tier:standard capability skills are
// unioned in when registered. #2322 correction: for the DEFAULT `full`
// profile, resolveProfile short-circuits to the `{skills:'*'}` sentinel
// BEFORE ever reading `registry` (there is nothing to union — '*' already
// means "everything"), so the registry consultation that matters for `full`
// happens LATER, at staging time (stageSkillsForRuntimeAsSkills's '*'
// fill-in, resolveRuntimeArtifactLayout's `capabilityRegistry` param below) —
// not here. `_installedCapabilityRegistry` (not the frozen `_capabilityRegistry`)
// is passed so an INSTALLED third-party capability (not just a first-party
// one) is honored on every profile, `full` included (#2322 blocker 2).
const _commandsDir = path.join(src, 'commands', 'msd');
const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir);
let _resolvedProfile = resolveProfile({
modes: [_activeProfileName],
manifest: _skillsManifest,
registry: _installedCapabilityRegistry,
});
// Unified staging function: all profiles use stageSkillsForProfile with the
// registry-aware _resolvedProfile (ADR-857 phase 4c cutover).
function _stageSkills(commandsMsdDir) {
return stageSkillsForProfile(commandsMsdDir, _resolvedProfile);
}
function _stageAgents(agentsDir) {
if (_isCoreProfileAlias) return agentsDir;
return stageAgentsForProfile(agentsDir, _resolvedProfile);
}
const persistActiveProfileMarker = () => {
try {
writeActiveProfile(targetDir, _activeProfileName);
} catch {
// Non-fatal: marker persistence failure doesn't break the install.
}
};
const resolvedTarget = path.resolve(targetDir).replace(/\\/g, '/');
const homeDir = os.homedir().replace(/\\/g, '/');
const isWindowsHost = process.platform === 'win32';
const pathPrefix = computePathPrefix({
isGlobal,
isOpencode: _hostBehaviors(runtime).skipHomePrefixSubstitution === true,
isWindowsHost,
resolvedTarget,
homeDir,
// #4377: the runtime's own localConfigDir. This is the prefix that reaches
// copyWithPathReplacement, i.e. the one actually written into every
// emitted command/skill/workflow body — the rewrite-engine seams below
// handle re-applied surfaces, not the first install.
localDirName: getDirName(runtime),
});
// runtimeLabel is now the single-source getRuntimeLabel lookup (ADR-1239
// Phase B / #1679) — collapses the prior 16-line assignment chain.
const runtimeLabel = getRuntimeLabel(runtime);
console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
// Track installation failures
const failures = [];
const configuredEntrypoints = [];
let installerMigrationResult = null;
const rollbackInstallerMigrations = () => {
if (!installerMigrationResult || typeof installerMigrationResult.rollback !== 'function') return;
const rollback = installerMigrationResult.rollback;
installerMigrationResult = null;
rollback();
};
// Save any locally modified MSD files before they get wiped.
// The pristine context lets saveLocalPatches populate msd-pristine/ via
// the install transform pipeline, giving the reapply-patches Step 5
// verifier a real diff baseline (#2998).
saveLocalPatches(targetDir, {
packageSrc: src,
runtime,
pathPrefix,
isGlobal,
});
// Run manifest-backed cleanup migrations before package materialization.
installerMigrationResult = runInstallerMigrations({ configDir: targetDir });
// #3245 — Codex idempotent rollback. Capture pre-install state of ALL
// directories and files MSD will mutate so that any post-install validation
// failure (config.toml schema check, write failure, etc.) can revert the
// entire install atomically — not just config.toml.
//
// Captured BEFORE the first Codex-specific write (skills/) so the snapshots
// reflect the true pre-MSD state. Non-Codex runtimes skip this block.
//
// Snapshot contents:
// codexPreInstallSkillNames — Set of msd-* skill dir names that existed
// codexPreInstallSkillContents — Map<skillName, Map<relPath, Buffer>> of
// the full file tree of each pre-existing msd-* skill dir, so that
// overwritten dirs can be fully restored on rollback (not just removed).
// codexPreInstallAgentFiles — Set of msd-*.{md,toml} filenames in agents/
// codexPreInstallAgentContents — Map<filename, Buffer> of pre-existing agent
// file bytes, enabling full content restore (not just deletion) on rollback.
// codexPreInstallVersionBytes — Buffer (or null) of msd-core/VERSION
//
// These are referenced by restoreCodexSnapshot(), defined below inside the
// config block. Defining the variables here (outer scope) makes them
// accessible by closure.
const codexPreInstallSkillNames = new Set();
// Map<skillDirName, Map<relPath, Buffer>> — full content snapshot of each
// pre-existing msd-* skill directory. Best-effort: read errors are silently
// skipped so a partial snapshot is still better than none.
const codexPreInstallSkillContents = new Map();
const codexPreInstallAgentFiles = new Set();
// Map<filename, Buffer> — content snapshot of each pre-existing msd-* agent file.
const codexPreInstallAgentContents = new Map();
let codexPreInstallVersionBytes = null;
// #4544 — manifest-driven snapshot state (captured in the block below):
// codexPreInstallManagedFiles — Map<normalizedRelPath, Buffer|null>; one
// entry per path the PRIOR install's msd-file-manifest.json recorded.
// null means the path did not exist pre-install, so rollback re-deletes
// whatever this install put there instead of resurrecting it.
// codexPreInstallManifestBytes — Buffer (or null) of the prior manifest file
// itself, which a reinstall rewrites.
// codexPreInstallHooksTree — Map<relPath, Buffer>, a full recursive
// snapshot of <targetDir>/hooks/. The Codex manifest deliberately omits
// hooks/ (the !isCodex gate on shared-hooks tracking), and hooks/ is
// shared space, so the restore is wholesale: user files that predate
// the install are in the snapshot and come back; anything the failed
// install staged does not.
const codexPreInstallManagedFiles = new Map();
let codexPreInstallManifestBytes = null;
const codexPreInstallHooksTree = new Map();
// #4544 (review) — capture-state flags the restore must consult:
// codexManagedSnapshotCaptured — the capture gate ran at all. When
// false (non-Codex runtimes, minimal mode) NO pre-install state was
// recorded, and the only safe restore is no restore: an empty
// snapshot must never be read as "hooks/ was absent".
// codexPreInstallHooksDirPreExisted — hooks/ existed as a DIRECTORY
// pre-install. A pre-existing hooks FILE is left alone on rollback
// rather than deleted.
// codexPreInstallHooksCaptureIncomplete — some part of the hooks/ tree
// could not be read (permissions, special files). The restore
// downgrades to per-file so an uncapturable user file is never
// destroyed by a wholesale delete whose snapshot lacked it.
let codexManagedSnapshotCaptured = false;
// null = the gate never ran; true/false = the gate ran and hooks/ (did|did
// not) exist as a directory pre-install. Two states are load-bearing: a
// clean first install records false, so its rollback removes the staged
// hooks/ tree entirely; a non-Codex runtime records null, so rollback does
// nothing.
let codexPreInstallHooksDirPreExisted = null;
let codexPreInstallHooksCaptureIncomplete = false;
// #4249 CR: not gated on install mode. restoreCodexSnapshot is reachable for
// a core/--minimal install too (#2695), and its pass-2 sweeps remove every
// msd-* skill dir / agent file the snapshot does not claim — so an empty
// minimal-mode snapshot deleted the whole surface with nothing to restore.
if (_hostBehaviors(runtime).tomlConfigInstall) {
codexManagedSnapshotCaptured = true;
const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
if (fs.existsSync(_preSkillsDir)) {
for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('msd-')) {
codexPreInstallSkillNames.add(entry.name);
// Recursively snapshot all files in this skill dir.
const skillDir = path.join(_preSkillsDir, entry.name);
const fileMap = new Map();
const _snapshotDir = (dir, relBase) => {
let children;
try { children = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
for (const child of children) {
const relPath = relBase ? `${relBase}/${child.name}` : child.name;
const fullPath = path.join(dir, child.name);
if (child.isDirectory()) {
_snapshotDir(fullPath, relPath);
} else {
try { fileMap.set(relPath, fs.readFileSync(fullPath)); } catch (_) { /* best-effort */ }
}
}
};
_snapshotDir(skillDir, '');
codexPreInstallSkillContents.set(entry.name, fileMap);
}
}
}
const _preAgentsDir = path.join(targetDir, 'agents');
if (fs.existsSync(_preAgentsDir)) {
for (const file of fs.readdirSync(_preAgentsDir)) {
if (file.startsWith('msd-') && (file.endsWith('.md') || file.endsWith('.toml'))) {
codexPreInstallAgentFiles.add(file);
try {
codexPreInstallAgentContents.set(file, fs.readFileSync(path.join(_preAgentsDir, file)));
} catch (_) { /* best-effort */ }
}
}
}
const _preVersionPath = path.join(targetDir, 'msd-core', 'VERSION');
if (fs.existsSync(_preVersionPath)) {
try { codexPreInstallVersionBytes = fs.readFileSync(_preVersionPath); } catch (_) { /* best-effort */ }
}
// #4544 — capture the manifest-driven surfaces, same best-effort
// conventions as the skills/ snapshot above. readInstallManifest is the
// same hardened reader installer-migrations uses (array/ garbage shapes
// degrade to an empty file set — rollback then simply covers less, never
// crashes), and resolveInstallRelativePath keeps a hostile manifest key
// from turning into a write outside the install root.
const _priorManifest = readInstallManifest(targetDir);
for (const rel of Object.keys(_priorManifest.files)) {
const resolved = resolveInstallRelativePath(targetDir, rel);
if (!resolved) continue;
try {
codexPreInstallManagedFiles.set(resolved.relPath, fs.readFileSync(resolved.fullPath));
} catch (_) {
// Listed but absent/unreadable pre-install: snapshot absence, so
// rollback re-deletes instead of resurrecting.
codexPreInstallManagedFiles.set(resolved.relPath, null);
}
}
const _preManifestPath = path.join(targetDir, MANIFEST_NAME);
if (fs.existsSync(_preManifestPath)) {
try { codexPreInstallManifestBytes = fs.readFileSync(_preManifestPath); } catch (_) { /* best-effort */ }
}
// #4544 (review) — a clean FIRST install has no prior manifest, so nothing
// above records the payload this install is about to write, and a failed
// clean install would roll back to a half-written tree. Enumerate the SAME
// source directories the installer copies (a directory walk tracks the
// source tree automatically — no second file list to keep in parity) and
// record every path as absent-pre-install. On a reinstall most of these
// already carry entries from the prior manifest; any that do not (files
// new in this version) snapshot their pre-install bytes or absence exactly
// like the rest, which also closes the new-version-file residual.
const _recordWritePlanTree = (srcDir, relPrefix) => {
let children;
try { children = fs.readdirSync(srcDir, { withFileTypes: true }); } catch (_) { return; }
for (const child of children) {
const rel = relPrefix ? `${relPrefix}/${child.name}` : child.name;
if (child.isDirectory()) {
_recordWritePlanTree(path.join(srcDir, child.name), rel);
} else if (child.isFile()) {
if (codexPreInstallManagedFiles.has(rel)) continue;
// USER_OWNED_ARTIFACTS are manifest-relative to msd-core/ (#2771):
// they are durably staged across reinstalls and must never enter a
// rollback delete-set.
const manifestRel = rel.startsWith('msd-core/') ? rel.slice('msd-core/'.length) : rel;
if (USER_OWNED_ARTIFACTS.includes(manifestRel)) continue;
const resolved = resolveInstallRelativePath(targetDir, rel);
if (!resolved) continue;
try {
codexPreInstallManagedFiles.set(rel, fs.existsSync(resolved.fullPath) ? fs.readFileSync(resolved.fullPath) : null);
} catch (_) {
codexPreInstallManagedFiles.set(rel, null);
}
}
}
};
_recordWritePlanTree(path.join(src, 'msd-core'), 'msd-core');
_recordWritePlanTree(path.join(src, 'scripts', 'changeset'), 'scripts/changeset');
_recordWritePlanTree(path.join(src, 'scripts', 'lib'), 'scripts/lib');
// msd-core/CHANGELOG.md is sourced from the repo root (not src/msd-core)
// and msd-core/.msd-runtime is generated at install time — neither appears
// in the directory walks, so record them explicitly.
for (const standalone of ['msd-core/CHANGELOG.md', 'msd-core/.msd-runtime', 'scripts/fix-slash-commands.cjs', 'scripts/gen-capability-registry.cjs', 'scripts/gen-loop-host-contract.cjs']) {
if (codexPreInstallManagedFiles.has(standalone)) continue;
const resolved = resolveInstallRelativePath(targetDir, standalone);
if (!resolved) continue;
try {
codexPreInstallManagedFiles.set(standalone, fs.existsSync(resolved.fullPath) ? fs.readFileSync(resolved.fullPath) : null);
} catch (_) {
codexPreInstallManagedFiles.set(standalone, null);
}
}
// hooks/ — full recursive snapshot, but never blind: lstat every entry so
// a symlink under hooks/ is neither followed (a link to a FIFO would hang
// the installer, /dev/zero would exhaust memory, and a link to private
// data would copy that data into the snapshot — hooks/ is user-writable
// shared space and, for local installs, repo-controllable) nor restored
// as a link. Anything unreadable or special marks the capture INCOMPLETE
// so the restore downgrades to per-file instead of wholesale-deleting a
// tree it never fully saw. A pre-existing hooks FILE (not directory) is
// recorded as such and left alone on rollback.
const _preHooksPath = path.join(targetDir, 'hooks');
let _preHooksStat = null;
try { _preHooksStat = fs.lstatSync(_preHooksPath); } catch (_) { /* absent */ }
codexPreInstallHooksDirPreExisted = Boolean(_preHooksStat && _preHooksStat.isDirectory());
if (codexPreInstallHooksDirPreExisted) {
const _snapshotHooksDir = (dir, relBase) => {
let children;
try { children = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) {
codexPreInstallHooksCaptureIncomplete = true;
return;
}
for (const child of children) {
const relPath = relBase ? `${relBase}/${child.name}` : child.name;
const fullPath = path.join(dir, child.name);
let st = null;
try { st = fs.lstatSync(fullPath); } catch (_) {
codexPreInstallHooksCaptureIncomplete = true;
continue;
}
if (st.isDirectory()) {
_snapshotHooksDir(fullPath, relPath);
} else if (st.isFile()) {
try { codexPreInstallHooksTree.set(relPath, fs.readFileSync(fullPath)); } catch (_) {
codexPreInstallHooksCaptureIncomplete = true;
}
} else {
codexPreInstallHooksCaptureIncomplete = true;
}
}
};
_snapshotHooksDir(_preHooksPath, '');
}
}
// #4544 — shared restore for the manifest-driven surfaces. Called by BOTH
// rollback paths: _codexPreConfigRollback (CHANGELOG.md, scripts/, the
// initial manifest write AND — via installer migrations' stale-hook removal
// — hooks/ itself are all mutated BEFORE config.toml is touched, so the
// early path must cover them) and the full restoreCodexSnapshot() below.
// Best-effort throughout, matching the #3245 convention: restore failures
// never mask the original install error.
const restoreCodexManagedSnapshot = () => {
// #4544 (review) — if the capture never ran (non-Codex runtimes, minimal
// mode), no pre-install state was recorded. The only safe action is NONE:
// an empty snapshot must never be read as "hooks/ was absent", or a
// minimal-mode rollback would delete the user's entire hooks/ tree.
if (!codexManagedSnapshotCaptured) return;
// hooks/ — the pre-install tree is restored wholesale: a user file that
// predated the install is IN the snapshot and comes back; anything the
// failed install staged is not, and goes away with the tree. When the
// capture was INCOMPLETE, wholesale deletion would permanently destroy a
// file whose bytes were never captured, so the restore downgrades to
// per-file: put back what was captured and remove only the names MSD
// itself stages (the hoisted CODEX_HOOKS_TO_COPY set plus the CommonJS
// marker). hooks/lib/ is left untouched in that mode — its contents are
// transitive and cannot be enumerated safely without the capture.
if (codexPreInstallHooksDirPreExisted !== null) {
const _hooksRestoreDir = path.join(targetDir, 'hooks');
if (!codexPreInstallHooksDirPreExisted) {
// Clean first install: nothing pre-existed under hooks/, so nothing
// the failed install staged may survive either.
try { fs.rmSync(_hooksRestoreDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
} else if (!codexPreInstallHooksCaptureIncomplete) {
try { fs.rmSync(_hooksRestoreDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
for (const [relPath, buf] of codexPreInstallHooksTree) {
const destFile = path.join(_hooksRestoreDir, relPath);
try {
fs.mkdirSync(path.dirname(destFile), { recursive: true });
fs.writeFileSync(destFile, buf);
} catch (_) { /* best-effort */ }
}
} else {
// MSD-owned names are removed FIRST: several of them are also
// legitimate pre-install files the snapshot just restored, and a
// removal pass after the restore would delete the restored bytes.
for (const hookName of CODEX_HOOKS_TO_COPY) {
try { fs.rmSync(path.join(_hooksRestoreDir, hookName), { force: true }); } catch (_) { /* best-effort */ }
}
try { fs.rmSync(path.join(_hooksRestoreDir, 'package.json'), { force: true }); } catch (_) { /* best-effort */ }
for (const [relPath, buf] of codexPreInstallHooksTree) {
const destFile = path.join(_hooksRestoreDir, relPath);
try {
fs.mkdirSync(path.dirname(destFile), { recursive: true });
fs.writeFileSync(destFile, buf);
} catch (_) { /* best-effort */ }
}
}
}
// Every MSD-owned path the prior manifest recorded (plus the clean-install
// write plan): restore bytes, or re-delete a path that was absent
// pre-install.
for (const [relPath, buf] of codexPreInstallManagedFiles) {
const resolved = resolveInstallRelativePath(targetDir, relPath);
if (!resolved) continue;
try {
if (buf !== null) {
fs.mkdirSync(path.dirname(resolved.fullPath), { recursive: true });
fs.writeFileSync(resolved.fullPath, buf);
} else if (fs.existsSync(resolved.fullPath)) {
fs.rmSync(resolved.fullPath, { force: true });
}
} catch (_) { /* best-effort */ }
}
// The prior manifest file itself: reinstall rewrites it; rollback returns
// the previous install's manifest (or removes it on a clean first install).
const _manifestRestorePath = path.join(targetDir, MANIFEST_NAME);
if (codexPreInstallManifestBytes !== null) {
try { fs.writeFileSync(_manifestRestorePath, codexPreInstallManifestBytes); } catch (_) { /* best-effort */ }
} else if (fs.existsSync(_manifestRestorePath)) {
try { fs.unlinkSync(_manifestRestorePath); } catch (_) { /* best-effort */ }
}
};
// #3245 CR finding 2 — Rollback coverage extends to ALL post-snapshot operations,
// not just the Codex config/hook error paths. Any throw between snapshot capture and
// the Codex config block (skills copy, agents copy, VERSION write, manifest write, etc.)
// must also trigger rollback so the caller is never left in a partially-installed state.
//
// _codexPreConfigRollback covers the surfaces that can be mutated before
// config.toml is touched: skills/, agents/, msd-core/VERSION, the manifest-
// driven surfaces (#4544 — CHANGELOG.md, scripts/, .msd-runtime and the
// manifest itself are all rewritten in this window), and orphaned
// atomic-write temp files. It is safe to call before any writes have happened.
// The full restoreCodexSnapshot() (defined inside the config block) additionally
// handles config.toml and the staged hooks/ tree, which are not yet touched
// at this point in the pipeline.
const _codexPreConfigRollback = !_hostBehaviors(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => {
rollbackInstallerMigrations();
// skills/msd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
for (const skillName of codexPreInstallSkillNames) {
const skillDirPath = path.join(_earlySkillsDir, skillName);
const fileMap = codexPreInstallSkillContents.get(skillName);
try {
fs.rmSync(skillDirPath, { recursive: true, force: true });
fs.mkdirSync(skillDirPath, { recursive: true });
if (fileMap) {
for (const [relPath, buf] of fileMap) {
const destFile = path.join(skillDirPath, relPath);
try {
fs.mkdirSync(path.dirname(destFile), { recursive: true });
fs.writeFileSync(destFile, buf);
} catch (_) { /* best-effort */ }
}
}
} catch (_) { /* best-effort */ }
}
// skills/msd-* — pass 2: remove any newly-created dirs not in the snapshot.
if (fs.existsSync(_earlySkillsDir)) {
try {
for (const entry of fs.readdirSync(_earlySkillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('msd-') && !codexPreInstallSkillNames.has(entry.name)) {
try { fs.rmSync(path.join(_earlySkillsDir, entry.name), { recursive: true, force: true }); }
catch (_) { /* best-effort */ }
}
}
} catch (_) { /* best-effort */ }
}
// agents/msd-* — pass 1: restore snapshot entries.
const _earlyAgentsDir = path.join(targetDir, 'agents');
for (const file of codexPreInstallAgentFiles) {
const buf = codexPreInstallAgentContents.get(file);
if (buf !== undefined) {
try {
fs.mkdirSync(_earlyAgentsDir, { recursive: true });
fs.writeFileSync(path.join(_earlyAgentsDir, file), buf);
} catch (_) { /* best-effort */ }
}
}
// agents/msd-* — pass 2: remove any newly-created files not in the snapshot.
if (fs.existsSync(_earlyAgentsDir)) {
try {
for (const file of fs.readdirSync(_earlyAgentsDir)) {
if (file.startsWith('msd-') && (file.endsWith('.md') || file.endsWith('.toml')) && !codexPreInstallAgentFiles.has(file)) {
try { fs.unlinkSync(path.join(_earlyAgentsDir, file)); } catch (_) { /* best-effort */ }
}
}
} catch (_) { /* best-effort */ }
}
// msd-core/VERSION
const _earlyVersionPath = path.join(targetDir, 'msd-core', 'VERSION');
if (codexPreInstallVersionBytes !== null) {
try { fs.writeFileSync(_earlyVersionPath, codexPreInstallVersionBytes); } catch (_) { /* best-effort */ }
} else if (fs.existsSync(_earlyVersionPath)) {
try { fs.unlinkSync(_earlyVersionPath); } catch (_) { /* best-effort */ }
}
// #4544 — manifest-driven surfaces (CHANGELOG.md, scripts/, the initial
// manifest write, and — via installer migrations' stale-hook removal —
// hooks/ itself are all mutated in this window). The shared restore is
// also idempotent against an untouched tree.
restoreCodexManagedSnapshot();
// Orphaned atomic-write temp files.
const _earlyTmpPattern = /\.tmp-\d+-\d+$/;
function _earlyCleanTmpFiles(dir) {
if (!fs.existsSync(dir)) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
_earlyCleanTmpFiles(full);
} else if (_earlyTmpPattern.test(entry.name) && __atomicWrittenTmps.has(full)) {
try { fs.unlinkSync(full); } catch (_) { /* best-effort */ }
}
}
}
_earlyCleanTmpFiles(targetDir);
};
// Run manifest-backed cleanup migrations after rollback snapshots exist and
// before package materialization. Codex rollback paths invoke the migration
// rollback handle if a later install step fails.
//
// Runtime scope comes from docs/installer-migrations.md#runtime-configuration-contract-registry:
// every supported runtime uses this same planner/apply/report path, while
// individual migration records decide whether a runtime-specific config
// rewrite is allowed by that runtime's documented ownership boundary.
// #3245 CR finding 2 — wrap the pre-config install operations in a try/catch so
// that ANY throw between snapshot capture and the Codex config block triggers rollback.
// Non-Codex paths are unaffected (_codexPreConfigRollback is null for them).
//
// agentsSrc is declared here (let, not const) because installCodexConfig() inside the
// Codex config block below also references it, and that block is outside the try scope.
let agentsSrc = path.join(src, 'agents');
// Capture upgrade signal BEFORE files are written (#683). Must be declared at function
// scope (outside the try block below) so it is accessible in the settings section later.
// Absent VERSION = fresh install; present VERSION = upgrade/re-install.
const priorInstallExisted = fs.existsSync(path.join(targetDir, 'msd-core', 'VERSION'));
try {
installerMigrationResult = runInstallerMigrations({
configDir: targetDir,
runtime,
scope: _installScopeId,
migrations: options.installerMigrations,
baselineScan: true,
});
// #3541: non-interactive runs (typical /msd-update via Claude Code) have
// no stdin TTY and therefore no way to answer prompt-user migration
// actions. Resolve safe categories by classification (stale SDK build
// artifacts → remove; user-facing skills → keep; bundled MSD hooks →
// remove [#3610]) and log every resolution; anything that cannot be
// safely defaulted falls through to assertInstallerMigrationsUnblocked,
// which now emits a grouped error with the documented resolution path.
//
// #3610: the classifier-based resolution must run regardless of TTY.
// For unambiguous categories (e.g. `hooks/msd-*` bundled hooks left
// behind by a previous version), there is no actual "user choice" to
// make — the file is a known MSD-managed artifact and the installer is
// about to write the fresh bundled version. Gating the resolver on
// `!isTTY` made `npx @golem15/msd-core@latest --codex` hard-abort with
// 12 blocked bundled hooks. The env-override branch (operator-supplied
// MSD_INSTALLER_MIGRATION_RESOLVE) still applies only in non-TTY mode.
const _migrationIsTty = process.stdin && process.stdin.isTTY === true;
if (Array.isArray(installerMigrationResult.blocked) &&
installerMigrationResult.blocked.length > 0 &&
installerMigrationResult.plan &&
Array.isArray(installerMigrationResult.plan.actions)) {
const { resolutions } = resolveInstallerMigrationPromptsForNonTty(
installerMigrationResult,
{ isTty: false }
);
for (const entry of resolutions) {
console.log(
` ↪ installer-migration auto-resolved: ${entry.relPath} → ${entry.choice} ` +
`(category=${entry.category}, source=${entry.source})`
);
}
// If we resolved anything, the original run returned early without
// applying the (now-unblocked) plan — apply it here.
if (resolutions.length > 0 && installerMigrationResult.plan.blocked.length === 0) {
const applyResult = applyInstallerMigrationPlan({
configDir: targetDir,
plan: installerMigrationResult.plan,
});
installerMigrationResult = {
...installerMigrationResult,
...applyResult,
blocked: [],
};
}
}
reportInstallerMigrationResult(installerMigrationResult);
assertInstallerMigrationsUnblocked(installerMigrationResult);
// Artifact install dispatcher — routes to layout-driven path for all
// skills-based runtimes (both full and minimal/core profiles); keeps
// back-compat paths for commands-based runtimes (OpenCode/
// Claude-local).
//
// installRuntimeArtifacts handles legacy migration + skill/agent staging
// via layout kinds for all profile modes. _resolvedProfile already reflects
// the user's --profile=core / --minimal choice.
//
// Non-layout side-effects preserved inline:
// Claude local: copyWithPathReplacement + stale-skills cleanup
// Layout-driven path for all skills-based runtimes (full and minimal modes).
// applyRuntimeContentRewritesInPlace (called inside installRuntimeArtifacts)
// handles per-runtime path + branding rewrites.
// Descriptor-driven (ADR-1016 / ADR-1239): a runtime takes the layout-driven
// installRuntimeArtifacts path when its scoped artifactLayout is non-empty
// (it declares any skills/commands/agents kind for this scope).
// This replaces the prior hardcoded `isCodex || ...` roster so a
// newly-added runtime with an artifact layout installs without a per-runtime
// branch — the add-a-host tax ADR-1239 Phase B retires. OpenCode now
// routes through this SAME path too: its hostBehaviors.combinedFamilyInstall
// flag makes installRuntimeArtifacts (in src/install-engine.cts) delegate to
// installOpencodeFamilyArtifacts for the combined commands+skills+native-plugin
// install (ADR-1239 / #2087), replacing the bespoke inline block this comment
// used to describe. Claude-local remains the one special-cased path
// (copyWithPathReplacement + stale-skills cleanup).
const _isSkillsRuntime = (() => {
if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && !isGlobal) return false; // legacy flat local path (descriptor-driven; #2086)
const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime];
const layout = cap && cap.runtime && cap.runtime.artifactLayout;
if (!layout) return false;
const scopeLayout = isGlobal ? layout.global : layout.local;
return Array.isArray(scopeLayout) && scopeLayout.length > 0;
})();
// Install the distribution-owned msd-core tree before layout materialization.
// installRuntimeArtifacts then provisions the durable Runtime Surface corpus
// exactly once into this final tree instead of having that corpus overwritten
// by a later whole-tree copy and needing a second provisioning pass.
const skillSrc = path.join(src, 'msd-core');
const skillDest = path.join(targetDir, 'msd-core');
const _msdArtifactsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
if (_msdArtifactsStagingRoot === null) {
console.warn(` ${yellow}!${reset} Skipping msd-core/${USER_OWNED_ARTIFACTS.join(', msd-core/')} preservation (staging unavailable) — it will be lost if present.`);
copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
} else {
const stagedMsdArtifacts = stageUserArtifacts(skillDest, USER_OWNED_ARTIFACTS, _msdArtifactsStagingRoot);
copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
restoreStagedUserArtifacts(skillDest, stagedMsdArtifacts);
discardStagedUserArtifacts(stagedMsdArtifacts);
}
if (verifyInstalled(skillDest, 'msd-core')) {
console.log(` ${green}✓${reset} Installed workflow assets`);
} else {
failures.push('msd-core');
}
// #2624: write the .msd-source marker. Extracted from its former late position so it can be
// called BEFORE staging reads the marker (see the call site below). Scoped to the Claude-global
// layout (issue #1477) — the only install path that ships the skills layout without a
// commands/msd source tree, so findInstallSourceRoot's walk-up has nothing to find and
// /msd-surface (list/status) throws without it. Points at the package's own commands/msd
// source. Guarded on source presence so a half-published package never writes a dangling
// marker. Write failure is non-fatal (install proceeds; warn so /msd-surface breakage is
// diagnosable) — the same contract the late write had.
function _writeMsdSourceMarker(runtime, targetDir, src, isGlobal) {
if (_hostBehaviors(runtime).sourceMarkerFile && isGlobal) {
const msdSourceCommands = path.join(src, 'commands', 'msd');
if (fs.existsSync(msdSourceCommands)) {
try {
// ADR-1239 Phase B write-confinement: the descriptor-sourced marker filename
// must resolve under targetDir (parity with the other descriptor-driven writes).
const _markerPath = assertDestWithinConfigHome(targetDir, _hostBehaviors(runtime).sourceMarkerFile);
if (hasExistingSymlinkBetween(path.resolve(targetDir), _markerPath, { allowOptInFollow: isSymlinkedDestOptIn() })) {
throw new Error(`compatibility marker "${_markerPath}" contains an untrusted symlink`);
}
fs.writeFileSync(_markerPath, msdSourceCommands + '\n', 'utf8');
} catch (err) {
// Non-fatal: install proceeds. But on the Claude-global layout walk-up
// also fails (no commands/msd source tree), so a silent write failure
// still leaves /msd-surface broken at runtime — warn so it's diagnosable.
console.warn(` ${yellow}!${reset} Could not write .msd-source marker (${err.message}); /msd-surface list/status may fail`);
}
}
}
}
// #2624: write the .msd-source marker BEFORE any staging reads it. The marker write
// formerly lived AFTER staging; on an upgrade the marker still held the PREVIOUS
// install's source path (e.g. an npx per-version cache dir that still exists on disk),
// so findInstallSourceRoot(configDir) — called inside installRuntimeArtifacts below —
// returned the stale path and every converted skill was generated from the OLD version's
// commands/msd, silently installing prior-version content with a self-consistent manifest
// hash. Writing first closes the read-before-write hole for every findInstallSourceRoot
// consumer (skills, commands, /msd-surface, capability-state). Placed here (before the
// _isSkillsRuntime branch) so it runs for every Claude-global install, matching the
// original write's sourceMarkerFile && isGlobal guard exactly.
_writeMsdSourceMarker(runtime, targetDir, src, isGlobal);
if (_isSkillsRuntime) {
// Layout-driven install for skills-based runtimes (full and minimal modes)
const scope = _installScopeId;
// Preserve an existing Runtime Surface selection during upgrades. The
// installer refreshes the source corpus first, then emits exactly the
// already-committed selection instead of widening it to the base profile.
const _committedSurface = readSurface(targetDir);
if (_committedSurface) {
_resolvedProfile = resolveSurface(
targetDir,
loadSkillsManifest(_commandsDir),
undefined,
_installedCapabilityRegistry,
_committedSurface,
);
}
// ADR-1239 upgrade 3 / #2088: a kind may declare an alternate install `home`
// (e.g. Codex skills -> $HOME/.agents/skills) instead of the runtime's normal
// configDir. Resolve the ACTUAL on-disk skills root here, descriptor-driven
// (no isCodex check), so downstream sidecar-cleanup and post-install
// verification look in the right place regardless of which runtime declares
// an alternate home for its skills kind.
const _skillsRootDir = _resolveSkillsRootDir(runtime, targetDir, scope);
// ADR-1239 / #2086: drive install through the public Host-Integration Interface
// (imperative adapter). The adapter delegates to the SAME installRuntimeArtifacts
// engine call -> byte-identical output (gated by golden-install-parity). Fail-open
// to the engine directly if the composed-registry adapter can't load.
const _adapter = _runtimeAdapter(runtime);
if (_adapter) {
_adapter.install({
configDir: targetDir,
scope,
resolvedProfile: _resolvedProfile,
resolveAttribution: getCommitAttribution,
});
} else {
// #2322: fallback path (adapter unavailable) — thread the composed
// registry too, so this path stages third-party capability skills
// identically to the primary adapter path above.
installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution, _installedCapabilityRegistry);
}
// #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
// msd-* skill dirs. Prior installs wrote these files so Codex would show a
// display name and description in the /skills TUI popup. Recent Codex builds
// index BOTH SKILL.md and the sidecar, causing each MSD skill to appear twice
// in autocomplete. Cleaning them up fixes the duplication; SKILL.md alone is
// sufficient for Codex discovery. User-owned dirs are never touched.
if (_hostBehaviors(runtime).cleanupSkillSidecars) {
cleanupCodexSkillMetadataSidecars(_skillsRootDir);
}
// ADR-1239 split-home migration: when a runtime's skills kind moved to an
// alternate `home` (e.g. Codex → ~/.agents/skills), pre-move installs left
// managed msd-* skill dirs at the old configDir-rooted location
// (~/.codex/skills). Reinstalling here writes the new location but would
// otherwise orphan the old one — clean up the stale msd-* dirs.
{
const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope);
if (_movedOldSkillsDir) {
const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'msd-');
if (migrated > 0) {
console.log(` ${green}✓${reset} Migrated ${migrated} skill dir(s) off the legacy ${_movedOldSkillsDir} location`);
}
}
}
// Verify installed artifacts and report
{
const skillsDir = _skillsRootDir;
if (fs.existsSync(skillsDir)) {
const count = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('msd-')).length;
if (count > 0) {
console.log(` ${green}✓${reset} Installed ${count} skills to skills/`);
} else {
failures.push('skills/msd-*');
}
} else {
failures.push('skills/msd-*');
}
}
} else {
// Claude Code local: flat msd-<cmd>.md layout — Claude Code registers
// commands from .claude/commands/ using the filename stem as the command
// name, so msd-<cmd>.md produces the /msd-<cmd> hyphen form used everywhere
// in the framework. The old commands/msd/<cmd>.md subdirectory layout caused
// Claude Code to namespace commands as /msd:<cmd> (colon form). (#1367)
const commandsDir = path.join(targetDir, 'commands');
fs.mkdirSync(commandsDir, { recursive: true });
const msdSrc = _stageSkills(_commandsDir);
const cmdNames = readMsdCommandNames();
// Remove stale msd-*.md files before writing new ones (clean install)
if (fs.existsSync(commandsDir)) {
for (const f of fs.readdirSync(commandsDir)) {
if (f.startsWith('msd-') && f.endsWith('.md')) {
fs.unlinkSync(path.join(commandsDir, f));
}
}
}
// Write each command as msd-<stem>.md (flat, hyphen-prefixed)
let cmdCount = 0;
if (fs.existsSync(msdSrc)) {
for (const entry of fs.readdirSync(msdSrc, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3);
let content = fs.readFileSync(path.join(msdSrc, entry.name), 'utf8');
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal, getCommitAttribution(runtime));
content = normalizeAgentBodyForRuntime(content, runtime, cmdNames);
fs.writeFileSync(path.join(commandsDir, `msd-${stem}.md`), content);
cmdCount++;
}
}
if (cmdCount > 0) {
console.log(` ${green}✓${reset} Installed ${cmdCount} commands to commands/ (msd-<cmd>.md flat form)`);
} else {
failures.push('commands/msd-*');
}
// Legacy cleanup: remove old commands/msd/ subdirectory from prior installs
// that used the namespaced layout (wrote bare-name files under commands/msd/).
const legacyMsdDir = path.join(commandsDir, 'msd');
if (fs.existsSync(legacyMsdDir)) {
// Stage user-owned dev-preferences.md DURABLY before wiping (#2875 /
// #1874-F19 "site 6" — this Claude commands-install path open-coded
// its own preserve/restore instead of calling preserveUserArtifacts,
// found by sweeping for the read-then-wipe-then-write PATTERN rather
// than for that helper's callers).
// #2875 defect fix: DEGRADE, never abort install, when the staging
// root cannot be resolved — skip this legacy-migration block entirely
// (leave the stale dir in place) rather than wipe without a durable
// backup.
const _legacyMsdStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
if (_legacyMsdStagingRoot !== null) {
const stagedDevPrefs = stageUserArtifacts(legacyMsdDir, ['dev-preferences.md'], _legacyMsdStagingRoot);
// Preserve the ORIGINAL truthy-content check exactly: an existing but
// EMPTY dev-preferences.md was (and still is) silently not migrated —
// matching prior behavior byte-for-byte rather than widening scope.
const preservedDevPrefs = stagedDevPrefs.names.includes('dev-preferences.md')
? fs.readFileSync(path.join(stagedDevPrefs.filesDir, 'dev-preferences.md'), 'utf8')
: null;
fs.rmSync(legacyMsdDir, { recursive: true });
console.log(` ${green}✓${reset} Removed legacy commands/msd/ (migrated to flat msd-<cmd>.md layout)`);
if (preservedDevPrefs) {
// Migrate dev-preferences to the new flat form — a RENAME on
// restore (staged as 'dev-preferences.md', restored as
// 'msd-dev-preferences.md'), not a round-trip.
restoreStagedUserArtifacts(commandsDir, stagedDevPrefs, { rename: { 'dev-preferences.md': 'msd-dev-preferences.md' } });
console.log(` ${green}✓${reset} Migrated dev-preferences.md to commands/msd-dev-preferences.md`);
}
discardStagedUserArtifacts(stagedDevPrefs);
}
}
// Clean up any stale skills/ from a previous local install
const staleSkillsDir = path.join(targetDir, 'skills');
if (fs.existsSync(staleSkillsDir)) {
const staleMsd = fs.readdirSync(staleSkillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('msd-'));
for (const e of staleMsd) {
fs.rmSync(path.join(staleSkillsDir, e.name), { recursive: true });
}
if (staleMsd.length > 0) {
console.log(` ${green}✓${reset} Removed ${staleMsd.length} stale MSD skill(s) from skills/`);
}
}
}
// #2624: the .msd-source marker is now written by _writeMsdSourceMarker()
// BEFORE staging reads it (see the early call above the _isSkillsRuntime
// block). The former write lived here — AFTER staging — which on an upgrade
// let staging read a stale prior-version marker and silently install
// old-version skill content. Moved up; this site intentionally left empty.
// Copy shared manifests into the msd-core payload
// at the co-located path that CJS modules resolve first:
// msd-core/bin/shared/*.json
//
// This source now lives under msd-core/bin/shared in-repo.
const sharedPayloadFiles = [
'model-catalog.json',
'config-defaults.manifest.json',
'config-schema.manifest.json',
'runtime-aliases.manifest.json',
];
for (const fileName of sharedPayloadFiles) {
const sharedSrc = path.join(src, 'msd-core', 'bin', 'shared', fileName);
const sharedDest = path.join(skillDest, 'bin', 'shared', fileName);
const displayPath = `msd-core/bin/shared/${fileName}`;
if (fs.existsSync(sharedSrc)) {
fs.mkdirSync(path.dirname(sharedDest), { recursive: true });
fs.copyFileSync(sharedSrc, sharedDest);
if (verifyFileInstalled(sharedDest, displayPath)) {
console.log(` ${green}✓${reset} Installed ${displayPath}`);
} else {
failures.push(displayPath);
}
} else {
failures.push(`msd-core/bin/shared/${fileName} (source missing)`);
}
}
// Agents directory materialization.
// #2875 Part 2 (the agents-bypass closure): EVERY runtime is now
// descriptor-driven for agents — installRuntimeArtifacts (called earlier in
// this function, the `_isSkillsRuntime` branch above) already wrote
// agents/ for any runtime whose capability.json declares an `agents` kind,
// via convertedAgentsKind/agentsKind (generic layout loop) or
// installAgentsKindStandalone (OpenCode's combinedFamilyInstall
// branch, called from within installOpencodeFamilyArtifacts) — both reuse
// the SAME stageAgentsForRuntimeWithConverter pipeline (path-rewrite →
// attribution → converter → frontmatter extensions → normalize) the
// inline loop this replaces used to hand-roll, and both prune stale msd-*
// entries via their own _removeMsdEntries pass BEFORE copying (broader
// than this loop's old extension-gated stale check — see
// runtime-artifact-layout.cts's convertedAgentsKind doc comment).
// Minimal-mode agent filtering is handled the SAME way it already was for
// the ten runtimes cut over before this change: via resolvedProfile.agents
// at staging time, not a separate branch here.
//
// `!_isSkillsRuntime` runtimes (claude-local's legacy-flat local path)
// never reach that loop at all — installAgentsKindStandalone is called
// here explicitly to cover them (install-engine.cts's own doc comment
// explains why; a regression here was caught by the install-tree golden
// fixture, tests/fixtures/install-tree/claude-local.json).
if (_isSkillsRuntime) {
console.log(` ${dim}↳${reset} Agents installed via descriptor-driven layout (${runtime})`);
} else {
const _standaloneProjectDir = isGlobal ? process.cwd() : targetDir;
const _standaloneAgentsResult = installAgentsKindStandalone(runtime, targetDir, _installScopeId, _resolvedProfile, pathPrefix, getCommitAttribution, _installedCapabilityRegistry, _standaloneProjectDir);
if (_standaloneAgentsResult) {
// #2875 defect fix: installAgentsKindStandalone now returns `null`
// (rather than a truthy result pointing at an empty destDir) whenever a
// restricted (non-'*') resolvedProfile — --minimal being the common
// case — legitimately stages ZERO agents, matching the pre-#2875-Part-2
// inline loop's behavior of never creating agentsDest under --minimal
// at all (see the deleted `isMinimalMode` branch). `destDir` is
// therefore guaranteed non-empty whenever we reach this branch, so a
// real staging failure still fails loudly via verifyInstalled below.
if (verifyInstalled(_standaloneAgentsResult.destDir, 'agents')) {
console.log(` ${green}✓${reset} Installed agents`);
} else {
failures.push('agents');
}
} else if (_resolvedProfile.skills !== '*') {
console.log(` ${dim}↳${reset} Skipping agents (${_resolvedProfile.name} profile excludes all agents — run \`msd update\` with a broader profile to add them)`);
} else {
console.log(` ${dim}↳${reset} No agents kind declared for ${runtime} at this scope`);
}
}
// Codex registers agents in `config.toml` via `[agents.msd-*]` sections —
// NOT agents-directory materialization (design doc "Deliberately not in
// scope"), so this stays independent of the agents/ write above. Without
// stripping these on a full → minimal reinstall, the runtime would keep
// advertising the old full agent surface even though the descriptor-driven
// write above already skipped writing the .md files for a minimal-tier
// resolvedProfile. Reuse the same helper that powers `--uninstall`.
if (isMinimalMode(_effectiveInstallMode) && _hostBehaviors(runtime).tomlConfigInstall) {
const codexConfigPath = path.join(targetDir, 'config.toml');
if (fs.existsSync(codexConfigPath)) {
const existing = fs.readFileSync(codexConfigPath, 'utf8');
const cleaned = stripMsdFromCodexConfig(existing);
if (cleaned === null) {
fs.unlinkSync(codexConfigPath);
} else if (cleaned !== existing) {
fs.writeFileSync(codexConfigPath, cleaned);
}
}
}
// agentsSrc is declared as `let` before the enclosing try block (not const)
// so it is accessible by installCodexConfig() in the Codex config section
// below — that function reads RAW source agents/*.md (not the
// descriptor-staged output above) to build Codex's per-agent config.toml
// sidecar files, a separate writer this migration deliberately does not
// touch (design doc: "Codex's config.toml [agents.msd-*] strip... is not
// agents-directory materialization").
agentsSrc = _stageAgents(path.join(src, 'agents'));
// Copy CHANGELOG.md
const changelogSrc = path.join(src, 'CHANGELOG.md');
const changelogDest = path.join(targetDir, 'msd-core', 'CHANGELOG.md');
if (fs.existsSync(changelogSrc)) {
fs.copyFileSync(changelogSrc, changelogDest);
if (verifyFileInstalled(changelogDest, 'CHANGELOG.md')) {
console.log(` ${green}✓${reset} Installed CHANGELOG.md`);
} else {
failures.push('CHANGELOG.md');
}
}
// Write VERSION file
const versionDest = path.join(targetDir, 'msd-core', 'VERSION');
fs.writeFileSync(versionDest, pkg.version);
if (verifyFileInstalled(versionDest, 'VERSION')) {
console.log(` ${green}✓${reset} Wrote VERSION (${pkg.version})`);
} else {
failures.push('VERSION');
}
// #2297: write a per-install runtime marker co-located with VERSION at
// <install>/msd-core/.msd-runtime. It gives resolveModelInternal a reliable
// "which runtime owns THIS install" signal in a no-project session (config.runtime
// is null and MSD_RUNTIME is not exported), so the shared ~/.msd/defaults.json
// resolve_model_ids:"omit" policy (written below for non-alias runtimes only)
// applies ONLY when a non-alias runtime is actually resolving — a Claude session
// reads its own marker and keeps its tier aliases instead of inheriting another
// runtime's install-order-dependent "omit". See src/model-resolver.cts.
const runtimeMarkerDest = path.join(targetDir, 'msd-core', '.msd-runtime');
fs.writeFileSync(runtimeMarkerDest, `${runtime}\n`);
if (verifyFileInstalled(runtimeMarkerDest, '.msd-runtime')) {
console.log(` ${green}✓${reset} Wrote runtime marker (.msd-runtime: ${runtime})`);
} else {
failures.push('.msd-runtime');
}
// Reusable: copy hooks/dist/ + hooks/lib/ into destRootDir, writing the
// CommonJS package.json marker alongside them. Used below for the generic
// configDir install path (guarded by hostBehaviors.skipSharedHooksInstall).
// Returns false when hooks/dist/ exists but failed to verify post-copy (a
// genuine failure the caller should surface); true otherwise (including
// when hooks/dist/ is absent from the package — nothing to verify).
function installSharedHooksBundle(destRootDir) {
// destRootDir already exists for the generic call site (targetDir — created
// earlier in install() by the skills/agents writes above). mkdirSync
// recursive is a safe no-op when the dir is already present.
fs.mkdirSync(destRootDir, { recursive: true });
// #3023: the bundle's directory NAME is descriptor-driven — a host that
// reserves `hooks/` must be able to opt out. Resolved once here so the
// stage / lib / marker sites can never disagree about where the bundle is.
// #2544: the CommonJS marker is NOT written here (destRootDir is the
// runtime's shared config root — user-writable territory on OpenCode,
// where it is the documented place to declare local-plugin npm
// dependencies). It is written into hooks/ below, the directory MSD
// creates and fills with its own .js scripts, once that directory exists.
let hooksOk = true;
// #2544: true once MSD has actually written into destRootDir/hooks/, which
// is what licenses the CommonJS marker below.
let stagedHooks = false;
// Copy hooks from dist/ (bundled with dependencies)
// Template paths for the target runtime (replaces '.claude' with correct config dir)
const hooksSrc = path.join(src, 'hooks', 'dist');
if (fs.existsSync(hooksSrc)) {
const hooksDest = path.join(destRootDir, SHARED_HOOKS_DIR);
fs.mkdirSync(hooksDest, { recursive: true });
const hookEntries = fs.readdirSync(hooksSrc);
if (hookEntries.some((e) => fs.statSync(path.join(hooksSrc, e)).isFile())) stagedHooks = true;
const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
for (const entry of hookEntries) {
const srcFile = path.join(hooksSrc, entry);
if (fs.statSync(srcFile).isFile()) {
const destFile = path.join(hooksDest, entry);
if (entry.endsWith('.js') || entry.endsWith('.cjs')) {
let content = fs.readFileSync(srcFile, 'utf8');
content = content.replace(/'\.claude'/g, configDirReplacement);
content = content.replace(/\/\.claude\//g, `/${getDirName(runtime)}/`);
content = content.replace(/\.claude\//g, `${getDirName(runtime)}/`);
// #376: rewrite msd: → msd- for hyphen-namespace runtimes
if (shouldNormalizeHyphenNamespaceInAgentBody(runtime)) {
content = content.replace(/msd:/gi, 'msd-');
}
content = content.replace(/\{\{MSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
} else {
// non-.js: .sh hooks need {{MSD_VERSION}} stamped; others are copied as-is
if (entry.endsWith('.sh')) {
let content = fs.readFileSync(srcFile, 'utf8');
content = content.replace(/\{\{MSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows doesn't support chmod */ }
} else {
fs.copyFileSync(srcFile, destFile);
}
}
} else if (fs.statSync(srcFile).isDirectory()) {
// #3579: recurse one level into hook subdirs (lib/ etc.). The
// graphify auto-update hook's rebuild helper lives at
// hooks/dist/lib/msd-graphify-rebuild.sh and must land at the
// mirrored target path so the hook's REBUILD_SCRIPT lookup resolves.
const subDest = path.join(hooksDest, entry);
fs.mkdirSync(subDest, { recursive: true });
const subEntries = fs.readdirSync(srcFile);
for (const subEntry of subEntries) {
const subSrcFile = path.join(srcFile, subEntry);
if (!fs.statSync(subSrcFile).isFile()) continue;
const subDestFile = path.join(subDest, subEntry);
if (subEntry.endsWith('.sh')) {
let content = fs.readFileSync(subSrcFile, 'utf8');
content = content.replace(/\{\{MSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(subDestFile, content);
try { fs.chmodSync(subDestFile, 0o755); } catch (e) { /* Windows */ }
} else {
fs.copyFileSync(subSrcFile, subDestFile);
}
}
}
}
if (verifyInstalled(hooksDest, 'hooks')) {
console.log(` ${green}✓${reset} Installed ${SHARED_HOOKS_DIR} (bundled)`);
// Warn if expected community .sh hooks are missing (non-fatal)
const expectedShHooks = ['msd-session-state.sh', 'msd-validate-commit.sh', 'msd-phase-boundary.sh', 'msd-graphify-update.sh'];
for (const sh of expectedShHooks) {
if (!fs.existsSync(path.join(hooksDest, sh))) {
console.warn(` ${yellow}⚠${reset} Missing expected hook: ${sh}`);
}
}
} else {
hooksOk = false;
}
}
// Gate hooks/lib/ install on the same set of runtimes that receive hooks/.
// Codex/Cursor do not use the shared hooks/lib/ helpers (Cursor uses
// standalone .js hook scripts registered via hooks.json — gated
// descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089;
// Codex uses hooks.json directly); ZCode also skips hooks entirely
// (hooksSurface:'none' with no plugin surface — #1821). None of the
// excluded runtimes must receive the hooks/lib/ helpers — otherwise the
// Codex comment downstream ("we deliberately do *not* copy hooks/lib/ for
// Codex") is contradicted in practice. (Gating lives at the call sites
// below; this helper itself only checks source presence.)
const hooksLibSrc = path.join(src, 'hooks', 'lib');
if (fs.existsSync(hooksLibSrc)) {
const hooksLibDest = path.join(destRootDir, SHARED_HOOKS_DIR, 'lib');
fs.mkdirSync(hooksLibDest, { recursive: true });
copyLibDir(hooksLibSrc, hooksLibDest, MSD_HOOK_LIB_FILES);
if (MSD_HOOK_LIB_FILES.some((f) => fs.existsSync(path.join(hooksLibDest, f)))) stagedHooks = true;
console.log(` ${green}✓${reset} Installed ${SHARED_HOOKS_DIR}/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
}
// #2544: pin the staged hook scripts to CommonJS from inside hooks/ — the
// directory MSD just created and filled — instead of from destRootDir.
// Scoping the marker to MSD's own directory keeps `require` working in
// hooks/*.js and hooks/lib/*.js under any ambient "type": "module", while
// leaving the shared config root untouched.
//
// Gated on `stagedHooks`, NOT on the directory merely existing: hooks/ is
// shared space, so an existence check would drop a MSD marker into a
// hooks/ directory the user created and MSD never wrote to — the same
// write-into-someone-else's-territory this issue is about. And never
// written over a package.json MSD does not own.
//
// ALSO gated on `hooksOk`: `stagedHooks` is computed from the SOURCE
// listing before the copy loop, so it stays true when the copies land but
// `verifyInstalled` then fails. Marking a hooks/ MSD did not successfully
// populate as CommonJS claims an ownership the install did not earn — the
// two flags answer different questions ("did we intend to fill it" vs "is
// it actually filled"), and the marker needs both.
const hooksMarkerDir = path.join(destRootDir, SHARED_HOOKS_DIR);
if (stagedHooks && hooksOk) {
switch (ensureCommonJsMarker(hooksMarkerDir)) {
case 'written':
console.log(` ${green}✓${reset} Wrote ${SHARED_HOOKS_DIR}/package.json (CommonJS mode)`);
break;
case 'preserved-foreign':
// #4759: the foreign file usually DOES declare "type": "commonjs" —
// any hand-written or formatter-touched package.json does — and Node
// then loads the staged .js hooks as CommonJS, so the old
// unconditional "may not resolve" claim was usually false. The
// sibling plugin path (src/install-engine.cts) words this same
// outcome conditionally; match it and keep will-not-load conditional
// on "type": "module", the only case where it is true.
console.warn(` ${yellow}⚠${reset} Left existing ${SHARED_HOOKS_DIR}/package.json untouched (not MSD's marker). If it declares "type": "module", the staged hooks will not load.`);
break;
case 'failed':
// Best-effort: a read-only or full config dir must not abort the
// install with a raw stack trace. The hooks themselves are staged.
console.warn(` ${yellow}⚠${reset} Could not write ${SHARED_HOOKS_DIR}/package.json (CommonJS mode) — install continued; MSD hooks may not resolve as CommonJS`);
break;
default:
break;
}
}
return hooksOk;
}
// #1821: ZCode declares hooksSurface:'none' AND has no plugin surface,
// so the staged hook scripts are dead weight for it — excluded here.
// OpenCode also declares hooksSurface:'none' but is deliberately NOT excluded:
// its native plugin adapter (#1914, installed above under plugins/msd-core.js)
// spawns the staged hooks/*.js scripts via OpenCode's event bus and needs both
// them and the CommonJS package.json marker written below.
// #2089: Cursor's exclusion is now descriptor-driven via
// hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor).
// #2101: ZCode's exclusion is likewise descriptor-driven (zcode declares
// skipSharedHooksInstall:true) — the redundant `&& !isZcode` was removed.
if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true) {
if (!installSharedHooksBundle(targetDir)) {
failures.push('hooks');
}
}
// Install scripts/changeset/ and scripts/lib/ into <configDir>/scripts/
// so that `node "$MSD_DIR/scripts/changeset/cli.cjs"` resolves at runtime.
//
// The changeset CLI (scripts/changeset/cli.cjs) is invoked by the update
// workflow (msd-core/workflows/update.md) to extract changelog ranges for
// the /msd-update preview step. It was previously only present in the npm
// tarball root but never copied to the runtime config dir, causing the
// preview to always silently fail (#935).
//
// cli.cjs requires:
// - sibling files in scripts/changeset/ (parse/render/serialize/github-release-notes)
// - ../lib/cli-exit.cjs → scripts/lib/cli-exit.cjs
// - ../../msd-core/bin/lib/semver-compare.cjs (already installed under msd-core/)
// - ../../msd-core/bin/lib/package-identity.cjs (already installed under msd-core/)
//
// All runtimes that use the update workflow need this, so we copy unconditionally
// (same scope as msd-core/ itself — every runtime that installs workflows gets it).
const changesetSrc = path.join(src, 'scripts', 'changeset');
const scriptsLibSrc = path.join(src, 'scripts', 'lib');
if (!fs.existsSync(changesetSrc)) {
// The changeset CLI source is missing from the package — mark as a hard failure
// so the user knows the changelog preview will not work rather than silently degrading.
failures.push('scripts/changeset/ (source missing from package — reinstall from npm)');
} else {
const changesetDest = path.join(targetDir, 'scripts', 'changeset');
const scriptsLibDest = path.join(targetDir, 'scripts', 'lib');
fs.mkdirSync(changesetDest, { recursive: true });
fs.mkdirSync(scriptsLibDest, { recursive: true });
// Copy scripts/changeset/ — all .cjs and .md files
for (const entry of fs.readdirSync(changesetSrc)) {
const srcFile = path.join(changesetSrc, entry);
if (fs.statSync(srcFile).isFile()) {
fs.copyFileSync(srcFile, path.join(changesetDest, entry));
}
}
// Copy scripts/lib/ — cli-exit.cjs (required by cli.cjs) and any future lib helpers.
// Hard-fail if missing: without cli-exit.cjs the installed CLI throws MODULE_NOT_FOUND.
if (!fs.existsSync(scriptsLibSrc)) {
failures.push('scripts/lib/ (source missing from package — reinstall from npm)');
} else {
for (const entry of fs.readdirSync(scriptsLibSrc)) {
const srcFile = path.join(scriptsLibSrc, entry);
if (fs.statSync(srcFile).isFile()) {
fs.copyFileSync(srcFile, path.join(scriptsLibDest, entry));
}
}
// Verify the critical dep cli-exit.cjs landed
if (!verifyFileInstalled(path.join(scriptsLibDest, 'cli-exit.cjs'), 'scripts/lib/cli-exit.cjs')) {
failures.push('scripts/lib/cli-exit.cjs');
}
}
if (verifyFileInstalled(path.join(changesetDest, 'cli.cjs'), 'scripts/changeset/cli.cjs')) {
console.log(` ${green}✓${reset} Installed scripts/changeset/ (changelog preview CLI)`);
} else {
failures.push('scripts/changeset/cli.cjs');
}
}
// Copy scripts/fix-slash-commands.cjs — required by msd-core/bin/lib/command-roster.cjs
// at load time via require('../../../scripts/fix-slash-commands.cjs'). Without this file
// every msd-tools command crashes with MODULE_NOT_FOUND (#1223).
// This copy is independent of scripts/changeset/ — it must land even when the
// changeset CLI source is absent.
{
const fixSlashSrc = path.join(src, 'scripts', 'fix-slash-commands.cjs');
const fixSlashDest = path.join(targetDir, 'scripts', 'fix-slash-commands.cjs');
fs.mkdirSync(path.join(targetDir, 'scripts'), { recursive: true });
if (!fs.existsSync(fixSlashSrc)) {
failures.push('scripts/fix-slash-commands.cjs (source missing from package — reinstall from npm)');
} else {
fs.copyFileSync(fixSlashSrc, fixSlashDest);
if (!verifyFileInstalled(fixSlashDest, 'scripts/fix-slash-commands.cjs')) {
failures.push('scripts/fix-slash-commands.cjs');
}
}
}
// Copy scripts/gen-capability-registry.cjs + scripts/gen-loop-host-contract.cjs —
// required by msd-core/bin/lib/capability-loader.cjs at overlay-composition time via
// require('../../../scripts/gen-capability-registry.cjs') (which itself requires
// gen-loop-host-contract.cjs). Without these, the loader's never-crash invariant
// discards EVERY third-party capability overlay and silently falls back to the frozen
// first-party registry, so installed capabilities are inert (#1920). Same class of
// gap as #1223 (fix-slash-commands.cjs) and copied unconditionally for the same reason:
// any runtime that installs msd-core/ needs the capability system to compose.
{
const capGenDestDir = path.join(targetDir, 'scripts');
fs.mkdirSync(capGenDestDir, { recursive: true });
for (const gen of ['gen-capability-registry.cjs', 'gen-loop-host-contract.cjs']) {
const genSrc = path.join(src, 'scripts', gen);
const genDest = path.join(capGenDestDir, gen);
if (!fs.existsSync(genSrc)) {
failures.push(`scripts/${gen} (source missing from package — reinstall from npm)`);
} else {
fs.copyFileSync(genSrc, genDest);
if (!verifyFileInstalled(genDest, `scripts/${gen}`)) {
failures.push(`scripts/${gen}`);
}
}
}
}
// Remove legacy get-shit-done-cc artifacts and stale update caches (#607).
// cleanupLegacyMsdCc handles both the legacy shared cache and the per-package
// cache (formerly an inline unlinkSync here). A cleanup failure must never
// abort a successful install — log a warning and continue.
// install() is never reached in --dry-run mode (the early-exit at the CLI
// dispatch handles preview), so cleanup here always applies for real.
//
// #3799: when --config-dir redirected the install, the scan is SCOPED to
// that destination ([targetDir]) — the default home's live install must
// never be planned for removal from a sandboxed install. --no-legacy-cleanup
// skips the scan entirely.
const skipNoLegacyCleanup = parseNoLegacyCleanupArg();
const legacyCleanupScope = (explicitConfigDir !== null && isGlobal)
? [targetDir]
: undefined;
if (!skipNoLegacyCleanup) {
try {
cleanupLegacyMsdCc({ dryRun: false, ...(legacyCleanupScope ? { configDirs: legacyCleanupScope } : {}) });
} catch (cleanupErr) {
console.warn(` ${yellow}Warning: legacy cleanup failed: ${cleanupErr.message}${reset}`);
}
}
if (failures.length > 0) {
console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
process.exit(1);
}
// Write file manifest for future modification detection
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
// Report any backed-up local patches
reportLocalPatches(targetDir, runtime);
// #2873: cross-scope shadow report. Fires ONCE per install (this is the
// only writeManifest call site that gets it — the other four sites are
// sub-writes within a single install, not separate installs). A shadowed
// install is a warning, never a failure (ADR-2866 Consequences), so this
// never touches `failures` or `process.exit`, and the whole block is
// wrapped in a try/catch that swallows everything: a report failure must
// never fail an otherwise-successful install (design row C5). No options
// are injected into buildShadowReport — this is the production call shape,
// resolving the real machine via os.homedir()/process.cwd() defaults
// inside the resolver.
try {
const shadowReport = buildShadowReport(runtime);
const shadowLines = renderShadowReport(shadowReport);
if (shadowLines.length > 0) {
console.warn(`\n ${yellow}⚠${reset} ${shadowLines[0]}`);
for (const line of shadowLines.slice(1)) {
console.warn(` ${dim}${line}${reset}`);
}
}
} catch (_shadowReportErr) {
// Never fail an install over a reporting concern — see comment above.
}
// Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped)
if (!_hostBehaviors(runtime).ownsClaudePaths) {
const leakedPaths = [];
// Only scan files that were written by this install (manifest-tracked).
// Scanning the entire targetDir can match user-authored content that
// legitimately references ~/.claude (e.g. personal notes), producing
// false-positive warnings. Restricting to the manifest avoids that.
let manifestFiles = null;
try {
const manifestPath = path.join(targetDir, MANIFEST_NAME);
if (fs.existsSync(manifestPath)) {
const manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifestData && typeof manifestData.files === 'object') {
manifestFiles = Object.keys(manifestData.files);
}
}
} catch (_manifestParseErr) {
// If we cannot read/parse the manifest, skip the scan entirely to
// avoid false positives rather than falling back to a full directory walk.
manifestFiles = null;
}
if (manifestFiles !== null) {
// #4667: codex-installed artifacts must not keep `@~/.claude/msd-core/…`
// include references — the `@` form resolves into the CLAUDE install
// (wrong copy on dual-runtime machines at divergent versions, nothing at
// all on codex-only ones; #570 cause 2 residue). Every target ships in
// the codex install, so rewriting the `@~/` include form to the codex
// root is mechanical and correct. This runs after all .md emitters
// (several bypass the per-runtime converters — that is how the leak
// survived the per-emitter fixes; the agent .tomls are generated later
// and prefix themselves), and before the scan below, which stays as the
// verification backstop. The `_MSD_RUNTIME_ROOT`/`$PREFERRED_CONFIG_DIR`
// fallback chains and prose `.claude` mentions carry no `@~/` prefix and
// are deliberately untouched, as is CHANGELOG.md.
if (runtime === 'codex') {
for (const relPath of manifestFiles) {
const fileName = path.basename(relPath);
if (!(fileName.endsWith('.md') || fileName.endsWith('.toml'))) continue;
if (fileName === 'CHANGELOG.md') continue;
const rewritePath = path.join(targetDir, relPath);
let rewriteContent;
try {
rewriteContent = fs.readFileSync(rewritePath, 'utf8');
} catch (rewriteErr) {
continue; // inaccessible or missing — the scan below reports or skips it
}
const rewritten = rewriteContent
.split('@~/.claude/msd-core/').join('@~/.codex/msd-core/')
.split('@$HOME/.claude/msd-core/').join('@$HOME/.codex/msd-core/');
if (rewritten !== rewriteContent) {
try {
fs.writeFileSync(rewritePath, rewritten);
} catch (writeErr) {
continue; // never fail the install over the rewrite; the scan still warns
}
}
}
}
for (const relPath of manifestFiles) {
const fileName = path.basename(relPath);
if (!(fileName.endsWith('.md') || fileName.endsWith('.toml'))) continue;
if (fileName === 'CHANGELOG.md') continue;
const fullPath = path.join(targetDir, relPath);
let content;
try {
content = fs.readFileSync(fullPath, 'utf8');
} catch (err) {
if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ENOENT') {
continue; // skip inaccessible or missing files
}
throw err;
}
const matches = content.match(/(?:~|\$HOME)\/\.claude\b/g);
if (matches) {
leakedPaths.push({ file: relPath, count: matches.length });
}
}
}
if (leakedPaths.length > 0) {
const totalLeaks = leakedPaths.reduce((sum, l) => sum + l.count, 0);
console.warn(`\n ${yellow}⚠${reset} Found ${totalLeaks} unreplaced .claude path reference(s) in ${leakedPaths.length} file(s):`);
for (const leak of leakedPaths.slice(0, 5)) {
console.warn(` ${dim}${leak.file}${reset} (${leak.count})`);
}
if (leakedPaths.length > 5) {
console.warn(` ${dim}... and ${leakedPaths.length - 5} more file(s)${reset}`);
}
console.warn(` ${dim}These paths may not resolve correctly for ${runtimeLabel}.${reset}`);
}
}
} catch (_earlyInstallErr) {
// Installer Migration Module Phase 4: docs/installer-migrations.md
// requires safe migrations to run before package materialization without
// leaving stale state behind when materialization fails. Roll migration
// actions back for every runtime; Codex then layers its broader runtime
// snapshot rollback on top.
rollbackInstallerMigrations();
// #3245 CR finding 2 — any throw in the pre-config install operations (skills copy,
// agents copy, VERSION write, manifest write, etc.) triggers the Codex pre-config
// rollback so the caller is never left in a partially-installed state.
// (The second, identical rollbackInstallerMigrations() that used to sit here was
// a duplicate of the line above, not a second phase — removed in #3725 review.)
// #3712 — the test-home guard refuses before any LAYOUT-DRIVEN write, so no
// msd-* directory in the skills root has been touched and there is nothing
// there to undo. (Legacy install migrations DO run first; that is why the
// rollbackInstallerMigrations() calls above still execute, and why the one
// migration that can reach a `home` override carries its own assertion.)
// Running the codex rollback anyway would delete and recreate every
// snapshotted msd-* directory in the resolved skills root, which for an
// un-sandboxed codex install IS the real ~/.agents/skills: the guard's own
// refusal would provoke the mutation it exists to prevent. This is the only
// _codexPreConfigRollback() call site, and applySurface/uninstall cannot
// reach it. Every other error still rolls back. Found by review, not by CI.
if (isTestHomeGuardRefusal(_earlyInstallErr)) throw _earlyInstallErr;
if (_codexPreConfigRollback) {
_codexPreConfigRollback();
}
throw _earlyInstallErr;
}
// #2695: this branch runs for BOTH `core` (minimal) and `full` profiles.
// Hooks are lightweight infrastructure (update-check + context monitor), not the
// "full agent surface" that `core` deliberately omits. The config.toml / agent
// generation below is still gated by its own inner `!isMinimalMode` guard, so
// `core` enters the branch to receive the hook-file copy + hooks.json wiring but
// does NOT get agent roles generated. Before #2695 the outer `!isMinimalMode`
// here skipped the whole branch for `core`, so the registered parent hook pointed
// at a worker/registry the same installer never delivered.
if (plan.installSurface === 'codex-toml') {
// Capture pre-install snapshots before ANY MSD mutation
// (#2760 fix 3). On post-write schema-validation failure OR any throw
// during the mutation sequence (write failure, merge throw, etc.) we
// restore these exact bytes so the user is never left with a broken
// Codex CLI (#2760 fix 4 — extends snapshot coverage to write-failure
// paths, paired with atomic temp-file writes in mergeCodexConfig and
// the final hooks-write below).
const codexConfigPathPreInstall = path.join(targetDir, 'config.toml');
const codexConfigPreInstallSnapshot = fs.existsSync(codexConfigPathPreInstall)
? fs.readFileSync(codexConfigPathPreInstall)
: null;
const codexHooksJsonPathPreInstall = path.join(targetDir, 'hooks.json');
const codexHooksJsonPreInstallSnapshot = fs.existsSync(codexHooksJsonPathPreInstall)
? fs.readFileSync(codexHooksJsonPathPreInstall)
: null;
const migrationTouchesHooksJson =
!!(installerMigrationResult
&& installerMigrationResult.plan
&& Array.isArray(installerMigrationResult.plan.actions)
&& installerMigrationResult.plan.actions.some((action) => action && action.relPath === 'hooks.json'));
// #3245 — unified idempotent rollback. Reverts ALL Codex-specific mutations:
// config.toml — restore pre-install bytes (or remove if was absent)
// hooks.json — restore pre-install bytes (or remove if was absent)
// skills/msd-* — restore pre-existing dirs from content snapshot; remove
// newly-created dirs (i.e. those not in the pre-install Set)
// agents/msd-* — restore pre-existing files from content snapshot; remove
// newly-created files
// msd-core/VERSION — restore or remove
// *.tmp-* — best-effort cleanup of installer-owned atomic-write temps
//
// Safe to call multiple times (idempotent): each remove/write is guarded by
// existence checks. Safe to call before any snapshots are captured (variables
// default to empty Set / null). Does NOT touch non-msd-* user content.
const restoreCodexSnapshot = () => {
rollbackInstallerMigrations();
// 1. config.toml
if (codexConfigPreInstallSnapshot !== null) {
try { fs.writeFileSync(codexConfigPathPreInstall, codexConfigPreInstallSnapshot); }
catch (_) { /* best-effort restore — surface the original error */ }
} else if (fs.existsSync(codexConfigPathPreInstall)) {
try { fs.rmSync(codexConfigPathPreInstall); } catch (_) { /* best-effort */ }
}
// 1b. hooks.json
// If installer migrations touched hooks.json, rollbackInstallerMigrations()
// already restored the pre-migration file. Don't overwrite that state with
// a post-migration snapshot.
if (!migrationTouchesHooksJson) {
if (codexHooksJsonPreInstallSnapshot !== null) {
try { fs.writeFileSync(codexHooksJsonPathPreInstall, codexHooksJsonPreInstallSnapshot); }
catch (_) { /* best-effort restore — surface the original error */ }
} else if (fs.existsSync(codexHooksJsonPathPreInstall)) {
try { fs.rmSync(codexHooksJsonPathPreInstall); } catch (_) { /* best-effort */ }
}
}
// 2. skills/msd-*
// • Dirs that pre-existed: wipe current contents, restore snapshotted files.
// The restore iterates the SNAPSHOT manifest (codexPreInstallSkillNames) rather
// than just the current filesystem so that dirs deleted during the install
// (copyCommandsAsCodexSkills removes pre-existing msd-* dirs before re-writing)
// are restored even when they are absent from disk at rollback time (#3245 CR).
// • Dirs that did not pre-exist: remove entirely.
const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
// Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
for (const skillName of codexPreInstallSkillNames) {
const skillDirPath = path.join(_rollbackSkillsDir, skillName);
const fileMap = codexPreInstallSkillContents.get(skillName);
try {
fs.rmSync(skillDirPath, { recursive: true, force: true });
fs.mkdirSync(skillDirPath, { recursive: true });
if (fileMap) {
for (const [relPath, buf] of fileMap) {
const destFile = path.join(skillDirPath, relPath);
try {
fs.mkdirSync(path.dirname(destFile), { recursive: true });
fs.writeFileSync(destFile, buf);
} catch (_) { /* best-effort file restore */ }
}
}
} catch (_) { /* best-effort dir restore */ }
}
// Pass 2 — remove any newly-created msd-* dirs (not in the pre-install snapshot).
if (fs.existsSync(_rollbackSkillsDir)) {
try {
for (const entry of fs.readdirSync(_rollbackSkillsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('msd-')) continue;
if (!codexPreInstallSkillNames.has(entry.name)) {
// New dir written this session: remove entirely.
try { fs.rmSync(path.join(_rollbackSkillsDir, entry.name), { recursive: true, force: true }); }
catch (_) { /* best-effort */ }
}
}
} catch (_) { /* best-effort */ }
}
// 3. agents/msd-*.{md,toml}
// • Files that pre-existed: restore bytes from content snapshot.
// Iterates the SNAPSHOT manifest (codexPreInstallAgentFiles) so that files
// deleted by the pre-copy stale-removal pass (lines 7862-7870) are restored
// even when absent from disk at rollback time (#3245 CR).
// • Files that did not pre-exist: remove.
const _rollbackAgentsDir = path.join(targetDir, 'agents');
// Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
for (const file of codexPreInstallAgentFiles) {
const buf = codexPreInstallAgentContents.get(file);
if (buf !== undefined) {
try {
fs.mkdirSync(_rollbackAgentsDir, { recursive: true });
fs.writeFileSync(path.join(_rollbackAgentsDir, file), buf);
} catch (_) { /* best-effort */ }
}
}
// Pass 2 — remove any newly-created msd-* agent files (not in the pre-install snapshot).
if (fs.existsSync(_rollbackAgentsDir)) {
try {
for (const file of fs.readdirSync(_rollbackAgentsDir)) {
if (!file.startsWith('msd-') || (!file.endsWith('.md') && !file.endsWith('.toml'))) continue;
if (!codexPreInstallAgentFiles.has(file)) {
// New file written this session: remove.
try { fs.unlinkSync(path.join(_rollbackAgentsDir, file)); } catch (_) { /* best-effort */ }
}
}
} catch (_) { /* best-effort */ }
}
// 4. msd-core/VERSION
const _rollbackVersionPath = path.join(targetDir, 'msd-core', 'VERSION');
if (codexPreInstallVersionBytes !== null) {
try { fs.writeFileSync(_rollbackVersionPath, codexPreInstallVersionBytes); }
catch (_) { /* best-effort */ }
} else if (fs.existsSync(_rollbackVersionPath)) {
try { fs.unlinkSync(_rollbackVersionPath); } catch (_) { /* best-effort */ }
}
// 4b. #4544 — manifest-driven surfaces: the staged hooks/ tree, every
// MSD-owned path the prior manifest recorded (scripts/, msd-core/
// payload), and the prior manifest file itself.
restoreCodexManagedSnapshot();
// 5. Orphaned atomic-write temp files (<file>.tmp-<pid>-<n>) in targetDir.
// These can accumulate if an atomic write fails mid-rename. Best-effort scan.
//
// Only delete temp files whose absolute path is in __atomicWrittenTmps —
// the Set populated by atomicWriteFileSync for every temp this installer
// process actually created. This scopes cleanup to installer-owned writes
// and avoids clobbering unrelated tools' temp files that happen to match
// the same *.tmp-<pid>-<n> suffix pattern.
const _tmpPattern = /\.tmp-\d+-\d+$/;
function _cleanTmpFiles(dir) {
if (!fs.existsSync(dir)) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
_cleanTmpFiles(full);
} else if (_tmpPattern.test(entry.name) && __atomicWrittenTmps.has(full)) {
try { fs.unlinkSync(full); } catch (_) { /* best-effort */ }
}
}
}
_cleanTmpFiles(targetDir);
};
let agentCount = 0;
if (!isMinimalMode(_effectiveInstallMode)) {
// #2834: write ~/.msd/defaults.json (resolve_model_ids + runtime) BEFORE generating
// agent TOMLs — installCodexConfig reads defaults.json at generation time, so on a
// clean first install the runtime-aware model resolver must already know the runtime.
writeNonClaudeDefaults(runtime);
try {
// Generate Codex config.toml and per-agent .toml files.
agentCount = installCodexConfig(targetDir, agentsSrc, plan.sandboxTier);
} catch (e) {
restoreCodexSnapshot();
throw e;
}
console.log(` ${green}✓${reset} Generated config.toml with ${agentCount} agent roles`);
console.log(` ${green}✓${reset} Generated ${agentCount} agent .toml config files`);
// Re-write the manifest now that .toml agent files exist on disk.
// The initial writeManifest call (before Codex config generation) could
// not include agents/msd-*.toml because those files did not yet exist.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
} else {
console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
}
// Copy only the hook files that Codex actually registers via its hook configuration (#2153).
// #772: added msd-context-monitor.js for the new SubagentStart/Stop/PostToolUse events.
// #2695: the parent msd-check-update.js spawn()s msd-check-update-worker.js, which
// require()s managed-hooks-registry.cjs for MANAGED_HOOKS — so all four must be
// installed/refreshed together for every profile, or Codex is wired to a dependency
// chain the same installer never delivers.
// We deliberately do *not* copy msd-graphify-update.sh for Codex in this
// change (graphify auto-update support for Codex is out of scope for #3579).
// hooks/lib/ WAS excluded here for the same reason, and that stopped being
// correct when #3911 (2ea5efc15) gave msd-context-monitor.js a real
// `require('./lib/hook-exit.js')`: the allowlist below is flat and never
// recursed, so the hook shipped without its helper and died with
// MODULE_NOT_FOUND at load, before its own try/catch, on every registered
// event (#4087, #4098). The libs are now derived from what the staged
// scripts actually require rather than hand-listed — see the
// stageTransitiveHookLibs call after the copy loop. The #3579 boundary is
// preserved: helpers no staged Codex hook requires (graphify tooling among
// them) are still not shipped.
// #2586: msd-context-monitor.js is deliberately NOT copied for Codex.
// It reads the statusline bridge file (${TMPDIR}/claude-ctx-{session_id}.json)
// written only by hooks/msd-statusline.js, which Codex never installs — so
// every registered event was a guaranteed silent no-op (readSentinel throws
// ENOENT -> allow(undefined), every invocation, every event, no exceptions).
// A pre-#2586 install's stale copy + hooks.json registrations are cleaned
// up below (see the CODEX_EXTENDED_HOOK_EVENTS loop), not re-added here.
// CODEX_HOOKS_TO_COPY itself lives at module scope (#4544) — the rollback's
// incomplete-capture path must name the same set without a second literal.
const codexHooksSrc = path.join(src, 'hooks', 'dist');
if (fs.existsSync(codexHooksSrc)) {
const codexHooksDest = path.join(targetDir, 'hooks');
fs.mkdirSync(codexHooksDest, { recursive: true });
const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
// #2544: track whether anything was actually staged. hooks/dist existing
// is not the same as an allowlisted file landing in it — see the marker
// gate below.
let codexStagedHooks = false;
// The entries THIS invocation actually staged. Seeding the lib scan from
// `existsSync` over the destination instead would also pick up a file
// left by a PREVIOUS install whose source is no longer staged — e.g. a
// name dropped from the allowlist — and derive helpers for a hook that is
// no longer shipped (review of #4087).
const codexStagedEntries = [];
for (const entry of fs.readdirSync(codexHooksSrc)) {
if (!CODEX_HOOKS_TO_COPY.includes(entry)) continue;
const srcFile = path.join(codexHooksSrc, entry);
if (!fs.statSync(srcFile).isFile()) continue;
const destFile = path.join(codexHooksDest, entry);
if (entry.endsWith('.js')) {
let content = fs.readFileSync(srcFile, 'utf8');
content = content.replace(/'\.claude'/g, configDirReplacement);
content = content.replace(/\/\.claude\//g, `/${getDirName(runtime)}/`);
content = content.replace(/\.claude\//g, `${getDirName(runtime)}/`);
content = content.replace(/\{\{MSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
} else if (entry.endsWith('.sh')) {
// #2136: any .sh hook reaching this loop must have {{MSD_VERSION}}
// stamped so installed scripts carry a concrete version header and
// stale-hook detection keeps working across upgrades. The current
// CODEX_HOOKS_TO_COPY allowlist excludes .sh files, so this branch
// is defensive — it preserves the invariant if the allowlist is
// extended later (e.g. to ship msd-graphify-update.sh for Codex).
let content = fs.readFileSync(srcFile, 'utf8');
content = content.replace(/\{\{MSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
} else {
// #2695: raw byte-for-byte copy for allowlisted artifacts that carry
// no {{MSD_VERSION}} placeholder and no runtime path token (e.g.
// managed-hooks-registry.cjs, whose only `.claude` mention is inside a
// doc comment). Version/path transforms would be a no-op at best and a
// surprise at worst; the issue requires the registry copied verbatim.
fs.copyFileSync(srcFile, destFile);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
}
codexStagedHooks = true;
codexStagedEntries.push(entry);
}
// Stage the hooks/lib/ helpers the staged scripts require, transitively
// (#4087, #4098). Shares writeCursorHooksJson's walker rather than a
// second copy: both reduced bundles hand-pick SCRIPTS, and the identical
// MODULE_NOT_FOUND was already fixed once for Cursor in 704859e9c. A flat
// list of today's three helpers would re-break the next time a
// Codex-bundled hook grows a lib dependency, which is exactly how this
// regressed. Gated on codexStagedHooks for the same reason the CommonJS
// marker below is: hooks/ is shared space, and staging nothing must not
// leave a MSD-owned lib/ behind in a directory MSD created but did not
// fill (#2544). Seeded from the copies staged by THIS invocation, so the
// scan sees the same bytes Node will load and never derives helpers for a
// hook left behind by an earlier install.
let codexStagedLibs = [];
if (codexStagedHooks) {
codexStagedLibs = hooksSurface.stageTransitiveHookLibs({
seedSources: codexStagedEntries
.map((entry) => fs.readFileSync(path.join(codexHooksDest, entry), 'utf8')),
srcLibDir: path.join(codexHooksSrc, 'lib'),
destLibDir: path.join(codexHooksDest, 'lib'),
runtimeLabel: 'Codex',
// Same substitutions the .js branch above applies to hook scripts, so
// a helper that ever gains a runtime path or version token is
// rewritten identically instead of shipping a Claude-shaped path.
// No-ops on today's helpers, which carry neither.
transform: (content) => content
.replace(/'\.claude'/g, configDirReplacement)
.replace(/\/\.claude\//g, `/${getDirName(runtime)}/`)
.replace(/\.claude\//g, `${getDirName(runtime)}/`)
.replace(/\{\{MSD_VERSION\}\}/g, pkg.version),
});
}
console.log(` ${green}✓${reset} Installed hooks (Codex)`);
if (codexStagedLibs.length > 0) {
console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (${codexStagedLibs.join(', ')})`);
}
// #2717: write the CommonJS marker into hooks/ alongside the staged .js
// scripts. Codex is excluded from installSharedHooksBundle by the
// !isCodex gate, so it never received the marker the shared-bundle path
// writes for the other runtimes. Without it, a ~/.codex/package.json
// declaring {"type":"module"} makes Node load msd-check-update.js /
// msd-context-monitor.js as ESM and their require() calls fail silently.
// Reuses the same helper the Cursor writer calls so the marker
// content + user-file-preservation contract is identical everywhere.
//
// #2544: gated on codexStagedHooks, mirroring installSharedHooksBundle's
// `stagedHooks`. The enclosing guard only proves hooks/dist EXISTS; if it
// holds none of CODEX_HOOKS_TO_COPY, this block mkdirs hooks/ and stages
// nothing, and an ungated marker would claim a directory MSD did not fill.
if (codexStagedHooks && hooksSurface.ensureCommonJsMarker(codexHooksDest)) {
console.log(` ${green}✓${reset} Wrote hooks/package.json (CommonJS mode)`);
}
}
// Add Codex hooks (SessionStart for update checking) — requires codex_hooks feature flag
const configPath = path.join(targetDir, 'config.toml');
// Use the pre-install snapshot captured before installCodexConfig ran so
// restore returns the file to its true pre-MSD state on validation
// failure (#2760 fix 3) — not to the post-agent-merge state.
const preWriteBackup = codexConfigPreInstallSnapshot;
try {
let configContent = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : '';
const eol = detectLineEnding(configContent);
// Strip ALL prior MSD-managed hook blocks BEFORE migration so the migration
// only touches user-authored hooks, not MSD-owned stale entries. Running
// strip after migration causes Shape 1 (legacy msd-update-check filename)
// to be converted by migration before the strip regex can match it (#2698).
//
// Historical shapes stripped, in order:
// Shape 1 — legacy msd-update-check filename (pre-#1755): flat [[hooks]] + event
// Shape 2 — flat [[hooks]] + event = "SessionStart" (#2637 era, never correct)
// Shape 4 — correct two-block nested (strip before shape 3 to avoid orphaned header)
// Shape 3 — single-block [[hooks.SessionStart]] without nested .hooks (#2760 era)
configContent = stripStaleMsdHookBlocks(configContent);
// Migrate legacy [hooks] map format and flat [[hooks]] AoT entries to the
// namespaced [[hooks.<EVENT>]] form after stripping MSD-managed stale blocks.
// Running migration after strip ensures only user-authored hooks are migrated
// (#2698 regression: migration before strip converts stale MSD blocks before
// the strip regexes can match their original shape).
const migratedContent = migrateCodexHooksMapFormat(configContent);
if (migratedContent !== configContent) {
configContent = migratedContent;
console.log(` ${green}✓${reset} Migrated legacy Codex [hooks] format to two-level nested AoT`);
}
const codexHooksFeature = ensureCodexHooksFeature(configContent);
configContent = setManagedCodexHooksOwnership(codexHooksFeature.content, codexHooksFeature.ownership);
// MSD-managed Codex hook payloads now live in hooks.json to avoid mixed
// representation warnings when a single layer contains both hooks.json
// and inline [hooks] entries. Keep config.toml focused on feature flags
// and agent metadata.
const codexNodeRunner = resolveNodeRunner();
// #2760 fix 3 — post-write schema validation. Parse the bytes we are
// about to commit and assert they match Codex's expected shape. If
// validation fails we restore the pre-install backup and abort so the
// user is never left with a Codex CLI that won't load.
// Test seam: tests can inject `__codexSchemaValidator` to force the
// validator to fail and exercise the restore-and-abort path.
const validatorFn = (typeof module !== 'undefined' && module.exports && module.exports.__codexSchemaValidator)
? module.exports.__codexSchemaValidator
: validateCodexConfigSchema;
const validation = validatorFn(configContent);
if (!validation.ok) {
restoreCodexSnapshot();
throw new Error(
`post-write Codex schema validation failed: ${validation.reason}. ` +
`Restored ${preWriteBackup !== null ? 'pre-install backup' : 'empty state'}.`
);
}
// Atomic write (#2760 fix 4) — write to a sibling temp file, then
// renameSync over the target. A mid-write failure cannot truncate the
// existing config; the snapshot restore below is a second line of
// defense if even the rename fails.
try {
atomicWriteFileSync(configPath, configContent, 'utf-8');
} catch (writeErr) {
// #2760 CR4 finding 1 — write failure must be loud and fatal. Wrap
// with a `post-write` prefix the outer catch recognises so install
// aborts with a clear error rather than warn-and-continue (which
// produced "Done!" with no Codex agents configured).
restoreCodexSnapshot();
const wrapped = new Error(
`post-write Codex install failed: ${writeErr && writeErr.message ? writeErr.message : String(writeErr)}. ` +
`Restored ${preWriteBackup !== null ? 'pre-install backup' : 'empty state'}.`
);
throw wrapped;
}
if (hasEnabledCodexHooksFeature(configContent)) {
const checkUpdateFile = path.join(targetDir, 'hooks', 'msd-check-update.js');
if (!fs.existsSync(checkUpdateFile)) {
console.warn(` ${yellow}⚠${reset} Skipped Codex SessionStart hook registration — msd-check-update.js not found at target`);
} else if (!codexNodeRunner) {
console.warn(` ${yellow}⚠${reset} Skipping Codex SessionStart hook registration — Node executable path unavailable (process.execPath is empty). See #2979 / #3002 / #3017.`);
} else {
const hookWrite = ensureCodexHooksJsonSessionStart(targetDir, {
absoluteRunner: codexNodeRunner,
platform: process.platform,
});
configuredEntrypoints.push(...(hookWrite.configuredEntrypoints || []));
if (hookWrite.wrote) {
console.log(` ${green}✓${reset} Configured Codex hooks (SessionStart via hooks.json)`);
} else {
console.log(` ${green}✓${reset} Verified Codex hooks (SessionStart via hooks.json)`);
}
}
// #2586: Codex's hook payload carries no context/token-usage field
// (confirmed against codex-rs/hooks/src/schema.rs), so agent-facing
// context warnings and MSD phase/lifecycle display cannot be
// supported on this runtime — state that plainly during install
// rather than silently omitting the capability. Matches
// capabilities/codex/capability.json's hostBehaviors.unsupportedFeatures.
console.log(` ${dim}↳${reset} Codex: agent-facing context warnings and MSD phase/lifecycle display are unsupported (Codex's hook payload has no context-usage metric)`);
// ── Codex extended hook events (#772, #2088) — REMOVED by #2586 ──────
// msd-context-monitor.js is no longer copied or registered for Codex
// (see the CODEX_HOOKS_TO_COPY comment above): every one of these
// events was a guaranteed silent no-op, since the metrics bridge file
// it reads is only ever written by Claude's own statusline hook.
// Every event in CODEX_EXTENDED_HOOK_EVENTS is unconditionally
// reconciled here — not gated on the script existing — so a
// pre-#2586 install's stale registrations (exact current shape, or a
// recognized legacy shape via isManagedHookCommand's
// includeLegacyAliases) are stripped on reinstall. Mirrors the
// unconditional uninstall-time loop over the same constant. A
// registration whose command does not match the managed shape (a
// hand-customized entry) survives untouched — see
// reconcileCodexHooksJsonEvent's isManagedHookCommand filter.
for (const codexEvent of CODEX_EXTENDED_HOOK_EVENTS) {
const eventCleanup = removeCodexHooksJsonEvent(targetDir, codexEvent);
if (eventCleanup.changed) {
console.log(` ${green}✓${reset} Removed stale Codex ${codexEvent} context-monitor hook from hooks.json`);
}
}
// Delete the orphaned script (+ Windows .cmd shim) left by a
// pre-#2586 install, but ONLY once no surviving hooks.json
// registration under any event still references it, and only when
// the on-disk file is MSD's own (see design doc's Ownership check —
// a content-signature check, not manifest membership, so this works
// on the very first reinstall after upgrading, with no bootstrap
// gap). A deletion failure never reverts the (already safe,
// already-written) hooks.json cleanup above — must-have #8.
const monitorCleanup = hooksSurface.cleanupOrphanedCodexContextMonitorScript(targetDir);
for (const deletedPath of monitorCleanup.deleted) {
console.log(` ${green}✓${reset} Removed orphaned Codex hook script (${path.basename(deletedPath)})`);
}
for (const warning of monitorCleanup.warnings) {
console.warn(` ${yellow}⚠${reset} Could not remove orphaned Codex hook script ${warning.path}: ${warning.reason}`);
}
// ── end Codex extended hook events ────────────────────────────────────
}
} catch (e) {
// #2760 — schema-validation and write failures must be loud and fatal
// so the user is never left with a config Codex refuses to load (or no
// Codex agents configured at all). The pre-install snapshot restore has
// already run for write-side throws via the inner catch above and via
// restoreCodexSnapshot in the validation branch.
if (e && typeof e.message === 'string' && e.message.startsWith('post-write')) {
console.error(` ${red}✗${reset} ${e.message}`);
throw e;
}
// #2760 CR5 finding 1 — pre-write failures (migrateCodexHooksMapFormat,
// ensureCodexHooksFeature, config reads, configContent construction,
// etc.) must ALSO be fatal. Previously this branch downgraded to a
// console.warn, leaving the install to print "Done!" with no Codex
// hooks configured — same defect class as finding 1, different layer.
// Restore the pre-install snapshot and rethrow so the outer install
// pipeline aborts.
restoreCodexSnapshot();
const wrapped = new Error(
`Codex hook configuration failed (pre-write): ${e && e.message ? e.message : String(e)}. ` +
`Restored ${preWriteBackup !== null ? 'pre-install backup' : 'empty state'}.`
);
console.error(` ${red}✗${reset} ${wrapped.message}`);
throw wrapped;
}
persistActiveProfileMarker();
// #4249: expose restoreCodexSnapshot (#3245) as a SECOND, separately named
// rollback rather than rebinding `rollbackInstallerMigrations` to it. A
// configured-entrypoint validation failure discovered later (outside this
// function, after Codex's own hooks.json/config.toml write already
// succeeded) previously had only the installer-migrations closure to call,
// leaving the just-written config.toml/hooks.json broken on disk despite
// Codex already owning a full pre-install snapshot/restore for exactly this.
//
// Every runtime's `rollbackInstallerMigrations` therefore still means what
// it says — the installer-migrations-only closure, which is what a
// finalize-stage failure that is NOT an entrypoint-validation failure gets
// (the Phase 4 contract). `rollbackPreInstallSnapshot` is Codex-only and is
// chosen only for entrypoint-validation failures. See the selection in
// installAllRuntimes' rollbackFinalizedInstallerMigrations.
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints, rollbackInstallerMigrations, rollbackPreInstallSnapshot: restoreCodexSnapshot };
}
if (plan.installSurface === 'cursor-hooks-json') {
// ADR-1239 / #2089: Cursor hooks.json driven by the descriptor-managed hook-bus
// adapter. Registers all 6 managed events (sessionStart, postToolUse, preToolUse,
// stop, subagentStart, subagentStop) via runtime-hooks-surface.cts, which reads
// the event list from the descriptor-driven adapter module.
const cursorHookResult = writeCursorHooksJson(targetDir, src, {
managedHookEvents: _hostBehaviors(runtime).managedHookEvents,
});
if (cursorHookResult.changed) {
console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop)`);
} else {
console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`);
}
// Re-run the manifest pass to capture any files the hooks-json write path
// produced. NOTE: hooks.json and the msd-cursor-*.js scripts are NOT
// manifest-tracked (verified) — uninstall removes them explicitly via
// removeCursorHooksJson + its script list, and reconcile is idempotent.
// The re-run is retained for parity with the settings.json install path.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints: cursorHookResult.configuredEntrypoints, rollbackInstallerMigrations };
}
if (plan.installSurface === 'profile-marker-only') {
// ZCode uses an artifact-only surface — no config.toml or settings.json
// hooks needed.
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints, rollbackInstallerMigrations };
}
// Configure statusline and hooks in settings.json (or settings.local.json for local Claude installs).
// ADR-857 phase 5f-2: drive the hook event dialect from the registry descriptor.
// runtimes with hookEvents='gemini' use AfterTool/BeforeTool; all others use PostToolUse/PreToolUse.
// Equivalence: hookEvents='gemini' iff runtime===antigravity — identical to the old check.
// A missing registry or missing descriptor defaults to 'not gemini' → PostToolUse (safe).
const _hookEventsDialect = plan.hookEvents;
const postToolEvent = _hookEventsDialect === 'gemini' ? 'AfterTool' : 'PostToolUse';
// #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
// so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
// settings.json. Global installs and all other runtimes continue to use settings.json.
// #2870: the CURRENT scope's settings filename is sourced from the Install
// Scope Module (_installScope.settingsFile, resolveScope's per-scope field)
// instead of indexing _scopedSettings by hand. _scopedSettings itself is
// retained unchanged as the #338-privacy fail-safe path: _hostBehaviors
// already degrades to FALLBACK_HOST_BEHAVIORS (see that constant's comment
// above) when the registry fails to load, whereas resolveScope's registry
// lookup throws in that same scenario (_installScope is null when it did).
// Falling back to _scopedSettings[_installScopeId] there — and keeping the
// non-local-claude branch's expression untouched — means this is
// byte-identical to the pre-migration computation in every case, including
// the broken-registry fail-safe floor.
const _scopedSettings = _hostBehaviors(runtime).settingsFileByScope || null;
const _currentScopeSettingsFile = _installScope
? _installScope.settingsFile
: (_scopedSettings ? (_scopedSettings[_installScopeId] ?? null) : null);
const isLocalClaude = (!isGlobal && !!_currentScopeSettingsFile);
const settingsFileName = isLocalClaude
? _currentScopeSettingsFile
: ((_scopedSettings && _scopedSettings.global) || 'settings.json');
// ADR-1239 Phase B write-confinement: the descriptor-sourced settings filename
// must resolve under targetDir (this path also drives a recursive mkdirSync).
const settingsPath = assertDestWithinConfigHome(targetDir, settingsFileName);
// #338 migration: if a prior local Claude install wrote MSD-shaped entries to settings.json,
// relocate them to settings.local.json and clear them from the shared file in the same run.
if (isLocalClaude) {
const sharedSettingsPath = path.join(targetDir, 'settings.json');
const sharedRaw = readSettings(sharedSettingsPath);
if (sharedRaw && typeof sharedRaw === 'object') {
const hasMsdHooks = sharedRaw.hooks && Object.values(sharedRaw.hooks).some(
entries => Array.isArray(entries) && entries.some(
entry => entry && entry.hooks && Array.isArray(entry.hooks) && entry.hooks.some(
h => h && typeof h.command === 'string' && isManagedHookCommand(h.command, { surface: 'settings-json' })
)
)
);
const hasMsdStatusline = sharedRaw.statusLine && sharedRaw.statusLine.command &&
isManagedHookCommand(sharedRaw.statusLine.command, { surface: 'settings-json' });
const needsMigration = hasMsdHooks || hasMsdStatusline;
// readSettings returns null ONLY for an unparseable file — its documented
// "preserve existing, don't touch" signal. Stand the WHOLE migration down
// in that case: skipping just the local merge while still stripping the
// shared file below would destroy the MSD entries outright instead of
// relocating them. Leaving both files untouched lets the migration retry
// once the user repairs the local file.
const localRaw = needsMigration ? readSettings(settingsPath) : null;
if (needsMigration && localRaw === null) {
console.log(' ' + yellow + 'i' + reset + ' Skipping #338 migration — ' + settingsFileName +
' could not be parsed. Your existing settings are preserved.');
} else if (needsMigration) {
// Merge MSD entries into settings.local.json
if (hasMsdStatusline && !localRaw.statusLine) {
localRaw.statusLine = sharedRaw.statusLine;
}
if (hasMsdHooks) {
if (!localRaw.hooks) localRaw.hooks = {};
for (const [eventName, entries] of Object.entries(sharedRaw.hooks || {})) {
if (!Array.isArray(entries)) continue;
const msdEntries = entries.filter(
entry => entry && entry.hooks && Array.isArray(entry.hooks) && entry.hooks.some(
h => h && typeof h.command === 'string' && isManagedHookCommand(h.command, { surface: 'settings-json' })
)
);
if (msdEntries.length > 0) {
if (!localRaw.hooks[eventName]) localRaw.hooks[eventName] = [];
// Only merge entries not already present in local
for (const entry of msdEntries) {
const alreadyPresent = localRaw.hooks[eventName].some(
le => le && le.hooks && Array.isArray(le.hooks) && le.hooks.some(
lh => lh && entry.hooks.some(eh => eh && eh.command === lh.command)
)
);
if (!alreadyPresent) localRaw.hooks[eventName].push(entry);
}
}
}
}
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
writeSettings(settingsPath, localRaw);
// Remove MSD entries from shared settings.json
if (hasMsdStatusline) {
delete sharedRaw.statusLine;
}
if (hasMsdHooks) {
for (const [eventName, entries] of Object.entries(sharedRaw.hooks || {})) {
if (!Array.isArray(entries)) continue;
sharedRaw.hooks[eventName] = entries.filter(
entry => !(entry && entry.hooks && Array.isArray(entry.hooks) && entry.hooks.some(
h => h && typeof h.command === 'string' && isManagedHookCommand(h.command, { surface: 'settings-json' })
))
);
if (sharedRaw.hooks[eventName].length === 0) {
delete sharedRaw.hooks[eventName];
}
}
if (sharedRaw.hooks && Object.keys(sharedRaw.hooks).length === 0) {
delete sharedRaw.hooks;
}
}
writeSettings(sharedSettingsPath, sharedRaw);
console.log(` ${green}✓${reset} Migrated MSD hook entries from settings.json to settings.local.json (#338)`);
}
}
}
const rawSettings = readSettings(settingsPath);
if (rawSettings === null) {
console.log(' ' + yellow + 'i' + reset + ' Skipping settings.local.json configuration — file could not be parsed (comments or malformed JSON). Your existing settings are preserved.');
persistActiveProfileMarker();
// Callers index this result by `runtime` (installAllRuntimes' statusline
// lookup), so every early exit must return the full shape — a bare return
// crashes the install rather than skipping one file. That includes
// configuredEntrypoints/rollbackInstallerMigrations: rollbackFinalizedInstallerMigrations
// reads result.rollbackInstallerMigrations unconditionally, and an omitted
// field there silently skips this runtime's rollback on a finalize-stage failure.
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints: [], rollbackInstallerMigrations };
}
const settings = validateHookFields(cleanupOrphanedHooks(rawSettings));
// #3002 CR / #3662: rewrite legacy `node .../msd-*.js` command strings (pre-
// #2979 installs) AND entries baked with another environment's absolute node
// path onto the runtime-resolving runner. Without this, existing managed
// hook entries stay bare-`node`-prefixed or foreign-absolute across
// reinstalls and remain broken under GUI/minimal-PATH runtimes and shared
// config roots — the #3662 mixed state where no environment can run all
// hooks.
const settingsRunner = buildNodeRunnerChainToken();
if (settingsRunner && rewriteLegacyManagedNodeHookCommands(settings, settingsRunner, { platform: process.platform, runtime })) {
console.log(` ${green}✓${reset} Rewrote legacy managed-hook commands to the runtime-resolving node runner (#2979/#3662)`);
}
// Local installs anchor hook paths so they resolve regardless of cwd (#1906).
// Claude Code sets $CLAUDE_PROJECT_DIR; Antigravity does not — and on
// Windows its own substitution logic doubles the path (#2557). It runs
// project hooks with the project dir as cwd, so bare relative paths work.
// Descriptor-driven (ADR-1239 / #2096): hookPathStyle comes from the
// runtime's hostBehaviors instead of a hardcoded `runtime === 'antigravity'`
// check inside projectLocalHookPrefix.
const localPrefix = projectLocalHookPrefix({ runtime, dirName, hookPathStyle: _hostBehaviors(runtime).hookPathStyle });
const settingsEntrypoints = [];
const hookOpts = {
portableHooks: hasPortableHooks,
runtime,
configPath: settingsPath,
// #4249: track unconditionally. Gating on `plan.hooksSurface ===
// 'settings-json'` made tracking depend on an unasserted
// installSurface/hooksSurface coupling — a descriptor that broke it would
// silently drop this runtime out of validation. Everything recorded here
// lands in settings.json by construction, and the registered-command
// filter below already discards entries no hook actually references.
configuredEntrypoints: settingsEntrypoints,
};
// #2979: local-install hook commands also use a runner GUI/minimal-PATH
// runtimes can resolve. Bare `node` fails when the host launches the
// runtime with a stripped PATH (Finder/Antigravity/etc) — #3662 replaces
// the baked absolute path with the runtime-resolving chain (baked path
// first, so the minimal-PATH guarantee is unchanged).
const localNodeRunner = buildNodeRunnerChainToken();
const localBashRunner = resolveBashRunner({ platform: process.platform });
// If we cannot resolve a node runner AND this is a local install,
// skip managed-hook registration. Returning null from buildHookCommand on
// global installs has the same effect. Better to skip than to emit a bare
// `node` command that recreates the #2979 failure.
const localCmd = (hookFile) => localNodeRunner === null
? null
: hooksSurface.recordConfiguredHookCommand(projectShellCommandText({
runnerToken: localNodeRunner,
argTokens: [`${localPrefix}/hooks/${hookFile}`],
runtime,
platform: process.platform,
}), targetDir, hookFile, hookOpts);
const localShellCmd = (hookFile) => hooksSurface.recordConfiguredHookCommand(buildLocalShellHookCommand({
localPrefix,
hookFile,
bashRunner: localBashRunner,
runtime,
platform: process.platform,
}), targetDir, hookFile, hookOpts);
const statuslineCommand = isGlobal
? buildHookCommand(targetDir, 'msd-statusline.js', hookOpts)
: localCmd('msd-statusline.js');
const updateCheckCommand = isGlobal
? buildHookCommand(targetDir, 'msd-check-update.js', hookOpts)
: localCmd('msd-check-update.js');
const contextMonitorCommand = isGlobal
? buildHookCommand(targetDir, 'msd-context-monitor.js', hookOpts)
: localCmd('msd-context-monitor.js');
const promptGuardCommand = isGlobal
? buildHookCommand(targetDir, 'msd-prompt-guard.js', hookOpts)
: localCmd('msd-prompt-guard.js');
const readGuardCommand = isGlobal
? buildHookCommand(targetDir, 'msd-read-guard.js', hookOpts)
: localCmd('msd-read-guard.js');
const readInjectionScannerCommand = isGlobal
? buildHookCommand(targetDir, 'msd-read-injection-scanner.js', hookOpts)
: localCmd('msd-read-injection-scanner.js');
const configReloadCommand = isGlobal
? buildHookCommand(targetDir, 'msd-config-reload.js', hookOpts)
: localCmd('msd-config-reload.js');
// #3002 CR: when resolveNodeRunner() returns null, every dependent JS-hook
// command is null too. Emit one warning here so the operator sees the cause
// ONCE instead of per-hook. Each registration site below also guards on its
// own *Command variable being truthy, so we never write `command: null`
// entries to settings.json (which the runtime's hook schema would reject).
const anyJsHookCommandNull = !statuslineCommand
|| !updateCheckCommand
|| !contextMonitorCommand
|| !promptGuardCommand
|| !readGuardCommand
|| !readInjectionScannerCommand;
if (anyJsHookCommandNull) {
console.warn(` ${yellow}⚠${reset} Skipping managed JS hook registration — Node executable path unavailable (process.execPath is empty). See #2979 / #3002.`);
}
// Register all MSD-managed hook entries into settings.hooks.* for runtimes
// that use the settings.json hook surface (ADR-857 phase 5f-1b).
// settings is mutated in place by applySettingsJsonHooks.
applySettingsJsonHooks(settings, {
runtime,
isGlobal,
targetDir,
postToolEvent,
hookEvents: _hookEventsDialect,
extendedHookEvents: plan.extendedHookEvents,
hooksSurface: plan.hooksSurface,
updateCheckCommand,
contextMonitorCommand,
promptGuardCommand,
readGuardCommand,
readInjectionScannerCommand,
configReloadCommand,
hookOpts,
localCmd,
localShellCmd,
});
// Compute the update-banner hook command alongside the others so
// installAllRuntimes can register it at finalize time when the user opts
// in (#2795). Computed here (not in finishInstall) so the same buildHookCommand
// / localCmd resolution logic is shared with the other JS hooks.
const updateBannerCommand = _hostBehaviors(runtime).skipUpdateBannerCommand
? null
: (isGlobal
? buildHookCommand(targetDir, 'msd-update-banner.js', hookOpts)
: localCmd('msd-update-banner.js'));
const registeredHookCommands = Object.values(settings.hooks || {})
.flatMap(groups => Array.isArray(groups) ? groups : [])
.flatMap(group => Array.isArray(group && group.hooks) ? group.hooks : [])
.map(hook => hook && hook.command)
.filter(command => typeof command === 'string');
// #4249: match by the managed script's `/hooks/<basename>` path segment, not
// by exact command-string equality. The blocking-guard hooks above register
// only-if-absent, so a hook already present from a prior install keeps its
// OLD command untouched — but `track()` always records the FRESHLY computed
// command for it, which never equals what's actually persisted. Matching on
// the segment (present in the persisted command either way, since every
// entry.scriptPath is <configDir>/hooks/<name> by construction) keeps an
// already-registered, still-active hook in the validated set instead of
// silently dropping it (#4154 Blocker) — anchored on `/hooks/` rather than a
// bare basename so an unrelated user command that merely mentions the same
// filename can't false-positive into MSD's validated set.
configuredEntrypoints.push(
...settingsEntrypoints.filter(entry => {
const hooksSegment = '/hooks/' + path.basename(entry.scriptPath);
return registeredHookCommands.some(command => command.includes(hooksSegment));
}),
);
const statuslineEntrypoints = settingsEntrypoints.filter(entry => entry.command === statuslineCommand);
const updateBannerEntrypoints = settingsEntrypoints.filter(entry => entry.command === updateBannerCommand);
// #683: Set worktree.baseRef:"head" in settings.local.json for local Claude installs.
// Both fresh and upgrade paths apply only when worktrees are enabled for the project.
// Never applies to global installs, non-Claude runtimes, or when the user already
// has an explicit baseRef in EITHER settings.local.json OR settings.json (no-clobber).
// Guard: skip entirely when settings is not a plain object (e.g. parsed to [] or primitive)
// to avoid crashing applyWorktreeBaseRef on unexpected top-level shapes.
if (isLocalClaude && settings !== null && typeof settings === 'object' && !Array.isArray(settings)) {
// Read shared settings.json baseRef so no-clobber spans both files (#683 FIX 1).
// shared settings.json no-clobber is checked here; settings.local.json no-clobber
// is enforced inside applyWorktreeBaseRef itself.
const sharedSettingsForBaseRef = readSettings(path.join(targetDir, 'settings.json')) || {};
const sharedBaseRef = readBaseRefFromSettings(sharedSettingsForBaseRef);
// Compute worktrees-enabled ONCE for both fresh and upgrade paths (FIX A: DRY + consistency).
// Read workflow.use_worktrees from .planning/config.json by walking up from
// targetDir (same walk-up pattern as readMsdRuntimeProfileResolver). Defaults
// to enabled (true) when the file is missing, unreadable, or the key is absent;
// only boolean false disables (string "false" stays enabled).
let worktreesEnabled = true; // default: enabled
try {
let probeDir = path.resolve(targetDir);
for (let depth = 0; depth < 8; depth += 1) {
const candidate = path.join(probeDir, '.planning', 'config.json');
if (fs.existsSync(candidate)) {
try {
const parsed = JSON.parse(stripJsonComments(fs.readFileSync(candidate, 'utf-8')));
if (parsed && typeof parsed === 'object' &&
parsed.workflow && parsed.workflow.use_worktrees === false) {
worktreesEnabled = false;
}
} catch {
// Malformed config.json — treat as enabled (safe fallback).
}
break;
}
const parent = path.dirname(probeDir);
if (parent === probeDir) break;
probeDir = parent;
}
} catch {
// Any unexpected error reading .planning — default to enabled.
}
if (worktreesEnabled && sharedBaseRef === null) {
if (!priorInstallExisted) {
// Fresh install — apply no-clobber baseRef set.
// canonical no-clobber logic: src/worktree-base-ref.cts applyWorktreeBaseRef (#683)
const { changed } = applyWorktreeBaseRef(settings);
if (changed) {
console.log(` ${green}✓${reset} Set worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
}
} else {
// Upgrade — auto-apply no-clobber baseRef set when worktrees are enabled.
const { changed } = applyWorktreeBaseRef(settings);
if (changed) {
console.log(` ${green}✓${reset} Enabled worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
}
}
}
// When worktreesEnabled is false: do nothing, print nothing (both fresh and upgrade).
}
persistActiveProfileMarker();
return {
settingsPath,
settings,
statuslineCommand,
updateBannerCommand,
statuslineEntrypoints,
updateBannerEntrypoints,
runtime,
configDir: targetDir,
rollbackInstallerMigrations,
configuredEntrypoints,
};
}
// #4249 (review, Major): rollback consequence differs by runtime surface —
// see docs/how-to/update-msd.md's rollback-matrix paragraph, which this
// mirrors. Codex reverts (pre-install snapshot restore); Cursor already
// wrote its config file inside install(), ahead of this gate, with no revert
// path, so it is left on disk broken; every other (settings.json-based)
// runtime writes strictly after this gate, so a failure here means nothing
// new was persisted for it.
const ENTRYPOINT_LEFT_UNREVERTED_RUNTIMES = new Set(['cursor']);
function describeEntrypointConsequence(invalidRuntime) {
if (invalidRuntime === 'codex') return 'reverted: its pre-install snapshot was restored';
if (ENTRYPOINT_LEFT_UNREVERTED_RUNTIMES.has(invalidRuntime)) return 'NOT reverted: its config file is already written and was left on disk — fix the reported path and rerun install';
return 'not persisted: this runtime writes its config after this check';
}
function assertConfiguredEntrypoints(entries) {
// #4249: some writers push the same (configPath, scriptPath) pair more than
// once (e.g. a context-monitor hook registered under several events, or
// the portable resolver script shared by every portable JS hook) — keep
// one so a broken entry is reported once, not once per duplicate.
const seen = new Set();
const deduped = (entries || []).filter((entry) => {
const key = JSON.stringify([entry.configPath, entry.scriptPath]);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const validation = hooksSurface.validateConfiguredEntrypoints(deduped);
if (validation.ok) return;
const error = new Error(
// #4249: lead each entry with its runtime, and name the actual consequence
// for that runtime (review, Major) — the aggregate gate is all-or-nothing
// across every runtime being installed, and a failure here can revert a
// runtime whose own entrypoints were fine (see
// rollbackFinalizedInstallerMigrations) while leaving another runtime's
// already-written config broken on disk with no revert at all, so an
// operator reading only this message must be able to tell WHOSE
// entrypoint broke and WHAT that means for their config, not just that
// something did.
`Configured entrypoint validation failed: ${validation.invalid.map(({ runtime: invalidRuntime, role, path: invalidPath, reason }) => `${invalidRuntime} ${role} ${invalidPath} (${reason}) [${describeEntrypointConsequence(invalidRuntime)}]`).join(', ')}`,
);
error.configuredEntrypointValidation = validation;
throw error;
}
// #4249: `bannerOpts.configuredEntrypoints` is the ONLY source assertConfiguredEntrypoints
// checks below — a caller that omits it (or calls finishInstall directly instead of
// through installAllRuntimes) gets zero entrypoint validation, silently. installAllRuntimes
// always passes the full set (per-runtime entries plus statusline/updateBanner); any other
// caller must do the same for this gate to mean anything.
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = DEFAULT_RUNTIME, isGlobal = true, configDir = null, bannerOpts = {}) {
const { isOpencode, isCodex, isCursor } = runtimeFlags(runtime);
const plan = resolveInstallPlan(runtime);
// #4249 Major: validate BEFORE this function's own settings.json write (and
// before writeNonClaudeDefaults) instead of after. Cursor
// already persisted their config inside install() by this point, with no
// rollback path covering those writes; Codex also persists inside install()
// but its rollback binds to a full pre-install snapshot restore, so it IS
// covered (see docs/how-to/update-msd.md). For the settings-json surface
// this ordering means a failing validation never reaches this function's
// own write at all. On the production path this is a redundant backstop —
// installAllRuntimes's own aggregate assertConfiguredEntrypoints call
// already validates the superset before finishInstall runs for any
// runtime — kept for a caller that invokes finishInstall directly.
assertConfiguredEntrypoints(bannerOpts.configuredEntrypoints);
if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
if (!isGlobal && !forceStatusline) {
// Local installs skip statusLine by default: repo settings.json takes precedence over
// profile-level settings.json in Claude Code, so writing here would silently clobber
// any profile-level statusLine the user has configured (#2248).
// Pass --force-statusline to override this guard.
console.log(` ${yellow}⚠${reset} Skipping statusLine for local install (avoids overriding profile-level settings; use --force-statusline to override)`);
} else if (!statuslineCommand) {
// #3002 CR: don't write { type: 'command', command: null } — the
// runtime's settings schema rejects null commands and the failure
// surfaces as a confusing parse error rather than a usable diagnostic.
console.warn(` ${yellow}⚠${reset} Skipped statusline registration — Node executable path unavailable (process.execPath is empty). See #2979 / #3002.`);
} else {
settings.statusLine = {
type: 'command',
command: statuslineCommand
};
console.log(` ${green}✓${reset} Configured statusline`);
}
}
// Register the opt-in update banner (#2795) when the user accepted the
// banner offer at install time. Only applies to runtimes that own a
// settings.json hooks block — opencode/codex/cursor/zcode either lack the
// surface or use a different config schema.
const { shouldInstallBanner, bannerCommand } = bannerOpts;
if (shouldInstallBanner && settings && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
if (!bannerCommand) {
console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
} else {
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
const alreadyRegistered = settings.hooks.SessionStart.some(entry =>
entry && entry.hooks && entry.hooks.some(h => h && referencesHook(h, 'msd-update-banner'))
);
const bannerHookFile = configDir ? path.join(configDir, 'hooks', 'msd-update-banner.js') : null;
const bannerInstalled = bannerHookFile ? fs.existsSync(bannerHookFile) : false;
if (alreadyRegistered) {
// Idempotent re-install: don't double-register.
} else if (!bannerInstalled) {
console.warn(` ${yellow}⚠${reset} Skipped update banner — msd-update-banner.js not found at target`);
} else {
const entry = buildUpdateBannerHookEntry(bannerCommand);
if (entry) {
settings.hooks.SessionStart.push(entry);
console.log(` ${green}✓${reset} Configured update banner hook (opt-in)`);
}
}
}
}
// #768 — Pre-populate permissions.allow/deny for Claude Code installs.
// Merges MSD-owned entries non-destructively (preserves existing user permissions).
// Scoped to Claude only: antigravity also writes settings.json but uses a
// different runtime and does not use these permission strings.
if (_hostBehaviors(runtime).permissionsSchema === 'claude') {
mergeClaudePermissions(settings);
}
// Write settings when runtime supports settings.json.
// #3002 CR: defense-in-depth — re-run validateHookFields right before
// serialization. The push-site guards above already skip null-command
// entries, but a future regression that bypasses them would still produce
// {type: 'command', command: null} items that the runtime hook schema
// rejects at parse time. validateHookFields filters those out so the file
// we write is always schema-valid.
if (settingsPath && settings && plan.writesSharedSettings) {
writeSettings(settingsPath, validateHookFields(settings));
}
// Configure OpenCode permissions
if (plan.finishPermissionWriter === 'opencode' && !process.env.MSD_TEST_MODE) {
configureOpencodePermissions(isGlobal, configDir);
}
// Configure Antigravity permissions + MCP companion server (#2096 Phase B
// Upgrades 1+2). Not MSD_TEST_MODE-gated; both writers target files (settings.json, mcp_config.json) scoped under
// this runtime's own configDir, so they are safe to run unconditionally.
if (plan.finishPermissionWriter === 'antigravity') {
configureAntigravityPermissions(isGlobal, configDir);
configureAntigravityMcpConfig(isGlobal, configDir);
}
// #2834: defaults.json (resolve_model_ids + runtime) is now written BEFORE
// installCodexConfig via writeNonClaudeDefaults(runtime) — extracted into a
// function so it can run at the right point in the flow (before agent TOML
// generation reads it). This call is idempotent (preserves existing values).
writeNonClaudeDefaults(runtime);
// program + command are now single-source lookups (ADR-1239 Phase B / #1679):
// program is the runtime display label; command is the per-host /msd-new-project
// invocation syntax.
const program = getRuntimeLabel(runtime);
const command = getRuntimeNewProjectCommand(runtime);
// Claude Code global installs use the skills/ format (CC 2.1.88+).
// Restart is required for CC to pick up newly-installed skills, and the
// slash-menu surface depends on CC version — so the instruction needs to
// cover both invocation paths to avoid #2957-style "no commands appear".
if (_hostBehaviors(runtime).skillsGlobalOnboarding && isGlobal) {
console.log(`
${green}Done!${reset} Restart ${program}, then in any directory either type ${cyan}${command}${reset} or ask Claude to run the ${cyan}msd-new-project${reset} skill.
${cyan}Join the community:${reset} https://discord.gg/mYgfVNfA2r
`);
return;
}
console.log(`
${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}.
${cyan}Join the community:${reset} https://discord.gg/mYgfVNfA2r
`);
}
/**
* Handle statusline configuration with optional prompt
*/
function handleStatusline(settings, isInteractive, callback) {
const hasExisting = settings.statusLine != null;
if (!hasExisting) {
callback(true);
return;
}
if (forceStatusline) {
callback(true);
return;
}
if (!isInteractive) {
console.log(` ${yellow}⚠${reset} Skipping statusline (already configured)`);
console.log(` Use ${cyan}--force-statusline${reset} to replace\n`);
callback(false);
return;
}
const existingCmd = settings.statusLine.command || settings.statusLine.url || '(custom)';
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
console.log(`
${yellow}⚠${reset} Existing statusline detected\n
Your current statusline:
${dim}command: ${existingCmd}${reset}
MSD includes a statusline showing:
• Model name
• Current task (from todo list)
• Context window usage (color-coded)
${cyan}1${reset}) Keep existing
${cyan}2${reset}) Replace with MSD statusline
`);
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
rl.close();
const choice = answer.trim() || '1';
callback(choice === '2');
});
}
/**
* Prompt for runtime selection
*/
/**
* Runtime selection options for the interactive installer prompt.
* Module-level so tests can import and assert structurally without grepping source.
*/
const runtimeMap = {
'1': 'claude',
'2': 'antigravity',
'3': 'codex',
'4': 'cursor',
'5': 'opencode',
'6': 'zcode'
};
const allRuntimes = ['claude', 'antigravity', 'codex', 'cursor', 'opencode', 'zcode'];
const ALL_RUNTIMES_OPTION = '7';
/**
* Build the runtime-selection prompt text shown by the interactive installer.
* Pure function — no I/O. Exported for tests so they can assert against the
* rendered prompt instead of grepping bin/install.js source text.
*/
function buildRuntimePromptText() {
return ` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset}
${cyan}2${reset}) Antigravity ${dim}(~/.gemini/antigravity)${reset}
${cyan}3${reset}) Codex ${dim}(~/.codex)${reset}
${cyan}4${reset}) Cursor ${dim}(~/.cursor)${reset}
${cyan}5${reset}) OpenCode ${dim}(~/.config/opencode)${reset}
${cyan}6${reset}) ZCode ${dim}(~/.zcode)${reset}
${cyan}7${reset}) All
${dim}Select multiple: 1,2,6 or 1 2 6${reset}
`;
}
/**
* Parse user input from the runtime-selection prompt into a runtime list.
* Pure function — exported so tests can verify split/dedupe/fallback behavior.
* - Accepts comma- and/or whitespace-separated choices
* - Deduplicates while preserving order
* - Maps option 7 ("All") to every runtime
* - Falls back to ['claude'] when nothing valid is selected
*/
function parseRuntimeInput(answer) {
const input = (answer == null ? '' : String(answer)).trim() || '1';
// Tokenize first so the all-runtimes shortcut also fires for inputs the
// prompt encourages — "16,", "16 1", etc. — not just the bare "16".
const choices = input.split(/[\s,]+/).filter(Boolean);
if (choices.includes(ALL_RUNTIMES_OPTION)) {
return allRuntimes.slice();
}
const selected = [];
for (const c of choices) {
const runtime = runtimeMap[c];
if (runtime && !selected.includes(runtime)) {
selected.push(runtime);
}
}
return selected.length > 0 ? selected : [DEFAULT_RUNTIME];
}
function promptRuntime(callback) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
let answered = false;
rl.on('close', () => {
if (!answered) {
answered = true;
console.log(`\n ${yellow}Installation cancelled${reset}\n`);
process.exit(0);
}
});
console.log(buildRuntimePromptText());
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
answered = true;
rl.close();
callback(parseRuntimeInput(answer));
});
}
// ─── Update banner (#2795) ──────────────────────────────────────────────────
/**
* Build the prompt text shown when offering the opt-in update banner.
* Pure function — no I/O. Exported for tests so they can assert against the
* rendered prompt structurally instead of grepping bin/install.js source.
*/
function buildUpdateBannerPromptText() {
return `
${yellow}Optional: MSD update banner${reset}
Without MSD's statusline, update notifications won't be visible. You can
install a SessionStart banner that surfaces a one-line message when a new
MSD release is available. The banner appears only at session start and
only when an update exists.
${cyan}1${reset}) ${dim}No banner (default)${reset}
${cyan}2${reset}) Install update banner
`;
}
/**
* Parse user input from the banner prompt. Returns true when the user opted
* in. Pure function — exported for direct unit testing.
*
* - Empty input or "1" → false (default: no banner).
* - "2" → true.
* - "y" / "yes" (case-insensitive) → true. Affirmative shortcuts.
*/
function parseUpdateBannerInput(answer) {
const input = (answer == null ? '' : String(answer)).trim().toLowerCase();
if (input === '2' || input === 'y' || input === 'yes') return true;
return false;
}
/**
* Build a SessionStart hook entry (settings.json shape) that runs the
* update-banner script. Returns null when the input command is empty so
* callers can warn-and-skip rather than writing { command: null } and
* tripping the runtime's hook schema (#3002).
*
* @param {string|null} bannerCommand - Result of buildHookCommand() / localCmd().
* @returns {{hooks: Array<{type: 'command', command: string}>}|null}
*/
function buildUpdateBannerHookEntry(bannerCommand) {
if (!bannerCommand) return null;
return {
hooks: [
{
type: 'command',
command: bannerCommand,
},
],
};
}
/**
* Interactive prompt that asks the user whether to install the opt-in
* update banner. Used by `installAllRuntimes` only when MSD's statusline
* was declined or skipped.
*
* @param {boolean} isInteractive
* @param {(shouldInstallBanner: boolean) => void} callback
*/
function handleUpdateBanner(isInteractive, callback) {
if (!isInteractive) {
// Never auto-install in non-interactive mode — user can re-run install
// interactively or hand-edit settings.json to opt in later.
callback(false);
return;
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log(buildUpdateBannerPromptText());
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
rl.close();
callback(parseUpdateBannerInput(answer));
});
}
/**
* Prompt for install location
*/
function promptLocation(runtimes) {
if (!process.stdin.isTTY) {
console.log(` ${yellow}Non-interactive terminal detected, defaulting to global install${reset}\n`);
installAllRuntimes(runtimes, true, false);
return;
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
let answered = false;
rl.on('close', () => {
if (!answered) {
answered = true;
console.log(`\n ${yellow}Installation cancelled${reset}\n`);
process.exit(0);
}
});
const pathExamples = runtimes.map(r => {
const globalPath = getGlobalConfigDir(r, explicitConfigDir);
return globalPath.replace(os.homedir(), '~');
}).join(', ');
const localExamples = runtimes.map(r => `./${getDirName(r)}`).join(', ');
console.log(` ${yellow}Where would you like to install?${reset}\n\n ${cyan}1${reset}) Global ${dim}(${pathExamples})${reset} - available in all projects
${cyan}2${reset}) Local ${dim}(${localExamples})${reset} - this project only
`);
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
answered = true;
rl.close();
const choice = answer.trim() || '1';
const isGlobal = choice !== '2';
installAllRuntimes(runtimes, isGlobal, true);
});
}
/**
* Check whether any common shell rc file already contains a `PATH=` line
* whose HOME-expanded value places `globalBin` on PATH (#2620).
*
* Parses `~/.zshrc`, `~/.bashrc`, `~/.bash_profile`, `~/.profile` (or the
* override list in `rcFileNames`), matches `export PATH=` / bare `PATH=`
* lines, and substitutes the common HOME forms (`$HOME`, `${HOME}`, `~`)
* with `homeDir` before comparing each PATH segment against `globalBin`.
*
* Best-effort: any unreadable / malformed / non-existent rc file is ignored
* and the fallback is the caller's existing absolute-path suggestion. Only
* the `$HOME/…`, `${HOME}/…`, and `~/…` forms are handled — we do not try
* to fully parse bash syntax.
*
* @param {string} globalBin Absolute path to npm's global bin directory.
* @param {string} homeDir Absolute path used to substitute HOME / ~.
* @param {string[]} [rcFileNames] Override the default rc file list.
* @returns {boolean} true iff any rc file adds globalBin to PATH.
*/
function homePathCoveredByRc(globalBin, homeDir, rcFileNames) {
if (!globalBin || !homeDir) return false;
const path = require('path');
const fs = require('fs');
const normalise = (p) => {
if (!p) return '';
let n = p.replace(/[\\/]+$/g, '');
if (n === '') n = p.startsWith('/') ? '/' : p;
return n;
};
const targetAbs = normalise(path.resolve(globalBin));
const homeAbs = path.resolve(homeDir);
const files = rcFileNames || ['.zshrc', '.bashrc', '.bash_profile', '.profile'];
const expandHome = (segment) => {
let s = segment;
s = s.replace(/\$\{HOME\}/g, homeAbs);
s = s.replace(/\$HOME/g, homeAbs);
if (s.startsWith('~/') || s === '~') {
s = s === '~' ? homeAbs : path.join(homeAbs, s.slice(2));
}
return s;
};
// Match `PATH=…` (optionally prefixed with `export `). The RHS captures
// through end-of-line; surrounding quotes are stripped before splitting.
const assignRe = /^\s*(?:export\s+)?PATH\s*=\s*(.+?)\s*$/;
for (const name of files) {
const rcPath = path.join(homeAbs, name);
let content;
try {
content = fs.readFileSync(rcPath, 'utf8');
} catch {
continue;
}
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.replace(/^\s+/, '');
if (line.startsWith('#')) continue;
const m = assignRe.exec(rawLine);
if (!m) continue;
let rhs = m[1];
if ((rhs.startsWith('"') && rhs.endsWith('"')) ||
(rhs.startsWith("'") && rhs.endsWith("'"))) {
rhs = rhs.slice(1, -1);
}
for (const segment of rhs.split(':')) {
if (!segment) continue;
const trimmed = segment.trim();
const expanded = expandHome(trimmed);
if (expanded.includes('$')) continue;
// Skip segments that are still relative after HOME expansion. A bare
// `bin` entry (or `./bin`, `node_modules/.bin`, etc.) depends on the
// shell's cwd at lookup time — it is NOT equivalent to `$HOME/bin`,
// so resolving against homeAbs would produce false positives.
if (!path.isAbsolute(expanded)) continue;
try {
const abs = normalise(path.resolve(expanded));
if (abs === targetAbs) return true;
} catch {
// ignore unresolvable segments
}
}
}
}
return false;
}
/**
* Decode fish's universal-variable value escaping (the inverse of fish's
* `full_escape`). fish serializes every non-`[A-Za-z0-9/_]` byte in
* `fish_variables` — e.g. space -> `\x20`, hyphen -> `\x2d`, dot -> `\x2e` —
* and joins list elements with the literal 4-char token `\x1e` (NOT a raw
* 0x1e byte). Callers split on `\x1e` first, then decode each element here.
*
* Pure and total: any unrecognised `\`-sequence is passed through verbatim,
* so `decode(fishEscape(p)) === p` holds for every path string. Exported for
* a fast-check round-trip property test (#323).
*
* @param {string} s A single (already `\x1e`-split) escaped value.
* @returns {string} The decoded literal.
*/
function decodeFishUniversalValue(s) {
let out = '';
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c !== '\\') { out += c; continue; }
const n = s[i + 1];
if (n === 'n') { out += '\n'; i += 1; }
else if (n === 'r') { out += '\r'; i += 1; }
else if (n === 't') { out += '\t'; i += 1; }
else if (n === '\\') { out += '\\'; i += 1; }
else if (n === 'x' || n === 'X') {
const hex = s.slice(i + 2, i + 4);
if (/^[0-9a-fA-F]{2}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 3; }
else { out += c; }
} else if (n === 'u') {
const hex = s.slice(i + 2, i + 6);
if (/^[0-9a-fA-F]{4}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 5; }
else { out += c; }
} else if (n === 'U') {
const hex = s.slice(i + 2, i + 10);
if (/^[0-9a-fA-F]{8}$/.test(hex)) { out += String.fromCodePoint(parseInt(hex, 16)); i += 9; }
else { out += c; }
} else { out += c; }
}
return out;
}
/**
* Check whether fish's configuration already places `globalBin` on PATH (#323).
*
* fish does not use the sh-style `export PATH=` rc files that
* `homePathCoveredByRc()` parses, so a fish user whose `fish_user_paths`
* already covers the global bin would otherwise see a false-positive
* "not on your PATH" warning on every install. Two detection routes,
* mirroring how `fish_add_path` actually persists:
*
* 1. The universal-variable store `fish_variables` — a
* `SETUVAR fish_user_paths:<a>\x1e<b>…` line whose `\x1e`-separated
* entries are absolute paths (fish does not HOME-expand them here).
* 2. `config.fish` — explicit `fish_add_path …`, `set -gx PATH …`, or
* `set -Ux fish_user_paths …` lines that name the directory after
* HOME expansion.
*
* Best-effort and side-effect-free: any unreadable / missing file is ignored
* (no fish subprocess is spawned). Honours `$XDG_CONFIG_HOME` and always also
* checks `~/.config/fish`. Pass `fishConfigDir` to override the lookup
* directory (tests).
*
* @param {string} globalBin Absolute path to npm's global bin directory.
* @param {string} homeDir Absolute path used to substitute HOME / ~.
* @param {string} [fishConfigDir] Override the fish config directory.
* @returns {boolean} true iff fish config adds globalBin to PATH.
*/
function homePathCoveredByFishConfig(globalBin, homeDir, fishConfigDir) {
if (!globalBin || !homeDir) return false;
const path = require('path');
const fs = require('fs');
const normalise = (p) => {
if (!p) return '';
let n = p.replace(/[\\/]+$/g, '');
if (n === '') n = p.startsWith('/') ? '/' : p;
return n;
};
const targetAbs = normalise(path.resolve(globalBin));
const homeAbs = path.resolve(homeDir);
const baseDirs = [];
if (fishConfigDir) {
baseDirs.push(fishConfigDir);
} else {
if (process.env.XDG_CONFIG_HOME) {
baseDirs.push(path.join(process.env.XDG_CONFIG_HOME, 'fish'));
}
baseDirs.push(path.join(homeAbs, '.config', 'fish'));
}
const expandHome = (segment) => {
let s = segment;
s = s.replace(/\$\{HOME\}/g, homeAbs).replace(/\$HOME/g, homeAbs);
if (s.startsWith('~/') || s === '~') {
s = s === '~' ? homeAbs : path.join(homeAbs, s.slice(2));
}
return s;
};
// Compare an already-resolved absolute literal (a decoded fish_user_paths
// entry — fish stores these resolved, never as `$VAR`/`~`). A literal `$`
// here is part of the directory name, so it must NOT be treated as an
// unexpanded variable.
const matchesLiteral = (segment) => {
if (!segment || !path.isAbsolute(segment)) return false;
try {
return normalise(path.resolve(segment)) === targetAbs;
} catch {
return false;
}
};
// Compare a config.fish shell token: strip surrounding quotes, expand the
// common HOME forms, and skip anything still holding a `$` (an unexpanded
// variable such as `$PATH` / `$fish_user_paths`) or still relative.
const matchesTarget = (rawSegment) => {
if (!rawSegment) return false;
let seg = rawSegment.trim();
if ((seg.startsWith('"') && seg.endsWith('"')) ||
(seg.startsWith("'") && seg.endsWith("'"))) {
seg = seg.slice(1, -1);
}
const expanded = expandHome(seg);
if (expanded.includes('$')) return false;
return matchesLiteral(expanded);
};
const readLines = (filePath) => {
try {
return fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
} catch {
return null;
}
};
for (const baseDir of baseDirs) {
// Route 1: universal variable store.
const uvarLines = readLines(path.join(baseDir, 'fish_variables'));
if (uvarLines) {
for (const rawLine of uvarLines) {
const m = /^SETUVAR(?:\s+--\S+)*\s+fish_user_paths:(.*)$/.exec(rawLine);
if (!m) continue;
// Elements are joined by the literal `\x1e` token; decode each. The
// decoded entry is an absolute literal — compare it directly.
for (const entry of m[1].split('\\x1e')) {
if (matchesLiteral(decodeFishUniversalValue(entry))) return true;
}
}
}
// Route 2: config.fish explicit PATH mutations.
const configLines = readLines(path.join(baseDir, 'config.fish'));
if (configLines) {
for (const rawLine of configLines) {
const line = rawLine.replace(/^\s+/, '');
if (line.startsWith('#')) continue;
let rest = null;
let m;
if ((m = /^fish_add_path\s+(.+)$/.exec(line))) {
rest = m[1];
} else if ((m = /^set\s+(?:-\S+\s+)*PATH\s+(.+)$/.exec(line))) {
rest = m[1];
} else if ((m = /^set\s+(?:-\S+\s+)*fish_user_paths\s+(.+)$/.exec(line))) {
rest = m[1];
}
if (rest === null) continue;
// Tokens are whitespace-separated; flag tokens (`-g`, `--path`) and
// variable references are skipped by matchesTarget / the `-` guard.
for (const tok of rest.split(/\s+/)) {
if (!tok || tok.startsWith('-')) continue;
if (matchesTarget(tok)) return true;
}
}
}
}
return false;
}
/**
* Emit a PATH-export suggestion if globalBin is not already on PATH AND
* the user's shell rc files do not already cover it via a HOME-relative
* entry (#2620).
*
* Prints one of:
* - nothing, if `globalBin` is already present on `process.env.PATH`
* - a diagnostic "already covered via rc file" note, if an rc file has
* `export PATH="$HOME/…/bin:$PATH"` (or equivalent) and the user just
* needs to reopen their shell
* - projected shell actions that append `export PATH="…:$PATH"` to
* `~/.zshrc` / `~/.bashrc` when neither PATH nor rc files cover globalBin
* if neither PATH nor any rc file covers globalBin
*
* Exported for tests; the installer calls this from finishInstall.
*
* @param {string} globalBin Absolute path to npm's global bin directory.
* @param {string} homeDir Absolute HOME path.
*/
function maybeSuggestPathExport(globalBin, homeDir) {
if (!globalBin || !homeDir) return;
const path = require('path');
const pathEnv = process.env.PATH || '';
const targetAbs = path.resolve(globalBin).replace(/[\\/]+$/g, '') || globalBin;
const onPath = pathEnv.split(path.delimiter).some((seg) => {
if (!seg) return false;
const abs = path.resolve(seg).replace(/[\\/]+$/g, '') || seg;
return abs === targetAbs;
});
if (onPath) return;
// Already added to PATH via an rc file, but the current shell predates that
// edit — tell the user to reopen rather than (wrongly) suggesting they add it
// again. Applies to whatever bin dir we install into (retained shim-agnostic).
if (homePathCoveredByRc(globalBin, homeDir)) {
console.log('');
console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset}'s directory is already on your PATH via an rc file entry — try reopening your shell (or ${cyan}source ~/.zshrc${reset}).`);
console.log('');
return;
}
// Same idea for fish users: fish_user_paths / config.fish already covers the
// dir, the current session just predates it. fish has no sh-style rc file so
// homePathCoveredByRc never sees it — check the fish config explicitly (#323).
if (homePathCoveredByFishConfig(globalBin, homeDir)) {
console.log('');
console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset}'s directory is already on your PATH via fish's universal variables — open a new fish session (or run ${cyan}exec fish${reset}).`);
console.log('');
return;
}
console.log('');
console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset} is not on your PATH.`);
const projected = projectPersistentPathExportActions({
targetDir: globalBin,
platform: process.platform,
});
if (projected.reason === PATH_ACTION_REASON.WIN32_RESERVED_QUOTE) {
// #3118 review MINOR: a win32 targetDir containing `"` makes
// projectPathActionProjection return [] (no command can quote it safely
// on Windows) — printing the "Add it with one of:" header with nothing
// under it is a silent dead-end. Name the cause instead.
console.log(` No command can be suggested: the path contains a ${cyan}"${reset} character, which cannot appear in a Windows path.`);
} else if (projected.shellActions.length === 0) {
// #3118: no target directory to talk about (reason === NO_TARGET_DIR, or
// no reason at all) — there is nothing to print beyond the "not on your
// PATH" line above.
} else {
console.log(` Add it with one of:`);
for (const action of projected.shellActions) {
const labelPrefix = action.label ? `${action.label}: ` : '';
console.log(` ${cyan}${labelPrefix}${action.command}${reset}`);
}
}
console.log('');
}
// Runtime subdir names to scan for legacy get-shit-done-cc artifacts (#607).
// Covers both local (project-relative) and common global forms.
const _LEGACY_SCAN_SUBDIR_NAMES = [
'.claude',
'.gemini',
'.opencode',
'.config/opencode',
'.codex',
'.agents', // antigravity local form (canonical, #791)
'.agent', // antigravity local form (legacy, backward-compat)
'.cursor',
];
/**
* Detect and remove leftover get-shit-done-cc artifacts across ALL known
* runtime config directories (issue #607).
*
* Exported so tests can call it directly without spawning a subprocess.
*
* Scans ONLY subdirs under homeDir — never cwd — to avoid touching the
* user's active-project hooks when the installer is run from a project dir.
*
* @param {object} [opts]
* @param {string} [opts.homeDir=os.homedir()] - home directory to scan
* @param {boolean} [opts.dryRun=false] - preview only; no mutations
* @param {object} [opts.logger=console] - injectable logger
* @returns {{ plan: {path:string,reason:string}[], result: object }}
*/
function cleanupLegacyMsdCc({ homeDir = os.homedir(), configDirs = null, dryRun = false, logger = console } = {}) {
// Build de-duplicated list of candidate config dirs to scan.
// Only scan under homeDir — never cwd — to prevent accidental deletion of
// the user's active-project hooks when the installer is invoked from a
// project directory that has .claude/hooks or similar subdirs.
// #3799: an explicit configDirs override (install() passes [targetDir]
// whenever --config-dir redirected the destination) scopes the WHOLE scan
// to that dir — the default-home scan must never plan removals of a live
// install that lives outside the destination the user chose.
const seen = new Set();
const scanDirs = [];
if (Array.isArray(configDirs) && configDirs.length > 0) {
for (const candidate of configDirs) {
if (!seen.has(candidate) && fs.existsSync(candidate)) {
seen.add(candidate);
scanDirs.push(candidate);
}
}
} else {
for (const name of _LEGACY_SCAN_SUBDIR_NAMES) {
const candidate = path.join(homeDir, name);
if (!seen.has(candidate) && fs.existsSync(candidate)) {
seen.add(candidate);
scanDirs.push(candidate);
}
}
}
// planLegacyCleanup scans each configDir and already includes the legacy
// shared cache (msd-update-check.json) as a plan entry.
const plan = planLegacyCleanup(scanDirs, { homeDir, ...(Array.isArray(configDirs) && configDirs.length > 0 ? { configDirs } : {}) });
// Apply the plan (dryRun honors the flag).
const result = applyLegacyCleanup(plan, { dryRun, logger });
// Also clear / preview the per-package cache so next session re-evaluates
// hook versions (replaces the former inline unlinkSync on line ~9104).
// #3799: under a configDirs override the cache is read/cleared under the
// SCOPE root, never the default home — same invariant as the scan itself.
const perPkgCacheRoot = (Array.isArray(configDirs) && configDirs.length > 0)
? configDirs[0]
: homeDir;
const perPkgCacheFile = path.join(perPkgCacheRoot, '.cache', 'msd', updateCacheFileName);
if (dryRun) {
logger.log('[dry-run] would remove: ' + perPkgCacheFile + ' (per-package-update-cache)');
} else {
try { fs.unlinkSync(perPkgCacheFile); } catch (_e) { /* cache may not exist yet */ }
}
// Concise summary
if (plan.length > 0 || !dryRun) {
const verb = dryRun ? 'Would remove' : 'Removed';
const count = dryRun ? plan.length : result.removed.length;
logger.log(`[legacy-cleanup] ${verb} ${count} legacy artifact(s).`);
}
return { plan, result };
}
/**
* Install MSD for all selected runtimes
*/
function installAllRuntimes(runtimes, isGlobal, isInteractive) {
const results = [];
const installerMigrations = discoverInstallerMigrations({
migrationsDir: path.join(_msdLibDir, 'installer-migrations'),
});
const rollbackFinalizedInstallerMigrations = (error) => {
const rollbackFailures = [];
// #4249: this discriminates on the error's KIND, never on which runtime
// owns the failing entrypoint. `wide` is true for ANY entrypoint-validation
// failure from ANY runtime, by design: the aggregate gate exists so a
// multi-runtime install cannot report success while one of its entrypoints
// is broken, so an invalid Cursor entrypoint reverts Codex's pre-install
// snapshot too — even though Codex itself was fine and its own "Done!"
// summary already printed. tests/configured-entrypoint-validation.test.cjs
// ('an aggregate entrypoint validation failure rolls the Codex install
// back') exercises exactly that, and it is the all-or-nothing behaviour
// docs/how-to/update-msd.md documents.
//
// What this narrows is the OTHER axis: a finalize-stage exception that is
// not an entrypoint-validation failure at all — e.g. a sibling runtime's
// permission-config write dying with EACCES — gets only the
// installer-migrations-only rollback that Phase 4 specifies
// (docs/installer-migrations.md#phase-4-installupdate-integration).
// Un-installing (and, on update, downgrading) an already-"Done!" Codex over
// an unrelated error is not an outcome any doc promises, while the sibling
// surfaces that write config inside install() would keep theirs regardless.
const wide = !!(error && error.configuredEntrypointValidation);
for (const result of [...results].reverse()) {
if (!result) continue;
const rollback = (wide && result.rollbackPreInstallSnapshot) || result.rollbackInstallerMigrations;
if (typeof rollback !== 'function') continue;
try {
rollback();
} catch (rollbackError) {
rollbackFailures.push({
runtime: result.runtime,
error: rollbackError.message,
});
}
}
if (rollbackFailures.length > 0) {
error.installerMigrationRollbackFailures = rollbackFailures;
}
};
try {
for (const runtime of runtimes) {
const result = install(isGlobal, runtime, { installerMigrations });
results.push(result);
}
} catch (error) {
rollbackFinalizedInstallerMigrations(error);
throw error;
}
const statuslineRuntimes = [DEFAULT_RUNTIME];
const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
const finalize = (shouldInstallStatusline, shouldInstallBanner) => {
try {
const selectedConfiguredEntrypoints = (result) => {
if (!result || result.skipped) return [];
const useStatusline = statuslineRuntimes.includes(result.runtime)
&& shouldInstallStatusline
&& (isGlobal || forceStatusline);
return [
...(result.configuredEntrypoints || []),
...(useStatusline ? (result.statuslineEntrypoints || []) : []),
...(shouldInstallBanner ? (result.updateBannerEntrypoints || []) : []),
];
};
assertConfiguredEntrypoints(results.flatMap(selectedConfiguredEntrypoints));
const printSummaries = () => {
for (const result of results) {
if (result && result.skipped) continue;
if (!result) continue;
const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline;
finishInstall(
result.settingsPath,
result.settings,
result.statuslineCommand,
useStatusline,
result.runtime,
isGlobal,
result.configDir,
{
shouldInstallBanner: !!shouldInstallBanner,
bannerCommand: result.updateBannerCommand,
configuredEntrypoints: selectedConfiguredEntrypoints(result),
}
);
}
};
printSummaries();
} catch (error) {
// Phase 4 install/update integration requires safe migrations to roll
// back when later package/finalization materialization fails:
// docs/installer-migrations.md#phase-4-installupdate-integration.
rollbackFinalizedInstallerMigrations(error);
throw error;
}
};
// Statusline first; if it won't actually be installed (declined, or local
// install without --force-statusline silently skips it per #2248), offer
// the opt-in update banner (#2795) as the secondary surface for update
// notifications. Skip the banner prompt entirely when no runtime in this
// install set can host the banner (e.g. Codex/Cursor-only installs whose
// updateBannerCommand is null).
//
// CR #3035: gate on actual installability — `shouldInstallStatusline`
// returned by handleStatusline is the raw user choice, but
// `finishInstall` later skips the statusline write on local installs
// unless --force-statusline is set. Passing the raw flag to
// continueAfterStatusline previously caused two bugs: (1) interactive
// local installs got neither a statusline nor a banner offer, and (2)
// banner-incapable runtimes got prompted even though every
// updateBannerCommand was null.
const canInstallBanner = results.some((r) => r && r.updateBannerCommand);
const continueAfterStatusline = (shouldInstallStatusline) => {
const willInstallStatusline =
shouldInstallStatusline && (isGlobal || forceStatusline);
if (willInstallStatusline) {
finalize(true, false);
return;
}
if (!canInstallBanner) {
finalize(shouldInstallStatusline, false);
return;
}
handleUpdateBanner(isInteractive, (shouldInstallBanner) => {
finalize(shouldInstallStatusline, shouldInstallBanner);
});
};
// `settings` is null on every early exit (unparseable file, skipped runtime),
// and handleStatusline dereferences it — an install that declined to touch a
// settings file has no statusline to prompt about, so fall through.
if (primaryStatuslineResult && primaryStatuslineResult.settings) {
handleStatusline(primaryStatuslineResult.settings, isInteractive, continueAfterStatusline);
} else if (canInstallBanner) {
// No statusline-capable runtime, but at least one runtime can host the
// banner — still offer it.
handleUpdateBanner(isInteractive, (shouldInstallBanner) => {
finalize(false, shouldInstallBanner);
});
} else {
// Nothing to prompt about — no statusline, no banner-capable runtime.
finalize(false, false);
}
}
// Always export so runtime-artifact-layout.cjs's lazy loader can access
// converter functions when called from within the CLI path (circular require).
// The main() block below is gated on !MSD_TEST_MODE, as before.
module.exports = {
// #3677 — hyphen-namespace normalization seam for agent bodies
shouldNormalizeHyphenNamespaceInAgentBody,
// #3664: --config-dir foreign-agent-destination warning (warn-and-proceed)
warnIfForeignAgentDest,
normalizeAgentBodyForRuntime,
yamlIdentifier,
getCodexSkillAdapterHeader,
convertClaudeCommandToCursorSkill,
convertClaudeAgentToCursorAgent,
convertClaudeAgentToCodexAgent,
generateCodexAgentToml,
_resetCodexWarningDedupeForTests,
cleanupCodexSkillMetadataSidecars,
cleanupMovedSkillsOldLocation,
_resolveMovedSkillsOldDir,
_resolveSkillsRootDir,
codexBareAgentsHasOnlyKnownScalars,
extractCodexUserAgentsScalars,
CODEX_EXTENDED_HOOK_EVENTS,
generateCodexConfigBlock,
stripMsdFromCodexConfig,
migrateCodexHooksMapFormat,
stripStaleMsdHookBlocks,
hasUserNamespacedAotHooks,
parseTomlToObject,
validateCodexConfigSchema,
mergeCodexConfig,
installCodexConfig,
install,
installAllRuntimes,
uninstall,
// #2086 — host-behavior resolution + the #338 privacy fail-safe floor (exported for tests)
_resolveHostBehaviors,
FALLBACK_HOST_BEHAVIORS,
// #3023 — shared hook bundle directory name, descriptor-driven
SHARED_HOOKS_DIR,
// #3184 — uninstall-side MSD-managed file enumerations, exported for
// parity assertions against the wholesale-copy source directories
MSD_CHANGESET_FILES,
MSD_SCRIPTS_LIB_FILES,
convertSlashCommandsToCodexSkillMentions,
convertClaudeCommandToCodexSkill,
convertClaudeToOpencodeFrontmatter,
configureOpencodePermissions,
neutralizeAgentReferences,
// #768 — Claude Code permissions pre-population
mergeClaudePermissions,
MSD_CLAUDE_ALLOW_PERMISSIONS,
MSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS,
MSD_CLAUDE_LEGACY_DENY_PERMISSIONS,
MSD_CODEX_MARKER,
// #3897 rung 3 (ADR-3473 §8.3, HALT.md option 2)
CODEX_SANDBOX_HOLDS,
deriveCodexSandboxMode,
validateCodexSandboxHolds,
getGlobalDir,
getConfigDirFromHome,
// #2096 Phase B Upgrades 1+2 — Antigravity permission-writer + MCP companion
toTildePosixPath,
buildAntigravityAllowRules,
configureAntigravityPermissions,
configureAntigravityMcpConfig,
convertClaudeToAntigravityContent,
convertClaudeCommandToAntigravitySkill,
convertClaudeAgentToAntigravityAgent,
convertClaudeCommandToClaudeSkill,
skillFrontmatterName,
MSD_CURSOR_SESSION_HOOK_SCRIPT,
MSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
MSD_CURSOR_HOOK_MARKER,
writeManifest,
saveLocalPatches,
reportLocalPatches,
validateHookFields,
populatePristineDir,
describeBaselineCoverage,
_resolveUserArtifactStagingRoot,
_tryResolveUserArtifactStagingRoot,
finishInstall,
homePathCoveredByRc,
homePathCoveredByFishConfig,
decodeFishUniversalValue,
maybeSuggestPathExport,
runtimeMap,
allRuntimes,
selectRuntimesFromArgs,
MSD_UNINSTALL_HOOKS,
parseRuntimeInput,
buildRuntimePromptText,
buildUpdateBannerPromptText,
parseUpdateBannerInput,
buildUpdateBannerHookEntry,
parseConfigDirFromArgs,
cleanupLegacyMsdCc,
// #1191 — exported so tests exercise the REAL readSettings, not a replica
readSettings,
writeSettings,
writeNonClaudeDefaults,
stripJsonComments,
copyWithPathReplacement,
};
// Main logic — only run when not loaded as a module for testing
if (require.main === module && !process.env.MSD_TEST_MODE) {
if (hasDryRun) {
// --dry-run: preview legacy cleanup and exit without installing.
if (hasUninstall) {
console.log('Note: --dry-run previews legacy get-shit-done-cc cleanup only; it does not preview --uninstall.');
}
console.log('Dry run — no files will be modified.\n');
// cleanupLegacyMsdCc with dryRun:true is the single source of truth for
// both the legacy artifacts and the per-package cache path — no duplicate
// printing here. #3799: the preview honors the SAME scope and skip the
// real install would apply (--config-dir scopes; --no-legacy-cleanup
// skips) — a preview that listed default-home paths a real install would
// never touch misrepresents the run.
if (parseNoLegacyCleanupArg()) {
console.log(' (--no-legacy-cleanup — legacy scan skipped)');
} else {
const previewScope = (explicitConfigDir !== null)
? [getGlobalConfigDir(DEFAULT_RUNTIME, explicitConfigDir)]
: undefined;
const { plan } = cleanupLegacyMsdCc({
dryRun: true,
...(previewScope ? { configDirs: previewScope } : {}),
});
if (plan.length === 0) {
console.log(' (no legacy get-shit-done-cc artifacts found)');
}
}
process.exit(0);
} else if (hasSkillsRoot) {
// Print the skills root directory for a given runtime (used by /msd-sync-skills).
// Usage: node install.js --skills-root <runtime>
const runtimeArg = args[args.indexOf('--skills-root') + 1];
if (!runtimeArg || runtimeArg.startsWith('--')) {
console.error('Usage: node install.js --skills-root <runtime>');
process.exit(1);
}
// #3024: validate the runtime id against the shipped capability registry
// BEFORE resolving anything. getGlobalSkillsBase's bare `runtimes[runtime]`
// lookup falls through the prototype chain to claude's skills root for an
// unregistered/hostile id (`__proto__`, `constructor`, `prototype`, …)
// instead of failing loudly. isRegisteredRuntimeId is the SAME validator
// msd-tools' `routeSkillsRoot` calls, so this entry point and the shipped
// `msd-tools query skills-root` entry point can never diverge on which
// runtime ids they accept.
if (!isRegisteredRuntimeId(runtimeArg)) {
console.error(`Unknown runtime "${runtimeArg}" — must be a registered runtime id`);
process.exit(1);
}
const skillsRoot = getGlobalSkillsBase(runtimeArg.trim());
if (skillsRoot === null) {
console.error(`${runtimeArg} does not use a skills directory`);
process.exit(1);
}
console.log(skillsRoot);
} else if (hasGlobal && hasLocal) {
console.error(` ${yellow}Cannot specify both --global and --local${reset}`);
process.exit(1);
} else if (explicitConfigDir && hasLocal) {
console.error(` ${yellow}Cannot use --config-dir with --local${reset}`);
process.exit(1);
} else if (hasUninstall) {
if (!hasGlobal && !hasLocal) {
console.error(` ${yellow}--uninstall requires --global or --local${reset}`);
process.exit(1);
}
const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : [DEFAULT_RUNTIME];
for (const runtime of runtimes) {
uninstall(hasGlobal, runtime);
}
} else if (selectedRuntimes.length > 0) {
if (!hasGlobal && !hasLocal) {
promptLocation(selectedRuntimes);
} else {
installAllRuntimes(selectedRuntimes, hasGlobal, false);
}
} else if (hasGlobal || hasLocal) {
// Default to Claude if no runtime specified but location is
installAllRuntimes([DEFAULT_RUNTIME], hasGlobal, false);
} else {
// Interactive
if (!process.stdin.isTTY) {
console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code global install${reset}\n`);
installAllRuntimes([DEFAULT_RUNTIME], true, false);
} else {
promptRuntime((runtimes) => {
promptLocation(runtimes);
});
}
}
} // end of !MSD_TEST_MODE main logic block