#!/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 `` 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:` → `/msd-` * normalization at install time. Descriptor-driven * (capabilities//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.]` 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 /.cursor/hooks.json (local) or // ~/.cursor/hooks.json (global) with the shape { version: 1, hooks: { : [...] } }. // 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 /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//capability.json -> runtime.hostBehaviors) instead of * scattered `runtime === ''` 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 === '' check) so the snapshot/rollback machinery and post-install * verification look where the skills actually landed. Falls back to /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= or --profile=, (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= → 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, '', ...) * @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 ${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=${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-) to the frontmatter `name:` used * by Claude Code as the skill identity. Emits the hyphen form (msd-) * so Claude Code autocomplete shows the canonical invocation form, not the * deprecated colon form. See #2808. * * Historical note: this previously returned `msd:` (colon) because * workflows called Skill(skill="msd:"). 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-` * (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-) — 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-` (hyphen) so Skill(skill="msd-") 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: or msd: in the body to the canonical // hyphen form (msd-) 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:` // 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 // comparison-table content (protected region). converted = applyClaudeCodeBrandSwap(converted, 'Cursor'); return converted; } function getCursorSkillAdapterHeader(skillName) { return ` ## 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") `; } 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- 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. 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 --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 \`\` 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 \`, 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/.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) `; } 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 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 = ` role: ${name} tools: ${tools} purpose: ${toSingleLine(description)} `; 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 — "flex" | "fast" (legacy) // model_verbosity: Option — "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 `*` 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 `*` 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/.toml`) are auto-discovered by Codex and are the * SOLE canonical registration source for each role. This block therefore no * longer emits `[agents.]` 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.]` 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.]`). 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.]` 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-]` 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.]` 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-] 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.]]` 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.]] 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.]] 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.]` body declares `event = "..."`, // prefer that as the event-name leaf key. The path segment may be // a `:::` 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.]]`) 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 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.]`) — never a bare * table value or an array of tables. * - `hooks.` MUST be an array of tables when present (Codex ≥0.124 * rejects bare `[hooks.]` 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.] 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.] 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.]] (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.] (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. // must be an array, and flat top-level [[hooks]] (parsed as Array on root) // is rejected — Codex 0.124.0+ requires [[hooks.]] 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.]] 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.[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. 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": { "": [ { "type": "command", "command": "" } ] } } // // Location: // Global: ~/.cursor/hooks.json // Local: /.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 /hooks.json. * * Both managed hook scripts (msd-cursor-session-start.js, msd-cursor-post-tool.js) * are copied from the MSD hooks/ source to /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= (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 -/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 `` 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: → /msd- 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 /.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 `/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 /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-.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/ 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 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= → 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> 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 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> — 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 — 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; 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, a full recursive // snapshot of /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-.md layout — Claude Code registers // commands from .claude/commands/ using the filename stem as the command // name, so msd-.md produces the /msd- hyphen form used everywhere // in the framework. The old commands/msd/.md subdirectory layout caused // Claude Code to namespace commands as /msd: (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-.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-.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-.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 // /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 /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 (.tmp--) 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-- 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.]] 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/` 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 /hooks/ 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:\x1e…` 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 const runtimeArg = args[args.indexOf('--skills-root') + 1]; if (!runtimeArg || runtimeArg.startsWith('--')) { console.error('Usage: node install.js --skills-root '); 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