Drop code whose only consumer was a retired runtime: hostBehaviors readers for agentFileExtension, localTargetIsProjectRoot, sharedHooksDirName, skillsManifestPrefix and skipCodexSkillsManifest; the empty NON_REGISTRY_CONFIG_HOME_DESCRIPTORS array and live-config-guard plumbing; the empty RUNTIME_NOTE_AUDIENCE_BY_HEADING filter; the unused resolveVersionFrom export; and the WINDSURF_SESSION_ID workstream session key. Delete tests that only exercised those mechanisms.
11103 lines
510 KiB
JavaScript
Executable File
11103 lines
510 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const readline = require('readline');
|
||
const crypto = require('crypto');
|
||
const {
|
||
isManagedHookBasename,
|
||
isManagedHookCommand,
|
||
projectLocalHookPrefix,
|
||
projectLegacySettingsHookCommand,
|
||
projectManagedHookCommand,
|
||
projectPathActionProjection,
|
||
projectPortableHookBaseDir,
|
||
projectPersistentPathExportActions,
|
||
PATH_ACTION_REASON,
|
||
projectShellCommandText,
|
||
projectCodexHookTomlCommand,
|
||
shellHookOmitsBashRunner,
|
||
buildLocalShellHookCommand,
|
||
} = require('../msd-core/bin/lib/shell-command-projection.cjs');
|
||
|
||
// Bidirectional MSD slash-command namespace transformer (#3583).
|
||
// Required at module scope so the command list can be computed once per install
|
||
// and passed down to convertClaudeCommandToClaudeSkill, avoiding repeated
|
||
// fs.readdirSync + RegExp work for every skill.
|
||
const {
|
||
transformContentToHyphen,
|
||
readMsdCommandNames,
|
||
} = require('../msd-core/bin/lib/command-roster.cjs');
|
||
const {
|
||
resolveAntigravityGlobalDir,
|
||
getGlobalConfigDir,
|
||
getGlobalSkillsBase,
|
||
isRegisteredRuntimeId,
|
||
} = require('../msd-core/bin/lib/runtime-homes.cjs');
|
||
// #2870: the Install Scope Module — turns a bare 'global' | 'local' scope id
|
||
// plus a runtime into one resolved value (configHome, settingsFile,
|
||
// consentRequired, hostPrecedenceRank) instead of the id being re-derived
|
||
// and re-interpreted at each call site. See src/install-scope.cts.
|
||
const { resolveScope } = require('../msd-core/bin/lib/install-scope.cjs');
|
||
const { isTestHomeGuardRefusal } = require('../msd-core/bin/lib/real-home-guard.cjs');
|
||
// getDirName (runtime -> local config dir name) is relocated out of this
|
||
// installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the
|
||
// conversion module's rewrite engine can consume it without importing
|
||
// bin/install.js. Imported here for install.js's own internal call sites
|
||
// (getConfigDirFromHome and the runtime-content-rewrite loops below) — #2876
|
||
// retired the re-export; tests now import getDirName directly from
|
||
// msd-core/bin/lib/runtime-name-policy.cjs.
|
||
const { getDirName, getRuntimeLabel, getGlobalConfigHomeFragment, runtimeFlags, getRuntimeNewProjectCommand } = require('../msd-core/bin/lib/runtime-name-policy.cjs');
|
||
const {
|
||
applyWorktreeBaseRef,
|
||
readBaseRefFromSettings,
|
||
} = require('../msd-core/bin/lib/worktree-base-ref.cjs');
|
||
const { resolveInstallPlan } = require('../msd-core/bin/lib/runtime-config-adapter-registry.cjs');
|
||
const { createImperativeAdapter } = require('../msd-core/bin/lib/adapter-imperative.cjs');
|
||
// #2930 (epic #1671 Phase 3): strips `<!-- msd:section -->` markers from
|
||
// workflow .md content at emit time, before any per-runtime rewrite runs.
|
||
const { composeWorkflow } = require('../msd-core/bin/lib/workflow-fragments.cjs');
|
||
// #3072: THE shared composition-scope predicate (also consumed by the served
|
||
// MCP catalog, src/mcp-catalog.cts) — see the comment at its call site below.
|
||
const { shouldCompose } = require('../msd-core/bin/lib/mcp-catalog.cjs');
|
||
const runtimeArtifactConversion = require('../msd-core/bin/lib/runtime-artifact-conversion.cjs');
|
||
const { escapeRegex: escapeRegExp } = require('../msd-core/bin/lib/pattern.cjs');
|
||
// #2873: cross-scope shadow detection — reports (never fails) when a
|
||
// MSD-owned scope shadows another on this machine (design doc:
|
||
// .msd/phase/feat-2873-cross-scope-shadowing/40-design.md).
|
||
const { buildShadowReport, renderShadowReport } = require('../msd-core/bin/lib/install-shadow-report.cjs');
|
||
// #2544: the CommonJS marker's single source of truth. classifyMarker() backs
|
||
// BOTH ensureCommonJsMarker() (install) and removeCommonJsMarker() (uninstall),
|
||
// so the write side can no longer clobber a package.json the remove side would
|
||
// correctly refuse to delete.
|
||
const { ensureCommonJsMarker, removeCommonJsMarker } = require('../msd-core/bin/lib/commonjs-marker.cjs');
|
||
// Canonical set of hook files shipped to users. Imported here so writeManifest()
|
||
// records exactly the same set that build-hooks.js copies to hooks/dist/, making
|
||
// the manifest and the installed hooks/ dir structurally identical. Avoids the
|
||
// prefix/extension-regex approach that missed managed-hooks-registry.cjs (#941).
|
||
const { HOOKS_TO_COPY: _HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
|
||
const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY);
|
||
|
||
// ADR-857 phase 5f-1: hook-surface writer functions extracted to a dedicated
|
||
// module. install.js used to re-export the whole hooksSurface surface so
|
||
// existing callers (require('../bin/install.js').writeCursorHooksJson etc.)
|
||
// kept working — #2876 found zero production/test consumers of any of those
|
||
// re-exports (tests import runtime-hooks-surface.cjs directly) and retired
|
||
// them from module.exports. install.js still requires hooksSurface below for
|
||
// its own internal call sites (writeCursorHooksJson,
|
||
// resolveNodeRunner, applySettingsJsonHooks, etc.).
|
||
const hooksSurface = require('../msd-core/bin/lib/runtime-hooks-surface.cjs');
|
||
|
||
/**
|
||
* #3677 predicate — true when an agent body needs `/msd:<cmd>` → `/msd-<cmd>`
|
||
* normalization at install time. Descriptor-driven
|
||
* (capabilities/<runtime>/capability.json -> runtime.hostBehaviors.hyphenNameAgentBody)
|
||
* instead of a hardcoded runtime allow-list (ADR-1239 / #2086). Sibling fixes
|
||
* #3583 / #3629 covered SKILL.md bodies, #3584 / #3606 covered runtime
|
||
* emissions — this is the agent-body surface (#3677).
|
||
*
|
||
* Unknown / future runtimes that don't declare the flag default to "no
|
||
* rewrite" (better to leak than to mangle a runtime whose namespace
|
||
* behavior we haven't verified).
|
||
*/
|
||
function shouldNormalizeHyphenNamespaceInAgentBody(runtime) {
|
||
if (typeof runtime !== 'string' || runtime === '') return false;
|
||
return _hostBehaviors(runtime).hyphenNameAgentBody === true;
|
||
}
|
||
|
||
/**
|
||
* #3677 helper — applies the hyphen-namespace transform iff the predicate
|
||
* says so. Pure function; safe to call unconditionally from the install
|
||
* loop. Returns the input unchanged for runtimes that self-convert or
|
||
* intentionally keep colon refs.
|
||
*/
|
||
function normalizeAgentBodyForRuntime(content, runtime, cmdNames) {
|
||
if (!shouldNormalizeHyphenNamespaceInAgentBody(runtime)) return content;
|
||
return transformContentToHyphen(content, cmdNames);
|
||
}
|
||
|
||
// Colors
|
||
const cyan = '\x1b[36m';
|
||
const green = '\x1b[32m';
|
||
const yellow = '\x1b[33m';
|
||
const red = '\x1b[31m';
|
||
const bold = '\x1b[1m';
|
||
const dim = '\x1b[2m';
|
||
const reset = '\x1b[0m';
|
||
|
||
// Codex config.toml constants
|
||
const MSD_CODEX_MARKER = '# MSD Agent Configuration \u2014 managed by msd-core installer';
|
||
const MSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# MSD codex_hooks ownership: ';
|
||
// Known scalar fields of Codex's `AgentsToml` struct (codex-rs/config/src/
|
||
// config_toml.rs \u2014 `[agents]` table). Codex marks the struct
|
||
// `#[schemars(deny_unknown_fields)]`, so a bare `[agents]` table is valid ONLY
|
||
// when every direct key is one of these (named agent roles live in the flattened
|
||
// `[agents.<name>]` sub-tables, a separate `AgentRoleToml`). MSD writes only
|
||
// `max_depth` (ADR-1239 upgrade 2 / #2088); the full set is enumerated so the
|
||
// schema check accepts a user's other legitimate AgentsToml scalars too.
|
||
const CODEX_AGENTS_TOML_SCALAR_KEYS = new Set([
|
||
'max_threads',
|
||
'max_depth',
|
||
'job_max_runtime_seconds',
|
||
'interrupt_message',
|
||
]);
|
||
// MSD's managed dispatch-depth value. Codex's implicit default is also 1 (root
|
||
// sessions start at depth 0); writing it EXPLICITLY pins the negotiated
|
||
// `dispatch.maxDepth: 1` axis instead of relying on codex-cli's implicit default
|
||
// (ADR-1239 upgrade 2 / #2088). Per the negotiated capability, MSD-hosted Codex
|
||
// dispatch is single-level (maxDepth === 1 \u2192 `degradationFor` flattens waves).
|
||
const MSD_CODEX_AGENTS_MAX_DEPTH = 1;
|
||
// Codex hooks.json lifecycle events MSD registers beyond SessionStart (which has
|
||
// its own dedicated path). This is Codex's OWN hook-event vocabulary (per
|
||
// developers.openai.com/codex/config-reference), distinct from the cross-runtime
|
||
// settings.json `extendedHookEvents` descriptor field (a claude/gemini-family
|
||
// allowlist consumed only by hooksSurface==='settings-json' runtimes — Codex is
|
||
// codex-hooks-json). All route through msd-context-monitor.js. #772 wired the
|
||
// first three; #2088 adds the remaining six documented events so MSD's monitor
|
||
// fires at the same lifecycle points as in Claude Code. Install and uninstall
|
||
// share this list so the registered set and the removed set never diverge.
|
||
const CODEX_EXTENDED_HOOK_EVENTS = [
|
||
'SubagentStart',
|
||
'Stop',
|
||
'PostToolUse',
|
||
'PreToolUse',
|
||
'PermissionRequest',
|
||
'PreCompact',
|
||
'PostCompact',
|
||
'SubagentStop',
|
||
'UserPromptSubmit',
|
||
];
|
||
// Codex's hook-enabling feature flag (issue #3566). Codex itself marks
|
||
// `codex_hooks` as a `legacy_key` in codex-rs/features/src/legacy.rs; the
|
||
// canonical current key under [features] is `hooks`. The installer always
|
||
// emits the canonical key going forward, recognizes legacy aliases as
|
||
// equivalent during reinstall, and migrates them forward on rewrite. The
|
||
// audit-marker string above is intentionally unchanged so existing
|
||
// installs' ownership lines continue to round-trip.
|
||
const CODEX_HOOKS_FEATURE_KEY = 'hooks';
|
||
const CODEX_HOOKS_FEATURE_LEGACY_KEYS = ['codex_hooks'];
|
||
const CODEX_HOOKS_FEATURE_ALL_KEYS = [CODEX_HOOKS_FEATURE_KEY, ...CODEX_HOOKS_FEATURE_LEGACY_KEYS];
|
||
function isCodexHooksFeatureKey(key) {
|
||
return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key);
|
||
}
|
||
|
||
// #768 \u2014 Claude Code permissions.allow entries.
|
||
// Pre-populated during Claude installs to eliminate first-run approval friction
|
||
// for msd-core's own known-safe tool calls. (The defense-in-depth deny entries
|
||
// for credential files that #768 also wrote are retired \u2014 see
|
||
// MSD_CLAUDE_LEGACY_DENY_PERMISSIONS below.)
|
||
//
|
||
// Format: each string uses Claude Code's documented permission rule syntax \u2014
|
||
// "Tool(pattern)" e.g. "Bash(npx msd-core *)", "Read(.planning/*)"
|
||
// "Tool" (bare tool name, no pattern)
|
||
//
|
||
// Merge policy: additive, non-destructive \u2014 existing user entries are preserved;
|
||
// MSD entries are appended only when not already present (idempotent).
|
||
// The reference/default runtime (ADR-1239 reference host). Single-sourced here
|
||
// instead of scattered literal 'claude' defaults/rosters (#2086).
|
||
const DEFAULT_RUNTIME = 'claude';
|
||
const MSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([
|
||
'Bash(npx msd-core *)',
|
||
'Read(.planning/*)',
|
||
'Edit(.planning/*)',
|
||
'Read(STATE.md)',
|
||
'Edit(STATE.md)',
|
||
]);
|
||
// #4221 \u2014 Retired deny rules. #768 wrote these three `Read()` deny rules
|
||
// into settings.json; Claude Code 2.1.259 hardened the Bash-side enforcement
|
||
// of Read() deny rules so that ANY such rule makes every
|
||
// `cd DIR && grep/cat relative-path` compound prompt for approval, even in
|
||
// `auto` permission mode \u2014 and MSD subagents emit hundreds of those per
|
||
// session. The same protection now ships as the managed PreToolUse hook
|
||
// hooks/msd-secret-read-guard.js (a hook denial is not a permission rule and
|
||
// never arms that check). Unlike the #2278 allow-side migration, there is no
|
||
// surviving "current" deny list: the constant is RENAMED to its legacy role
|
||
// and only ever filtered, never added. Byte-equal strings only \u2014 a user's
|
||
// own hand-written identical rule is indistinguishable and is removed too
|
||
// (the install manifest never recorded permission strings, so a
|
||
// manifest-gated cleanup is not possible).
|
||
const MSD_CLAUDE_LEGACY_DENY_PERMISSIONS = Object.freeze([
|
||
'Read(.env)',
|
||
'Read(.env.*)',
|
||
'Read(.secrets)',
|
||
]);
|
||
// #2278 — Stale allow-rule forms from before the fix. Claude Code has no
|
||
// standalone `Write` permission gate: file-editing tools (Write/Edit/
|
||
// NotebookEdit) are gated collectively via `Edit(pattern)`. The original
|
||
// `Write(.planning/*)` / `Write(STATE.md)` entries were therefore silently
|
||
// unmatched (never granted anything) and Claude Code additionally surfaces a
|
||
// session-start warning about unmatched permission rules. This list lets
|
||
// mergeClaudePermissions and uninstall cleanup retire those stale entries on
|
||
// existing installs while the current MSD_CLAUDE_ALLOW_PERMISSIONS above
|
||
// carries the working `Edit(...)` forms.
|
||
const MSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS = Object.freeze([
|
||
'Write(.planning/*)',
|
||
'Write(STATE.md)',
|
||
]);
|
||
|
||
/**
|
||
* Merge MSD-owned permission entries into a Claude Code settings object.
|
||
*
|
||
* Additive and idempotent: existing allow/deny entries are preserved; MSD
|
||
* entries are appended only if not already present. No other permission sub-keys
|
||
* (ask, disableBypassPermissionsMode, etc.) are touched.
|
||
*
|
||
* Migration (#2278): before adding the current MSD_CLAUDE_ALLOW_PERMISSIONS,
|
||
* any stale MSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS entry (e.g. the unmatched
|
||
* `Write(...)` forms from before the fix) is removed from permissions.allow,
|
||
* so existing installs end up with the working `Edit(...)` forms instead of
|
||
* both the dead legacy entry and its replacement sitting side by side.
|
||
*
|
||
* Migration (#4221): the retired MSD_CLAUDE_LEGACY_DENY_PERMISSIONS entries
|
||
* are removed from permissions.deny (byte-equal only). Nothing is added to
|
||
* deny any more: an absent `deny` key is left absent (never created as an
|
||
* empty array), and a `deny` array emptied BY THIS FILTER is deleted so the
|
||
* retirement leaves no `"deny": []` residue; a user's pre-existing empty
|
||
* `deny: []` is untouched.
|
||
*
|
||
* Defensive: if settings is not a plain object, returns immediately without
|
||
* throwing. If permissions.allow / permissions.deny exist but are not arrays
|
||
* (malformed settings), they are replaced with valid arrays.
|
||
*
|
||
* @param {object} settings - The parsed settings.json object to mutate in-place.
|
||
*/
|
||
function mergeClaudePermissions(settings) {
|
||
if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) return;
|
||
|
||
if (!settings.permissions || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)) {
|
||
settings.permissions = {};
|
||
}
|
||
|
||
if (!Array.isArray(settings.permissions.allow)) {
|
||
settings.permissions.allow = [];
|
||
}
|
||
if (settings.permissions.deny !== undefined && !Array.isArray(settings.permissions.deny)) {
|
||
settings.permissions.deny = [];
|
||
}
|
||
|
||
settings.permissions.allow = settings.permissions.allow.filter(
|
||
(e) => !MSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS.includes(e)
|
||
);
|
||
|
||
for (const entry of MSD_CLAUDE_ALLOW_PERMISSIONS) {
|
||
if (!settings.permissions.allow.includes(entry)) {
|
||
settings.permissions.allow.push(entry);
|
||
}
|
||
}
|
||
if (Array.isArray(settings.permissions.deny)) {
|
||
const before = settings.permissions.deny.length;
|
||
settings.permissions.deny = settings.permissions.deny.filter(
|
||
(e) => !MSD_CLAUDE_LEGACY_DENY_PERMISSIONS.includes(e)
|
||
);
|
||
if (settings.permissions.deny.length === 0 && before > 0) {
|
||
delete settings.permissions.deny;
|
||
}
|
||
}
|
||
}
|
||
|
||
// #777 — Cursor CLI lifecycle hook constants.
|
||
// Cursor reads hook configs from <project-root>/.cursor/hooks.json (local) or
|
||
// ~/.cursor/hooks.json (global) with the shape { version: 1, hooks: { <event>: [...] } }.
|
||
// Events use camelCase: sessionStart, postToolUse, preToolUse, etc.
|
||
// A `command` hook entry runs an external script. MSD registers six managed hooks
|
||
// (AC4a upgrade, #2089 — ADR-1239):
|
||
// sessionStart → msd-cursor-session-start.js (context injection)
|
||
// postToolUse → msd-cursor-post-tool.js (STATE.md update monitor)
|
||
// preToolUse → msd-cursor-pre-tool.js (write-path guard)
|
||
// stop → msd-cursor-stop.js (verify-work reminder)
|
||
// subagentStart → msd-cursor-subagent-start.js (subagent context injection)
|
||
// subagentStop → msd-cursor-subagent-stop.js (subagent completion reminder)
|
||
// Cursor docs: https://cursor.com/docs/hooks
|
||
//
|
||
// These script-name/marker constants used to be independently re-declared
|
||
// here with their own string literals — a second, unlinked copy of exactly
|
||
// the values runtime-hooks-surface.cts also defines for its own internal use
|
||
// (buildCursorHookEntry, writeCursorHooksJson, etc.). #2876's code review
|
||
// found tests reading the constant from install.js's copy while calling
|
||
// functions built from hooksSurface's copy, with nothing guarding the two
|
||
// staying equal — the same unlinked-duplicate-value hazard the ADR-1508
|
||
// dedup elsewhere in this file exists to prevent. Fixed at the root: these
|
||
// are now bare references to hooksSurface's own exports, so there is exactly
|
||
// one literal definition of each value, full stop.
|
||
const MSD_CURSOR_SESSION_HOOK_SCRIPT = hooksSurface.MSD_CURSOR_SESSION_HOOK_SCRIPT;
|
||
const MSD_CURSOR_POST_TOOL_HOOK_SCRIPT = hooksSurface.MSD_CURSOR_POST_TOOL_HOOK_SCRIPT;
|
||
const MSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = hooksSurface.MSD_CURSOR_PRE_TOOL_HOOK_SCRIPT;
|
||
const MSD_CURSOR_STOP_HOOK_SCRIPT = hooksSurface.MSD_CURSOR_STOP_HOOK_SCRIPT;
|
||
const MSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = hooksSurface.MSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT;
|
||
const MSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = hooksSurface.MSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT;
|
||
// All MSD-managed Cursor hook scripts (used by uninstall cleanup). Not
|
||
// independently defined in hooksSurface — built here from the bare
|
||
// references above, so it can never drift from them either.
|
||
const MSD_CURSOR_HOOK_SCRIPTS = [
|
||
MSD_CURSOR_SESSION_HOOK_SCRIPT,
|
||
MSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
|
||
MSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
|
||
MSD_CURSOR_STOP_HOOK_SCRIPT,
|
||
MSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
|
||
MSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
|
||
];
|
||
// Marker comment embedded in managed hook entries so MSD can find+remove them.
|
||
const MSD_CURSOR_HOOK_MARKER = hooksSurface.MSD_CURSOR_HOOK_MARKER;
|
||
|
||
// MSD-managed files under hooks/lib/ (helpers required by msd-*.js hooks).
|
||
// git-cmd.js does not start with "msd-" (shared classifier for #3129), msd-graphify-rebuild.sh does.
|
||
// cursor-workspace.js (#2587) is required by the Cursor lifecycle hooks. Those
|
||
// are staged individually by writeCursorHooksJson (Cursor sets
|
||
// hostBehaviors.skipSharedHooksInstall, so it never reaches the bulk hooks/lib
|
||
// copy below) — that function stages this helper alongside them. Listing it
|
||
// here keeps uninstall and the manifest managing it for every OTHER runtime
|
||
// that does receive hooks/lib.
|
||
// injection-patterns.js (#3504) is required by msd-prompt-guard.js and
|
||
// msd-read-injection-scanner.js — the shared prompt-injection pattern list the
|
||
// two guards require so their copies cannot drift.
|
||
const MSD_HOOK_LIB_FILES = ['git-cmd.js', 'msd-graphify-rebuild.sh', 'cursor-workspace.js', 'injection-patterns.js'];
|
||
|
||
/** Directory name MSD stages its shared hook bundle under, inside a runtime's install root. */
|
||
const SHARED_HOOKS_DIR = 'hooks';
|
||
|
||
// #3184 — MSD-managed file enumerations for scripts/changeset/ and scripts/lib/
|
||
// uninstall. The install-side copy of both directories is wholesale ("copy every
|
||
// file present"), so these enumerations MUST be kept in parity with the real
|
||
// directory contents or an added file ships on install and then orphans on
|
||
// uninstall (survives removal, keeps the dir non-empty, blocks its rmdir).
|
||
// Hoisted to module scope (and exported below) so tests/install.test.cjs can
|
||
// assert parity against fs.readdirSync(scripts/lib) / fs.readdirSync(scripts/changeset)
|
||
// without source-grepping this file.
|
||
const MSD_CHANGESET_FILES = [
|
||
'cli.cjs', 'parse.cjs', 'render.cjs', 'serialize.cjs',
|
||
'github-release-notes.cjs', 'lint.cjs', 'new.cjs',
|
||
'README.md', // documentation only — not user-authored
|
||
];
|
||
const MSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs', 'drift-scan.cjs', 'alias-drift-families.cjs', 'exit-code-registry.cjs', 'ndjson-reporter.cjs', 'ci-job-timing.cjs', 'shellcheck-fetch.cjs', 'npm-version-check-diagnosis.cjs', 'platform-conformance-tier.generated.cjs', 'suite-detection.cjs', 'macos-conformance-tier.generated.cjs'];
|
||
|
||
// #4544 — the Codex hook payload the install stages into <targetDir>/hooks/.
|
||
// Hoisted to module scope (the #3184 precedent above) so the rollback's
|
||
// incomplete-capture path can name exactly the files MSD owns without a
|
||
// second copy of the list drifting away from the staging site, which lives
|
||
// inside the Codex config block where the constant used to be declared.
|
||
const CODEX_HOOKS_TO_COPY = [
|
||
'msd-check-update.js',
|
||
'msd-check-update-worker.js',
|
||
'managed-hooks-registry.cjs',
|
||
];
|
||
|
||
// #3897 rung 3 — sandbox_mode derivation, the hold list, and the hold-roster
|
||
// validator now live in `src/codex-agent-toml.cts` (compiled to
|
||
// `msd-core/bin/lib/codex-agent-toml.cjs`), NOT here. This module used to be
|
||
// the sole owner, and `agent-install-check.cts`'s `checkCodexSandboxPosture`
|
||
// lazily `require()`d THIS FILE to reach `deriveCodexSandboxMode` — but
|
||
// requiring `bin/install.js` runs its whole top-level script, including the
|
||
// CLI's ASCII banner print to stdout, which corrupted every stdout-JSON
|
||
// caller downstream of that posture check (`msd-tools validate agents`).
|
||
// `codex-agent-toml.cjs` is a genuine leaf (no top-level side effects), so
|
||
// both this file and `agent-install-check.cts` import the derivation from
|
||
// there — ONE owner, no second predicate. See that module's header for the
|
||
// full rationale, and CAUSE B (below, `installCodexConfig`) for the removal
|
||
// of `validateCodexSandboxHolds`'s call from the install runtime path.
|
||
const {
|
||
CODEX_SANDBOX_HOLDS,
|
||
deriveCodexSandboxMode,
|
||
validateCodexSandboxHolds,
|
||
// #3897 list-form parse fix, Fix 3 (generative-fix-divergence): this
|
||
// file's own `generateCodexAgentToml` used to pull `tools:` via its
|
||
// private `extractFrontmatterField` (single-line only) instead of this
|
||
// shared reader — the two sandbox-feeding paths (this emitter and
|
||
// `agent-install-check.cts`'s `checkCodexSandboxPosture`) silently
|
||
// disagreed on YAML block-list `tools:` form. Both now route through this
|
||
// ONE extractor.
|
||
extractToolsValue,
|
||
} = require(path.join(__dirname, '..', 'msd-core', 'bin', 'lib', 'codex-agent-toml.cjs'));
|
||
|
||
// Get version from package.json
|
||
const pkg = require('../package.json');
|
||
|
||
// #2517 — runtime-aware tier resolution shared with core.cjs.
|
||
// Hoisted to top with absolute __dirname-based paths so `msd install codex` works
|
||
// when invoked via npm global install (cwd is the user's project, not the msd repo
|
||
// root). Inline `require('../msd-core/...')` from inside install functions
|
||
// works only because Node resolves it relative to the install.js file regardless
|
||
// of cwd, but keeping the require at the top makes the dependency explicit and
|
||
// surfaces resolution failures at process start instead of at first install call.
|
||
const _msdLibDir = path.join(__dirname, '..', 'msd-core', 'bin', 'lib');
|
||
const {
|
||
RUNTIME_PROFILE_MAP: MSD_RUNTIME_PROFILE_MAP,
|
||
isAnthropicFlavoredModel: msdIsAnthropicFlavoredModel,
|
||
} = require(path.join(_msdLibDir, 'model-catalog.cjs'));
|
||
// #4145: shared hash-first recovery for msd-pristine/ baselines stored at an
|
||
// unexpected path (e.g. without the msd-core/ prefix an earlier release's
|
||
// writer dropped). Same module the reapply verifier uses, so the two readers
|
||
// cannot drift apart again.
|
||
const {
|
||
findPristineByHash: msdFindPristineByHash,
|
||
} = require(path.join(_msdLibDir, 'pristine-baseline.cjs'));
|
||
// #2875 Part 2: MODEL_PROFILES + resolveTierEntry are now consumed only by
|
||
// install-model-override-resolver.cjs's readMsdRuntimeProfileResolver
|
||
// (required below) — this installer no longer needs its own bindings.
|
||
|
||
// #2071 — install-time effort resolution (readMsdEffectiveEffortConfig /
|
||
// resolveInstallTimeEffort, plus their _getMsdEffortCatalog + _readMsdConfigFile
|
||
// helpers) was extracted into the shipped msd-core/bin/lib/install-effort-resolver.cjs
|
||
// so `msd-tools effort sync` can require it from the installed runtime instead of this
|
||
// package-root bin/install.js, which the installer never copies (#2071 crash). The
|
||
// installer imports it back here — single source of truth for both surfaces.
|
||
const {
|
||
readMsdEffectiveEffortConfig,
|
||
resolveInstallTimeEffort,
|
||
_getMsdEffortCatalog,
|
||
_readMsdConfigFile,
|
||
} = require(path.join(_msdLibDir, 'install-effort-resolver.cjs'));
|
||
|
||
const {
|
||
MINIMAL_SKILL_ALLOWLIST,
|
||
PROFILES,
|
||
isMinimalMode,
|
||
stageSkillsForMode,
|
||
readActiveProfile,
|
||
writeActiveProfile,
|
||
resolveEffectiveProfile,
|
||
mostRestrictiveProfile,
|
||
resolveProfile,
|
||
loadSkillsManifest,
|
||
stageSkillsForProfile,
|
||
stageAgentsForProfile,
|
||
stageSkillsForRuntimeAsSkills,
|
||
} = require(path.join(_msdLibDir, 'install-profiles.cjs'));
|
||
// ADR-857 phase 4c: load capability registry (optional; missing → falls back to undefined)
|
||
let _capabilityRegistry;
|
||
try {
|
||
_capabilityRegistry = require(path.join(_msdLibDir, 'capability-registry.cjs'));
|
||
} catch (_) {
|
||
_capabilityRegistry = undefined;
|
||
}
|
||
|
||
// #2322 BLOCKER 2: `_capabilityRegistry` above is the FROZEN first-party registry
|
||
// (capability-registry.cjs, built at publish time) — it never reflects an
|
||
// INSTALLED third-party overlay capability, so a fresh `msd install` could never
|
||
// stage an installed third-party capability's skill regardless of registration,
|
||
// even on the DEFAULT `--profile full`. `_installedCapabilityRegistry` composes
|
||
// the overlay via capability-loader's `loadRegistry({includeInstalled:true})` —
|
||
// the SAME call capability-writer.cts's `capability set --runtime` path already
|
||
// uses — so a fresh install and a post-install `capability set` agree on
|
||
// third-party skill availability. Used ONLY for skill-profile resolution and
|
||
// runtime-artifact-layout staging below; `_capabilityRegistry` (frozen) remains
|
||
// the source for msd-core's OWN runtime/host-behavior descriptors (unaffected —
|
||
// those are always first-party). A load failure degrades to the frozen
|
||
// `_capabilityRegistry` (no overlay data -> no third-party skills staged; never
|
||
// a crash and never a scan-and-guess fallback).
|
||
let _installedCapabilityRegistry;
|
||
try {
|
||
const _capabilityLoader = require(path.join(_msdLibDir, 'capability-loader.cjs'));
|
||
_installedCapabilityRegistry = _capabilityLoader.loadRegistry({
|
||
includeInstalled: true,
|
||
cwd: process.cwd(),
|
||
msdHome: process.env['MSD_HOME'],
|
||
});
|
||
} catch (_) {
|
||
_installedCapabilityRegistry = _capabilityRegistry;
|
||
}
|
||
|
||
// Fail-safe floor for the reference host's #338-privacy-critical behaviors, used
|
||
// ONLY when the first-party capability registry cannot be loaded (a broken bundle).
|
||
// Without it, a registry-load failure would make `_hostBehaviors('claude')` return
|
||
// {} and silently route a claude LOCAL install to the repo-shared, committed
|
||
// `settings.json` instead of the gitignored `settings.local.json` (#338) — leaking
|
||
// engineer-specific absolute paths. Keyed by runtime id (a DATA lookup, not a
|
||
// hardcoded string-equality branch) so behavior degrades CLOSED (safe), never open.
|
||
// The live descriptor (capabilities/claude/capability.json) remains the source of
|
||
// truth; this mirrors only the privacy-load-bearing subset. (ADR-1239 / #2086)
|
||
//
|
||
// #2870: NOT routed through the Install Scope Module (resolveScope,
|
||
// src/install-scope.cts) despite that module owning per-scope settings-file
|
||
// resolution elsewhere in this file. resolveScope's own descriptor lookup
|
||
// goes through the SAME capability registry require this floor exists to
|
||
// survive the failure of (see getRegistry() in install-scope.cts) — so on
|
||
// exactly the "registry failed to load" path this constant is for,
|
||
// resolveScope would throw too. Routing through it here would trade a
|
||
// graceful, documented degrade for a crash in the one case this floor was
|
||
// added to prevent. This hardcoded literal is the correct, honest answer,
|
||
// not an un-migrated leftover.
|
||
const FALLBACK_HOST_BEHAVIORS = Object.freeze({
|
||
claude: Object.freeze({
|
||
settingsFileByScope: Object.freeze({ local: 'settings.local.json', global: 'settings.json' }),
|
||
permissionsSchema: 'claude',
|
||
sourceMarkerFile: '.msd-source',
|
||
hyphenNameAgentBody: true,
|
||
legacyCommandsMsdInstallMigration: true,
|
||
legacyCommandsMsdUninstall: 'global',
|
||
}),
|
||
// antigravity's global config dir is resolved dynamically (env-overridable,
|
||
// multi-segment) via resolveAntigravityGlobalDir in getConfigDirFromHome. If the
|
||
// registry fails to load, this floor keeps that routing intact instead of
|
||
// silently falling through to the generic getGlobalConfigHomeFragment default
|
||
// (which would return the wrong '.claude' fragment). (ADR-1239 / #2096)
|
||
antigravity: Object.freeze({ globalDirResolver: 'antigravity' }),
|
||
});
|
||
|
||
/**
|
||
* Resolve a runtime's host behaviors from a capability registry, with the
|
||
* #338-privacy fail-safe floor when the registry (or the runtime's descriptor)
|
||
* is unavailable. Registry is passed in so this is unit-testable under a
|
||
* simulated registry-load failure. (ADR-1239 / #2086)
|
||
*/
|
||
function _resolveHostBehaviors(runtime, registry) {
|
||
const cap = registry && registry.runtimes && registry.runtimes[runtime];
|
||
const declared = cap && cap.runtime && cap.runtime.hostBehaviors;
|
||
if (declared) return declared;
|
||
return FALLBACK_HOST_BEHAVIORS[runtime] || {};
|
||
}
|
||
|
||
/**
|
||
* Host-specific install behaviors, declared on the runtime descriptor
|
||
* (capabilities/<runtime>/capability.json -> runtime.hostBehaviors) instead of
|
||
* scattered `runtime === '<id>'` string checks (ADR-1239 / #2086). Returns {}
|
||
* for runtimes that declare none, so every behavior branch degrades to the
|
||
* generic path by default — EXCEPT the reference host's #338-critical keys, which
|
||
* fall back to FALLBACK_HOST_BEHAVIORS if the registry failed to load.
|
||
*/
|
||
function _hostBehaviors(runtime) {
|
||
return _resolveHostBehaviors(runtime, _capabilityRegistry);
|
||
}
|
||
|
||
/**
|
||
* #2870: shared install()/uninstall() scope resolution. Routes `id` through
|
||
* the Install Scope Module (src/install-scope.cts) and degrades to `null` on
|
||
* failure (unknown/non-installable runtime, broken registry bundle) instead
|
||
* of throwing, so each call site's own plain-id fallback keeps working
|
||
* exactly as it did before this migration. Both call sites previously carried
|
||
* their own copy of this try/catch; this is the one shared copy.
|
||
*/
|
||
function _resolveScopeSafe(id, runtime) {
|
||
try {
|
||
return resolveScope({ id, runtime });
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve a layout kind's on-disk destination directory. Shared by the
|
||
* skills-root resolution above and the #3664 warning below so the
|
||
* home-override + destSubpath join exists once (#3659-class re-encoding guard).
|
||
*/
|
||
function _kindDestDir(layout, kindName, targetDir) {
|
||
const kind = layout.kinds.find((k) => k.kind === kindName);
|
||
if (!kind) return null;
|
||
return path.join(kind.home || targetDir, kind.destSubpath);
|
||
}
|
||
|
||
/**
|
||
* #3738: scope-aware, layout-resolving wrapper over _kindDestDir for callers
|
||
* that have (runtime, configDir, scope) rather than a resolved Layout — the
|
||
* writeManifest agents surface being the first. Never throws: a runtime whose
|
||
* layout cannot be resolved (unknown id, descriptor error) keeps the caller's
|
||
* own fallback rather than losing the manifest.
|
||
*/
|
||
function _kindDestDirSafe(runtime, configDir, scope, kindName) {
|
||
try {
|
||
return _kindDestDir(resolveRuntimeArtifactLayout(runtime, configDir, scope), kindName, configDir);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* #3664 — warn (never refuse) when `--config-dir` points the install at a
|
||
* directory whose agent destination already holds FOREIGN (non-MSD) agent
|
||
* files — the fingerprint of another harness's config home (e.g. ~/.junie,
|
||
* ~/.factory) or a hand-curated agents dir. MSD emits the selected runtime's
|
||
* artifacts verbatim: tool IDs (`Skill`) and MCP grants (`mcp__server__tool`)
|
||
* that are inert or invalid in a foreign harness surface only at dispatch
|
||
* time, months later. Warn-and-proceed is the issue-sanctioned option (b):
|
||
* a fresh custom dir (the documented brand-specific-dir use), a msd-only dir
|
||
* (updates, the --all shared dir), and the no-flag default-home path
|
||
* (users keep personal agents in ~/.claude/agents) all stay silent. Degrades
|
||
* silently on any resolution failure — the warn path never blocks install.
|
||
*/
|
||
function warnIfForeignAgentDest(runtime, targetDir, scope, explicitConfigDir) {
|
||
if (explicitConfigDir !== true) return;
|
||
try {
|
||
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
|
||
const agentsDir = _kindDestDir(layout, 'agents', targetDir);
|
||
if (!agentsDir) return;
|
||
if (!fs.existsSync(agentsDir) || !fs.statSync(agentsDir).isDirectory()) return;
|
||
const foreign = fs
|
||
.readdirSync(agentsDir)
|
||
.filter((f) => f.endsWith('.md') && !f.startsWith('msd-') && f !== 'msd.md');
|
||
if (foreign.length === 0) return;
|
||
console.log(
|
||
` ${yellow}⚠${reset} ${bold}${targetDir}${reset} already contains ${foreign.length} non-MSD agent file(s) — this may be another harness's config home. MSD emits artifacts shaped for ${runtime}: tool IDs and MCP grants may be inert or invalid for whatever harness reads this directory (#3664).`
|
||
);
|
||
} catch (_) {
|
||
/* never block install on the warning path */
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a
|
||
* skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills ->
|
||
* $HOME/.agents/skills instead of the runtime's configDir). Descriptor-driven
|
||
* (no runtime === '<id>' check) so the snapshot/rollback machinery and post-install
|
||
* verification look where the skills actually landed. Falls back to <targetDir>/skills.
|
||
*/
|
||
function _resolveSkillsRootDir(runtime, targetDir, scope) {
|
||
try {
|
||
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
|
||
const skillsDir = _kindDestDir(layout, 'skills', targetDir);
|
||
if (skillsDir) return skillsDir;
|
||
} catch (_e) { /* fall through to the configDir default */ }
|
||
return path.join(targetDir, 'skills');
|
||
}
|
||
|
||
/**
|
||
* Construct the imperative Host-Integration adapter (ADR-1239 / #2086), FAIL-OPEN.
|
||
* `createImperativeAdapter` composes the capability registry via
|
||
* `loadRegistry({includeInstalled:true})`, which require()s several capability
|
||
* modules. If any is unavailable (e.g. a packaging regression), return null so
|
||
* the caller degrades to the engine directly rather than hard-crashing install/
|
||
* uninstall — matching the optional `capability-registry.cjs` load posture above.
|
||
*/
|
||
function _runtimeAdapter(runtime) {
|
||
try {
|
||
return createImperativeAdapter({ runtime });
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
const {
|
||
acquireInstallMigrationLock,
|
||
applyInstallerMigrationPlan,
|
||
discoverInstallerMigrations,
|
||
MANIFEST_SCHEMA_VERSION,
|
||
readInstallManifest,
|
||
runInstallerMigrations,
|
||
} = require(path.join(_msdLibDir, 'installer-migrations.cjs'));
|
||
const {
|
||
assertInstallerMigrationsUnblocked,
|
||
resolveInstallerMigrationPromptsForNonTty,
|
||
summarizeInstallerMigrationResult,
|
||
} = require(path.join(_msdLibDir, 'installer-migration-report.cjs'));
|
||
const {
|
||
resolveRuntimeArtifactLayout,
|
||
} = require(path.join(_msdLibDir, 'runtime-artifact-layout.cjs'));
|
||
const {
|
||
readSurface,
|
||
resolveSurface,
|
||
} = require(path.join(_msdLibDir, 'surface.cjs'));
|
||
const {
|
||
assertDestWithinConfigHome,
|
||
createRuntimeArtifactInstallPlan,
|
||
createRuntimeArtifactUninstallPlan,
|
||
} = require(path.join(_msdLibDir, 'runtime-artifact-install-plan.cjs'));
|
||
const {
|
||
planLegacyCleanup,
|
||
applyLegacyCleanup,
|
||
} = require(path.join(__dirname, '..', 'msd-core', 'bin', 'lib', 'legacy-cleanup.cjs'));
|
||
const {
|
||
updateCacheFileName,
|
||
PACKAGE_NAME,
|
||
} = require(path.join(__dirname, '..', 'msd-core', 'bin', 'lib', 'package-identity.cjs'));
|
||
|
||
// ADR-1239 Phase B: runtime-artifact install cluster extracted to install-engine.cjs.
|
||
// getCommitAttribution STAYS here (impure install-time config I/O); it is injected
|
||
// into the engine functions via the resolveAttribution parameter at each call site.
|
||
const installEngine = require(path.join(_msdLibDir, 'install-engine.cjs'));
|
||
// #2876: _copyStaged and convertClaudeCommandToOpencodeSkill used to be
|
||
// destructured here too — both had no install.js internal caller and no
|
||
// export consumer (tests import them directly from
|
||
// msd-core/bin/lib/install-engine.cjs), so the retired bindings were dead
|
||
// code. applyOpencodeFamilyPathPrefix, _runLegacyInstallMigrations,
|
||
// _runLegacyUninstallCleanup, _removeMsdEntries, and _restoreDir were the
|
||
// same — unused local bindings that were never part of this module's export
|
||
// surface either — found and retired in the same sweep.
|
||
const {
|
||
installRuntimeArtifacts,
|
||
uninstallRuntimeArtifacts,
|
||
installOpencodeFamilySkills,
|
||
installAgentsKindStandalone,
|
||
hasExistingSymlinkBetween,
|
||
isSymlinkedDestOptIn,
|
||
USER_OWNED_ARTIFACTS,
|
||
_snapshotDir,
|
||
} = installEngine;
|
||
|
||
// #2875 (epic #2866 Phase 6): durable on-disk staging for USER_OWNED_ARTIFACTS
|
||
// across the preserve -> wipe -> restore window (#1874-F19). See
|
||
// src/user-artifact-staging.cts's module doc.
|
||
const {
|
||
stageUserArtifacts,
|
||
restoreStagedUserArtifacts,
|
||
discardStagedUserArtifacts,
|
||
recoverOrphanedUserArtifacts,
|
||
} = require(path.join(_msdLibDir, 'user-artifact-staging.cjs'));
|
||
|
||
/**
|
||
* Resolve the durable staging root for `configDir`, confined via
|
||
* `assertDestWithinConfigHome` and refused via `hasExistingSymlinkBetween`
|
||
* (test-matrix E1/E4) — mirrors install-engine.cts's
|
||
* `_resolveUserArtifactStagingRoot`, kept local here because bin/install.js's
|
||
* two call sites (uninstall's legacy-migration block, install's mainline
|
||
* msd-core copy) are not inside that module.
|
||
*/
|
||
function _resolveUserArtifactStagingRoot(configDir) {
|
||
const stagingRoot = assertDestWithinConfigHome(configDir, path.posix.join('.msd-staging', 'user-artifacts'));
|
||
if (hasExistingSymlinkBetween(path.resolve(configDir), stagingRoot, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
||
throw new Error(
|
||
`_resolveUserArtifactStagingRoot: staging root "${stagingRoot}" contains a symlink the install root "${configDir}" does not trust — refusing to stage. If this is an intentional user-owned symlink layout, re-run with MSD_ALLOW_SYMLINKED_DEST=1.`,
|
||
);
|
||
}
|
||
return stagingRoot;
|
||
}
|
||
|
||
/**
|
||
* Degrade-not-abort wrapper over `_resolveUserArtifactStagingRoot` — mirrors
|
||
* install-engine.cts's own `_tryResolveUserArtifactStagingRoot` (kept local
|
||
* here for the same reason the throwing version above is: bin/install.js's
|
||
* own call sites are not inside that module). A hostile/broken
|
||
* `.msd-staging` path (or a symlinked configDir itself) must never brick
|
||
* `install()` or `uninstall()` — before this fix, `_resolveUserArtifactStagingRoot`
|
||
* was called UNGUARDED as the first statement of both, so
|
||
* `ln -s /nonexistent ~/.claude/.msd-staging` killed both commands, including
|
||
* uninstall, the remedy for the first problem. Returns `null` (never throws),
|
||
* logging one warning; every call site MUST treat `null` as "skip the
|
||
* staging-dependent step for this run".
|
||
*/
|
||
function _tryResolveUserArtifactStagingRoot(configDir) {
|
||
try {
|
||
return _resolveUserArtifactStagingRoot(configDir);
|
||
} catch (err) {
|
||
console.warn(` ${yellow}!${reset} user-artifact staging unavailable for "${configDir}" (${err.message}) — proceeding without durable staging for this step.`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Parse args
|
||
const args = process.argv.slice(2);
|
||
const hasGlobal = args.includes('--global') || args.includes('-g');
|
||
const hasLocal = args.includes('--local') || args.includes('-l');
|
||
const hasUninstall = args.includes('--uninstall') || args.includes('-u');
|
||
const hasSkillsRoot = args.includes('--skills-root');
|
||
const hasPortableHooks = args.includes('--portable-hooks') || process.env.MSD_PORTABLE_HOOKS === '1';
|
||
const hasMinimal = args.includes('--minimal') || args.includes('--core-only');
|
||
const hasDryRun = args.includes('--dry-run');
|
||
// #4377: emit project-relative `@` includes (`.claude/msd-core/...`) for a
|
||
// LOCAL install instead of this checkout's absolute path.
|
||
//
|
||
// Opt-in, and it stays opt-in: absolute includes work for a single checkout,
|
||
// which is nearly everyone, and flipping the default would change every
|
||
// existing local install to solve a problem those users do not have. The
|
||
// people who need it know they do — they run the same repo from several git
|
||
// worktrees, where a baked absolute path means every worktree reads its
|
||
// workflow prose out of whichever checkout happened to run the installer, and
|
||
// updating that one checkout breaks all the others at once with no way to
|
||
// stage it.
|
||
//
|
||
// Exported through the environment rather than threaded as a parameter,
|
||
// exactly like --portable-hooks/MSD_PORTABLE_HOOKS above: five separate seams
|
||
// compute a path prefix (the install engine, both rewrite entry points, the
|
||
// install plan, and applySurface), and one variable they all read cannot fall
|
||
// out of sync the way five signatures can.
|
||
const hasRelativeIncludes = args.includes('--relative-includes') || process.env.MSD_RELATIVE_INCLUDES === '1';
|
||
if (hasRelativeIncludes) process.env.MSD_RELATIVE_INCLUDES = '1';
|
||
// --profile=<name> or --profile=<n1>,<n2> (composable); mutually exclusive with --minimal
|
||
const _profileArgRaw = (() => {
|
||
for (const arg of args) {
|
||
if (arg.startsWith('--profile=')) return arg.slice('--profile='.length);
|
||
}
|
||
return null;
|
||
})();
|
||
// Resolve active profile name:
|
||
// 1. --minimal / --core-only → 'core' (back-compat alias)
|
||
// 2. --profile=<name> → named profile
|
||
// 3. neither → 'full' (default, back-compat)
|
||
// Note: when re-running as `msd update` the marker is read later (after
|
||
// configDir is resolved) and may override 'full' — see writeActiveProfile call below.
|
||
const _profileIsCore = _profileArgRaw === 'core';
|
||
const _requestedProfileName = (hasMinimal || _profileIsCore) ? 'core' : (_profileArgRaw || null);
|
||
|
||
if (hasMinimal && _profileArgRaw) {
|
||
console.error(` ${yellow}Cannot specify both --minimal/--core-only and --profile${reset}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
function selectRuntimesFromArgs(runtimeArgs) {
|
||
if (runtimeArgs.includes('--all')) {
|
||
return ['claude', 'opencode', 'codex', 'antigravity', 'cursor', 'zcode'];
|
||
}
|
||
if (runtimeArgs.includes('--both')) {
|
||
return ['claude', 'opencode'];
|
||
}
|
||
|
||
const selected = [];
|
||
if (runtimeArgs.includes('--claude')) selected.push('claude');
|
||
if (runtimeArgs.includes('--opencode')) selected.push('opencode');
|
||
if (runtimeArgs.includes('--codex')) selected.push('codex');
|
||
if (runtimeArgs.includes('--antigravity')) selected.push('antigravity');
|
||
if (runtimeArgs.includes('--cursor')) selected.push('cursor');
|
||
if (runtimeArgs.includes('--zcode')) selected.push('zcode');
|
||
return selected;
|
||
}
|
||
|
||
// Runtime selection - can be set by flags or interactive prompt
|
||
let selectedRuntimes = selectRuntimesFromArgs(args);
|
||
|
||
// #1928: Google sunset Gemini CLI on 2026-06-18; Antigravity CLI is its
|
||
// official successor. `--gemini` is no longer a valid runtime selector —
|
||
// selectRuntimesFromArgs above no longer recognizes it, so it never lands in
|
||
// selectedRuntimes. Print a one-time redirect notice, and — when `--gemini`
|
||
// was the ONLY runtime flag supplied (selectedRuntimes is empty) — exit
|
||
// deterministically rather than silently falling through to the "no runtime
|
||
// specified" defaults below (which would install Claude Code, surprising a
|
||
// user who explicitly asked for Gemini). Other flags (e.g. `--codex`) still
|
||
// parse and install normally alongside the notice.
|
||
if (args.includes('--gemini')) {
|
||
const wantsHelp = args.includes('--help') || args.includes('-h');
|
||
console.error('Gemini CLI was sunset by Google on 2026-06-18 and is no longer served for free/Pro/Ultra tiers.');
|
||
console.error('MSD now supports Antigravity CLI (the official successor). Re-run with: --antigravity');
|
||
if (hasUninstall) {
|
||
// The gemini runtime was removed (#1928), so there is no automated
|
||
// `--gemini --uninstall`. Guide manual cleanup and exit — do NOT fall
|
||
// through to the uninstall dispatch below, which defaults an empty runtime
|
||
// selection to 'claude' and would wrongly uninstall the user's Claude install.
|
||
console.error('The gemini runtime was removed, so `--gemini --uninstall` is no longer available.');
|
||
console.error('To remove a prior Gemini install, delete MSD files under your Gemini config dir');
|
||
console.error('(e.g. ~/.gemini/commands/msd) and MSD hook entries in ~/.gemini/settings.json.');
|
||
process.exit(1);
|
||
}
|
||
// For `--gemini --help`, fall through so the usage block still prints. For a
|
||
// bare install attempt (no other runtime selected), exit rather than silently
|
||
// installing Claude.
|
||
if (!wantsHelp && selectedRuntimes.length === 0) {
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// WSL + Windows Node.js detection
|
||
// When Windows-native Node runs on WSL, os.homedir() and path.join() produce
|
||
// backslash paths that don't resolve correctly on the Linux filesystem.
|
||
if (process.platform === 'win32') {
|
||
let isWSL = false;
|
||
try {
|
||
if (process.env.WSL_DISTRO_NAME) {
|
||
isWSL = true;
|
||
} else if (fs.existsSync('/proc/version')) {
|
||
const procVersion = fs.readFileSync('/proc/version', 'utf8').toLowerCase();
|
||
if (procVersion.includes('microsoft') || procVersion.includes('wsl')) {
|
||
isWSL = true;
|
||
}
|
||
}
|
||
} catch {
|
||
// Ignore read errors — not WSL
|
||
}
|
||
|
||
if (isWSL) {
|
||
console.error(`
|
||
${yellow}⚠ Detected WSL with Windows-native Node.js.${reset}
|
||
|
||
This causes path resolution issues that prevent correct installation.
|
||
Please install a Linux-native Node.js inside WSL:
|
||
|
||
curl -fsSL https://fnm.vercel.app/install | bash
|
||
fnm install --lts
|
||
|
||
Then re-run: npx ${pkg.name}@latest
|
||
`);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// getDirName (runtime -> local config dir name) now lives in
|
||
// runtime-name-policy.cjs (ADR-1508 / #1510 Phase 1); imported above for
|
||
// install.js's own internal use only — #2876 retired the re-export.
|
||
|
||
/**
|
||
* Get the config directory path relative to home directory for a runtime
|
||
* Used for templating hooks that use path.join(homeDir, '<configDir>', ...)
|
||
* @param {string} runtime - 'claude', 'opencode', 'codex', ...
|
||
* @param {boolean} isGlobal - Whether this is a global install
|
||
*/
|
||
function getConfigDirFromHome(runtime, isGlobal) {
|
||
if (!isGlobal) {
|
||
// Local installs use the same dir name pattern
|
||
return `'${getDirName(runtime)}'`;
|
||
}
|
||
// Global installs. antigravity's home is resolved dynamically (env-overridable,
|
||
// multi-segment via resolveAntigravityGlobalDir + path.relative) — not a table
|
||
// entry. (The prior inner `if (!isGlobal) return "'.agents'"` was unreachable:
|
||
// !isGlobal returns at the top of this function.)
|
||
// Descriptor-driven (ADR-1239 / #2096): folded from a hardcoded
|
||
// `runtime === 'antigravity'` literal into a read of the runtime's
|
||
// `hostBehaviors.globalDirResolver` descriptor field (via _hostBehaviors, which
|
||
// also degrades to FALLBACK_HOST_BEHAVIORS on registry-load failure). This is
|
||
// antigravity-unique: `globalDirResolver` is only set by antigravity.
|
||
if (_hostBehaviors(runtime).globalDirResolver === 'antigravity') {
|
||
const antigravityDir = resolveAntigravityGlobalDir();
|
||
const rel = path.relative(os.homedir(), antigravityDir);
|
||
const segments = rel.split(path.sep).filter(Boolean);
|
||
if (segments.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||
return segments.map((seg) => `'${seg}'`).join(', ');
|
||
}
|
||
// If resolution points outside HOME (e.g. via env override), keep the
|
||
// stable legacy template so generated path.join() calls remain valid.
|
||
return "'.gemini', 'antigravity'";
|
||
}
|
||
// All other runtimes: single source-of-truth fragment table (ADR-1239 Phase B,
|
||
// #1679). claude/unknown fall through to the table's default '.claude'.
|
||
return getGlobalConfigHomeFragment(runtime);
|
||
}
|
||
|
||
/**
|
||
* Compatibility seam for tests and older installer consumers.
|
||
* Runtime home resolution now lives in runtime-homes.cjs.
|
||
*/
|
||
function getGlobalDir(runtime, explicitDir = null) {
|
||
return getGlobalConfigDir(runtime, explicitDir);
|
||
}
|
||
const banner = '\n' +
|
||
cyan + ' ███╗ ███╗███████╗██████╗\n' +
|
||
' ████╗ ████║██╔════╝██╔══██╗\n' +
|
||
' ██╔████╔██║███████╗██║ ██║\n' +
|
||
' ██║╚██╔╝██║╚════██║██║ ██║\n' +
|
||
' ██║ ╚═╝ ██║███████║██████╔╝\n' +
|
||
' ╚═╝ ╚═╝╚══════╝╚═════╝' + reset + '\n' +
|
||
'\n' +
|
||
' MSD Core ' + dim + 'v' + pkg.version + reset + '\n' +
|
||
' Make Software Done.\n' +
|
||
' A meta-prompting, context engineering and spec-driven\n' +
|
||
' development workflows for Claude Code, Codex, OpenCode, Cursor, ZCode and Antigravity.\n';
|
||
|
||
// Pure seam: parse --config-dir / -c from an arbitrary args array.
|
||
// Returns the path string, '' for an empty equals-form value, or null when the
|
||
// flag is absent. Space-separated form returns null (not an error string) when
|
||
// the next token is missing or flag-looking — callers that want process.exit
|
||
// behaviour must check after calling this function.
|
||
// Exported via module.exports so unit tests can exercise it directly.
|
||
function parseConfigDirFromArgs(argsArray) {
|
||
const configDirIndex = argsArray.findIndex(arg => arg === '--config-dir' || arg === '-c');
|
||
if (configDirIndex !== -1) {
|
||
const nextArg = argsArray[configDirIndex + 1];
|
||
// No value / next token is a flag → signal "missing" by returning null
|
||
if (!nextArg || nextArg.startsWith('-')) {
|
||
return null;
|
||
}
|
||
return nextArg;
|
||
}
|
||
// Handle --config-dir=value and -c=value format.
|
||
// Use indexOf('=') + 1 so that = signs inside the path value are preserved.
|
||
const configDirArg = argsArray.find(arg => arg.startsWith('--config-dir=') || arg.startsWith('-c='));
|
||
if (configDirArg) {
|
||
return configDirArg.slice(configDirArg.indexOf('=') + 1);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Parse --no-legacy-cleanup (#3799) — skip the legacy get-shit-done-cc scan
|
||
// entirely. Some users run msd-core alongside a live legacy install on
|
||
// purpose; the scan is best-effbelt cleanup, never load-bearing for the
|
||
// install itself.
|
||
function parseNoLegacyCleanupArg(args = process.argv) {
|
||
return args.includes('--no-legacy-cleanup');
|
||
}
|
||
|
||
// Parse --config-dir argument
|
||
function parseConfigDirArg() {
|
||
const result = parseConfigDirFromArgs(args);
|
||
if (result === null) {
|
||
// Check if the space-separated form was present but missing a value
|
||
const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c');
|
||
if (configDirIndex !== -1) {
|
||
console.error(` ${yellow}--config-dir requires a path argument${reset}`);
|
||
process.exit(1);
|
||
}
|
||
return null;
|
||
}
|
||
if (result === '') {
|
||
console.error(` ${yellow}--config-dir requires a non-empty path${reset}`);
|
||
process.exit(1);
|
||
}
|
||
return result;
|
||
}
|
||
const explicitConfigDir = parseConfigDirArg();
|
||
const hasHelp = args.includes('--help') || args.includes('-h');
|
||
const forceStatusline = args.includes('--force-statusline');
|
||
|
||
if (!hasSkillsRoot) console.log(banner);
|
||
|
||
if (hasUninstall) {
|
||
console.log(' Mode: Uninstall\n');
|
||
}
|
||
|
||
// Show help if requested
|
||
if (hasHelp) {
|
||
console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall MSD (remove all MSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}--no-legacy-cleanup${reset} Skip the legacy get-shit-done-cc artifact scan\n (an explicit --config-dir already scopes the scan to it)\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n and resolve the node runner at hook-fire time via\n hooks/msd-node-runner.sh (WSL/Docker bind-mount\n setups; also MSD_PORTABLE_HOOKS=1)\n ${cyan}--relative-includes${reset} With --local: write project-relative @ includes\n (.claude/msd-core/...) instead of this checkout's\n absolute path, so several git worktrees of one repo\n each read their own copy (also MSD_RELATIVE_INCLUDES=1)\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`msd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --codex --global --config-dir ~/.codex-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Local install for a repo worked from several git worktrees${reset}\n npx ${pkg.name} --claude --local --relative-includes\n\n ${dim}# Uninstall MSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / CODEX_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / ZCODE_CONFIG_DIR environment variables.\n`);
|
||
process.exit(0);
|
||
}
|
||
|
||
// computePathPrefix: implementation moved to runtimeArtifactConversion._computePathPrefix
|
||
// (ADR-1508 / #1511 Phase 2 — single owner). The const binding above re-binds
|
||
// it here for install.js's own internal call sites only — #2876 retired the
|
||
// module.exports entry (zero consumers found; tests import
|
||
// runtimeArtifactConversion._computePathPrefix directly).
|
||
// Original doc: Compute the path prefix used for `@file` references in installed
|
||
// command/skill markdown. For global installs under $HOME uses $HOME/... form;
|
||
// OpenCode always uses the absolute path (#2376 Windows, #2831 macOS/Linux).
|
||
|
||
// resolveNodeRunner, resolveBashRunner, referencesHook are now owned by the
|
||
// runtime-hooks-surface module. Import them here so install.js callers
|
||
// continue to work and so there is a single implementation of these helpers.
|
||
// (normalizeNodePath was re-bound here too until #2876 found bin/install.js
|
||
// had no internal caller and no export consumer for it — hooksSurface owns
|
||
// the single implementation now, used internally by resolveNodeRunner there.)
|
||
const resolveNodeRunner = hooksSurface.resolveNodeRunner;
|
||
// #3662: the runtime-resolving runner token for managed JS hooks — the baked
|
||
// absolute node path tried FIRST, then `command -v node`, then well-known
|
||
// layouts, resolved by the shell at hook-fire time instead of bake time.
|
||
const buildNodeRunnerChainToken = hooksSurface.buildNodeRunnerChainToken;
|
||
const resolveBashRunner = hooksSurface.resolveBashRunner;
|
||
// referencesHook: pure predicate over hook entry objects, shared between
|
||
// install() and finishInstall() (ADR-857 phase 5f-1b).
|
||
const referencesHook = hooksSurface.referencesHook;
|
||
// applySettingsJsonHooks: mutates settings.hooks.* in place with all MSD-managed
|
||
// hook registrations for settings.json-surface runtimes (ADR-857 phase 5f-1b).
|
||
const applySettingsJsonHooks = hooksSurface.applySettingsJsonHooks;
|
||
// processAttribution: pure Co-Authored-By content transform, relocated to the
|
||
// conversion module (ADR-1508 / #1510 Phase 1). Bound here so install.js
|
||
// callers continue to work and there is a single implementation. (All call
|
||
// sites are below this line, so the const binding has no TDZ hazard.)
|
||
const processAttribution = runtimeArtifactConversion.processAttribution;
|
||
const filterRuntimeNotesForTarget = runtimeArtifactConversion.filterRuntimeNotesForTarget;
|
||
// computePathPrefix: implementation lives in runtimeArtifactConversion
|
||
// (ADR-1508 / #1511 Phase 2 — single owner). Re-bound here so install.js call
|
||
// sites continue to work. #2876 retired the sibling
|
||
// applyRuntimeContentRewritesInPlace / applyRuntimeContentRewritesForCommandsInPlace
|
||
// re-bindings that used to sit alongside it.
|
||
// (All call sites are below this line → no TDZ hazard.)
|
||
const computePathPrefix = runtimeArtifactConversion._computePathPrefix;
|
||
// #2931 (ADR-1508): single-sourced in the conversion module — was a second,
|
||
// unlinked verbatim copy here (used by the local Cursor converter below),
|
||
// the exact drift class this PR exists to reduce. Verified
|
||
// behaviorally identical (no block / one block / adjacent blocks / whole-
|
||
// content block / unclosed opening tag / nested-looking tags / repeated
|
||
// sequential calls for global-regex lastIndex leakage) before merging.
|
||
// install.js re-binds (does not re-define) — RUNTIME_COMPATIBILITY_BLOCK_RE
|
||
// is no longer duplicated here either. (All call sites are below this line
|
||
// → no TDZ hazard.)
|
||
const applyClaudeCodeBrandSwap = runtimeArtifactConversion.applyClaudeCodeBrandSwap;
|
||
|
||
function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
|
||
return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
|
||
}
|
||
|
||
// #2876: reconcileManagedShellHookCommands, buildCodexHookBlock,
|
||
// rewriteLegacyCodexHookBlock, reconcileCodexHooksJsonEvent, and
|
||
// reconcileCodexHooksJsonSessionStart used to be re-bound here as one-line
|
||
// delegates to the equivalent hooksSurface.* implementations. None had an
|
||
// install.js internal caller or an export consumer (tests import all five
|
||
// directly from msd-core/bin/lib/runtime-hooks-surface.cjs), so the retired
|
||
// bindings were dead code with no reachable body — removed rather than kept
|
||
// as unreachable wrappers.
|
||
|
||
/**
|
||
* Ensure Codex hooks.json contains exactly one managed SessionStart
|
||
* msd-check-update hook entry, while preserving user-owned entries.
|
||
*
|
||
* Codex accepts hook config from hooks.json and config.toml. To avoid the
|
||
* startup warning for mixed representations in the same layer, MSD now stores
|
||
* the managed SessionStart hook in hooks.json and keeps config.toml for
|
||
* feature flags / agent metadata only.
|
||
*
|
||
* Supports both known hooks.json shapes:
|
||
* 1) { "SessionStart": [...] }
|
||
* 2) { "hooks": { "SessionStart": [...] } }
|
||
*
|
||
* On Windows, writes a .cmd shim alongside the .js hook file and uses the
|
||
* .cmd shim path as the hook command to avoid the `bash.exe: cannot execute
|
||
* binary file` failure (#3426).
|
||
*
|
||
* #772: also emits `commandWindows` in the hook entry so that a
|
||
* cross-platform hooks.json works on both POSIX and Windows without
|
||
* requiring per-OS regeneration. Codex dispatches `commandWindows` on
|
||
* Windows and `command` on other platforms (HookHandlerConfig in
|
||
* codex-rs/config/src/hook_config.rs).
|
||
*
|
||
* @param {string} targetDir
|
||
* @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts
|
||
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
||
*/
|
||
function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
||
return hooksSurface.ensureCodexHooksJsonSessionStart(targetDir, opts);
|
||
}
|
||
|
||
/**
|
||
* Remove a MSD-managed event entry from hooks.json. Called during uninstall,
|
||
* and (#2586) unconditionally during install/reinstall to clean up a
|
||
* pre-#2586 install's stale msd-context-monitor.js registrations — MSD no
|
||
* longer ADDS entries for these events (see CODEX_HOOKS_TO_COPY /
|
||
* cleanupOrphanedCodexContextMonitorScript in bin/install.js's Codex branch),
|
||
* only removes recognized ones, so the `ensureCodexHooksJsonEvent` wrapper
|
||
* that used to add them was removed as dead code.
|
||
*
|
||
* @param {string} targetDir
|
||
* @param {string} eventName
|
||
*/
|
||
function removeCodexHooksJsonEvent(targetDir, eventName) {
|
||
return hooksSurface.removeCodexHooksJsonEvent(targetDir, eventName);
|
||
}
|
||
|
||
function removeCodexHooksJsonSessionStart(targetDir) {
|
||
return hooksSurface.removeCodexHooksJsonSessionStart(targetDir);
|
||
}
|
||
|
||
/**
|
||
* Build a hook command path using forward slashes for cross-platform compatibility.
|
||
* On Windows, $HOME is not expanded by cmd.exe/PowerShell, so we use the actual path.
|
||
*
|
||
* @param {string} configDir - Resolved absolute config directory path
|
||
* @param {string} hookName - Hook filename (e.g. 'msd-statusline.js')
|
||
* @param {{ portableHooks?: boolean, platform?: NodeJS.Platform, runtime?: string }} [opts] - Options
|
||
* portableHooks: when true, emit $HOME-relative paths instead of absolute paths.
|
||
* Safe for Linux/macOS global installs and WSL/Docker bind-mount scenarios.
|
||
* Not suitable for pure Windows (cmd.exe/PowerShell do not expand $HOME).
|
||
* platform: test injection for shell command formatting. Defaults to process.platform.
|
||
* runtime: target runtime name for shell projection policy.
|
||
*/
|
||
function buildHookCommand(configDir, hookName, opts) {
|
||
return hooksSurface.buildHookCommand(configDir, hookName, opts);
|
||
}
|
||
|
||
/**
|
||
* Resolve the opencode config file path, preferring .jsonc if it exists.
|
||
*/
|
||
function resolveOpencodeConfigPath(configDir) {
|
||
const jsoncPath = path.join(configDir, 'opencode.jsonc');
|
||
if (fs.existsSync(jsoncPath)) {
|
||
return jsoncPath;
|
||
}
|
||
return path.join(configDir, 'opencode.json');
|
||
}
|
||
|
||
// #2087 — attribution config-path resolvers, keyed by descriptor (hostBehaviors.attributionConfigResolver)
|
||
const ATTRIBUTION_CONFIG_RESOLVERS = { opencode: resolveOpencodeConfigPath };
|
||
|
||
/**
|
||
* Strip JSONC comments (// and /* */) from a string to produce valid JSON.
|
||
* Handles comments inside strings correctly (does not strip them).
|
||
*/
|
||
function stripJsonComments(text) {
|
||
let result = '';
|
||
let i = 0;
|
||
let inString = false;
|
||
let stringChar = '';
|
||
while (i < text.length) {
|
||
// Handle string literals — don't strip comments inside strings
|
||
if (inString) {
|
||
if (text[i] === '\\') {
|
||
result += text[i] + (text[i + 1] || '');
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (text[i] === stringChar) {
|
||
inString = false;
|
||
}
|
||
result += text[i];
|
||
i++;
|
||
continue;
|
||
}
|
||
// Start of string
|
||
if (text[i] === '"' || text[i] === "'") {
|
||
inString = true;
|
||
stringChar = text[i];
|
||
result += text[i];
|
||
i++;
|
||
continue;
|
||
}
|
||
// Line comment
|
||
if (text[i] === '/' && text[i + 1] === '/') {
|
||
// Skip to end of line
|
||
while (i < text.length && text[i] !== '\n') i++;
|
||
continue;
|
||
}
|
||
// Block comment
|
||
if (text[i] === '/' && text[i + 1] === '*') {
|
||
i += 2;
|
||
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
||
i += 2; // skip closing */
|
||
continue;
|
||
}
|
||
result += text[i];
|
||
i++;
|
||
}
|
||
// Remove trailing commas before } or ] (common in JSONC)
|
||
return result.replace(/,\s*([}\]])/g, '$1');
|
||
}
|
||
|
||
/**
|
||
* Read and parse settings.json, returning empty object if it doesn't exist.
|
||
* Supports JSONC (JSON with comments) — many CLI tools allow comments in
|
||
* their settings files, so we strip them before parsing to avoid silent
|
||
* data loss from JSON.parse failures.
|
||
*/
|
||
function readSettings(settingsPath) {
|
||
if (fs.existsSync(settingsPath)) {
|
||
try {
|
||
const raw = fs.readFileSync(settingsPath, 'utf8');
|
||
let parsed;
|
||
// Try standard JSON first (fast path)
|
||
try { parsed = JSON.parse(raw); }
|
||
catch { parsed = JSON.parse(stripJsonComments(raw)); }
|
||
return parsed === null ? {} : parsed; // valid JSON null = empty settings, not malformed
|
||
} catch (e) {
|
||
// If even JSONC stripping fails, warn instead of silently returning {}
|
||
console.warn(' ' + yellow + '⚠' + reset + ' Warning: Could not parse ' + settingsPath + ' — file may be malformed. Existing settings preserved.');
|
||
return null;
|
||
}
|
||
}
|
||
return {};
|
||
}
|
||
|
||
/**
|
||
* Write settings.json with proper formatting.
|
||
*
|
||
* Atomic (temp+rename) because hosts discard the ENTIRE settings file on any
|
||
* parse failure, so a truncated write costs the user every hook, permission,
|
||
* and statusline they have — not just MSD's entries. This is the sole writer
|
||
* of that surface for six runtimes.
|
||
*
|
||
* `atomicWriteFileSync` is declared further down this file; it is dereferenced
|
||
* at call time, after module evaluation, so the ordering is safe.
|
||
*/
|
||
function writeSettings(settingsPath, settings) {
|
||
atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||
}
|
||
|
||
// #2875 Part 2 (J8): model-override resolution (readMsdGlobalModelOverrides /
|
||
// readMsdEffectiveModelOverrides / readMsdRuntimeProfileResolver, plus the
|
||
// shared resolveAgentModelOverride precedence chain) was extracted into the
|
||
// shipped msd-core/bin/lib/install-model-override-resolver.cjs, mirroring
|
||
// install-effort-resolver.cjs's existing #2071 precedent, so the descriptor-
|
||
// driven agents pipeline (runtime-artifact-layout.cts's convertedAgentsKind)
|
||
// and this installer resolve model_overrides / model_profile_overrides
|
||
// through the SAME code — a single source of truth for the precedence chain
|
||
// the inline agent loop below used to duplicate across ~24 lines per runtime
|
||
// (opencode). See install-model-override-resolver.cts's module doc.
|
||
const {
|
||
readMsdEffectiveModelOverrides,
|
||
readMsdRuntimeProfileResolver,
|
||
resolveAgentModelOverride,
|
||
} = require(path.join(_msdLibDir, 'install-model-override-resolver.cjs'));
|
||
|
||
// #2875 Part 2: effort frontmatter injection moved to runtimeArtifactConversion
|
||
// (single source of truth with the descriptor pipeline's
|
||
// applyAgentFrontmatterExtensions step, which now also owns disallowedTools
|
||
// injection + the read-only agent deny-list internally — see its module doc
|
||
// in src/runtime-artifact-conversion.cts). injectEffortFrontmatter used to be
|
||
// re-bound here purely to stay on this module's export surface; #2876 found
|
||
// no install.js internal caller either (tests import it directly from
|
||
// msd-core/bin/lib/runtime-artifact-conversion.cjs) and retired the binding.
|
||
|
||
// Cache for attribution settings (populated once per runtime during install)
|
||
const attributionCache = new Map();
|
||
|
||
/**
|
||
* Get commit attribution setting for a runtime
|
||
* @param {string} runtime - 'claude', 'opencode', 'codex', ...
|
||
* @returns {null|undefined|string} null = remove, undefined = keep default, string = custom
|
||
*/
|
||
function getCommitAttribution(runtime) {
|
||
// Return cached value if available
|
||
if (attributionCache.has(runtime)) {
|
||
return attributionCache.get(runtime);
|
||
}
|
||
|
||
let result;
|
||
|
||
const _attrResolverKey = _hostBehaviors(runtime).attributionConfigResolver;
|
||
if (_attrResolverKey && ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey]) {
|
||
const resolveConfigPath = ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey];
|
||
const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
|
||
result = (config && config.disable_ai_attribution === true) ? null : undefined;
|
||
} else if (_hostBehaviors(runtime).attributionSource === 'settings-json-commit') {
|
||
// Claude Code
|
||
const settings = readSettings(path.join(getGlobalConfigDir(runtime, explicitConfigDir), 'settings.json'));
|
||
if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
|
||
result = undefined;
|
||
} else if (settings.attribution.commit === '') {
|
||
result = null;
|
||
} else {
|
||
result = settings.attribution.commit;
|
||
}
|
||
} else {
|
||
// Codex currently has no attribution setting equivalent
|
||
result = undefined;
|
||
}
|
||
|
||
// Cache and return
|
||
attributionCache.set(runtime, result);
|
||
return result;
|
||
}
|
||
|
||
// processAttribution (pure Co-Authored-By content transform) relocated to
|
||
// runtime-artifact-conversion.cjs (ADR-1508 / #1510 Phase 1); bound above.
|
||
// getCommitAttribution stays here — it is impure install-time config I/O.
|
||
|
||
/**
|
||
* Convert Claude Code frontmatter to opencode format
|
||
* - Converts 'allowed-tools:' array to 'permission:' object
|
||
* @param {string} content - Markdown file content with YAML frontmatter
|
||
* @returns {string} - Content with converted frontmatter
|
||
*/
|
||
// Color name to hex mapping for opencode compatibility
|
||
const colorNameToHex = {
|
||
cyan: '#00FFFF',
|
||
red: '#FF0000',
|
||
green: '#00FF00',
|
||
blue: '#0000FF',
|
||
yellow: '#FFFF00',
|
||
magenta: '#FF00FF',
|
||
orange: '#FFA500',
|
||
purple: '#800080',
|
||
pink: '#FFC0CB',
|
||
white: '#FFFFFF',
|
||
black: '#000000',
|
||
gray: '#808080',
|
||
grey: '#808080',
|
||
};
|
||
|
||
// Tool name mapping from Claude Code to OpenCode
|
||
// OpenCode uses lowercase tool names; special mappings for renamed tools
|
||
const claudeToOpencodeTools = {
|
||
AskUserQuestion: 'question',
|
||
SlashCommand: 'skill',
|
||
TodoWrite: 'todowrite',
|
||
WebFetch: 'webfetch',
|
||
WebSearch: 'websearch', // Plugin/MCP - keep for compatibility
|
||
};
|
||
|
||
// Tool name mapping from Claude Code to Antigravity
|
||
// Antigravity uses Gemini's snake_case built-in tool names
|
||
const claudeToAntigravityTools = {
|
||
// #4705: Antigravity-NATIVE tool names (see src/runtime-artifact-conversion.cts)
|
||
Read: 'view_file',
|
||
Write: 'write_file',
|
||
Edit: 'replace_file_content',
|
||
Bash: 'run_command',
|
||
Glob: 'glob',
|
||
Grep: 'grep_search',
|
||
WebSearch: 'google_web_search',
|
||
WebFetch: 'web_fetch',
|
||
TodoWrite: 'write_todos',
|
||
}
|
||
|
||
/**
|
||
* Convert a Claude Code tool name to OpenCode format
|
||
* - Applies special mappings (AskUserQuestion -> question, etc.)
|
||
* - Converts to lowercase (except MCP tools which keep their format)
|
||
*/
|
||
function convertToolName(claudeTool) {
|
||
// Check for special mapping first
|
||
if (claudeToOpencodeTools[claudeTool]) {
|
||
return claudeToOpencodeTools[claudeTool];
|
||
}
|
||
// MCP tools (mcp__*) keep their format
|
||
if (claudeTool.startsWith('mcp__')) {
|
||
return claudeTool;
|
||
}
|
||
// Default: convert to lowercase
|
||
return claudeTool.toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Convert a Claude Code tool name to Antigravity format
|
||
* - Applies Claude→Antigravity mapping (Read→read_file, Bash→run_shell_command, etc.)
|
||
* - Filters out MCP tools (mcp__*) — they are auto-discovered at runtime in Antigravity
|
||
* - Filters out Task/Agent — agents are auto-registered as tools in Antigravity
|
||
* @returns {string|null} Antigravity tool name, or null if tool should be excluded
|
||
*/
|
||
function convertAntigravityToolName(claudeTool) {
|
||
// MCP tools: exclude — auto-discovered from mcpServers config at runtime
|
||
if (claudeTool.startsWith('mcp__')) {
|
||
return null;
|
||
}
|
||
// Task/Agent: exclude — agents are auto-registered as callable tools.
|
||
// AskUserQuestion: exclude — Antigravity (Gemini tool dialect) does not expose
|
||
// an ask_user tool; emitting it causes frontmatter validation errors (#3362).
|
||
// Skill/SlashCommand: exclude — Antigravity (Gemini tool dialect) has no 'skill'
|
||
// built-in tool; the lowercase fallback would emit an invalid
|
||
// 'skill'/'slashcommand' name that fails frontmatter validation
|
||
// (tools.N: Invalid tool name) and aborts the entire agent load (#1394).
|
||
if (
|
||
claudeTool === 'Task' ||
|
||
claudeTool === 'Agent' ||
|
||
claudeTool === 'AskUserQuestion' ||
|
||
claudeTool === 'ask_user' ||
|
||
claudeTool === 'Skill' ||
|
||
claudeTool === 'SlashCommand'
|
||
) {
|
||
return null;
|
||
}
|
||
// Check for explicit mapping
|
||
if (claudeToAntigravityTools[claudeTool]) {
|
||
return claudeToAntigravityTools[claudeTool];
|
||
}
|
||
// Default: lowercase
|
||
return claudeTool.toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Map a skill directory name (msd-<cmd>) to the frontmatter `name:` used
|
||
* by Claude Code as the skill identity. Emits the hyphen form (msd-<cmd>)
|
||
* so Claude Code autocomplete shows the canonical invocation form, not the
|
||
* deprecated colon form. See #2808.
|
||
*
|
||
* Historical note: this previously returned `msd:<cmd>` (colon) because
|
||
* workflows called Skill(skill="msd:<cmd>"). Those calls have been updated
|
||
* to use hyphen form (#2808) so the colon rewrite is no longer needed.
|
||
*
|
||
* Codex must NOT use this helper: its adapter invokes skills as `$msd-<cmd>`
|
||
* (shell-var syntax) — hyphen form is already correct there.
|
||
*/
|
||
function skillFrontmatterName(skillDirName) {
|
||
if (typeof skillDirName !== 'string') return skillDirName;
|
||
// Return the hyphen form as-is (msd-<cmd>) — canonical since #2808.
|
||
return skillDirName;
|
||
}
|
||
|
||
/**
|
||
* Convert a Claude command (.md) to a Claude skill (SKILL.md).
|
||
* Claude Code is the native format, so minimal conversion needed —
|
||
* preserve allowed-tools as YAML multiline list, preserve argument-hint.
|
||
* Emits `name: msd-<cmd>` (hyphen) so Skill(skill="msd-<cmd>") calls and
|
||
* tab autocomplete use the canonical command namespace.
|
||
*/
|
||
function convertClaudeCommandToClaudeSkill(content, skillName, _runtime = null, cmdNames = null) {
|
||
const { frontmatter, body } = extractFrontmatterAndBody(content);
|
||
if (!frontmatter) return content;
|
||
|
||
// #3583: rewrite any /msd:<cmd> or msd:<cmd> in the body to the canonical
|
||
// hyphen form (msd-<cmd>) so installed SKILL.md bodies match the hyphen
|
||
// `name:` Claude Code registers under (#2808). `cmdNames`
|
||
// is optional and pre-computed by the caller for performance; direct test
|
||
// calls fall back to reading the list.
|
||
const names = cmdNames || readMsdCommandNames();
|
||
const normalizedBody = transformContentToHyphen(body, names);
|
||
|
||
// #4324: the description is the text the host's skill picker renders, so it
|
||
// needs the same hyphen normalisation the body gets — otherwise a `/msd:<cmd>`
|
||
// mention in a command description ships the retired colon form to the user.
|
||
const description = transformContentToHyphen(
|
||
extractFrontmatterField(frontmatter, 'description') || '', names,
|
||
);
|
||
const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
|
||
const agent = extractFrontmatterField(frontmatter, 'agent');
|
||
// #769: preserve context: from source command files so it is emitted into
|
||
// the installed SKILL.md frontmatter unchanged. (#3151: effort: is no longer
|
||
// emitted into skill frontmatter — a static effort value invalidates the
|
||
// caller's prompt cache at both scope boundaries.)
|
||
const context = extractFrontmatterField(frontmatter, 'context');
|
||
|
||
// Preserve allowed-tools as YAML multiline list (Claude native format)
|
||
const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m);
|
||
let toolsBlock = '';
|
||
if (toolsMatch) {
|
||
toolsBlock = 'allowed-tools:\n' + toolsMatch[1];
|
||
// Ensure trailing newline
|
||
if (!toolsBlock.endsWith('\n')) toolsBlock += '\n';
|
||
}
|
||
|
||
// Reconstruct frontmatter in Claude skill format
|
||
const frontmatterName = skillFrontmatterName(skillName);
|
||
let fm = `---\nname: ${frontmatterName}\ndescription: ${yamlQuote(description)}\n`;
|
||
if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
|
||
if (agent) fm += `agent: ${agent}\n`;
|
||
// #769: emit context: when present so the runtime can honour it natively
|
||
// (context: fork = isolated subagent window). Claude-specific; unknown
|
||
// frontmatter fields are silently ignored by other runtimes (backward-compatible).
|
||
// (#3151: effort: is intentionally NOT emitted into skill frontmatter — a
|
||
// static effort value changes output_config.effort on invocation and
|
||
// invalidates the caller's prompt cache at both scope boundaries.)
|
||
if (context) fm += `context: ${context}\n`;
|
||
if (toolsBlock) fm += toolsBlock;
|
||
fm += '---';
|
||
|
||
return `${fm}\n${normalizedBody}`;
|
||
}
|
||
|
||
/**
|
||
* Apply Antigravity-specific content conversion — path replacement + command name conversion.
|
||
* Path mappings depend on install mode:
|
||
* Global: ~/.claude/skills/ → ~/.gemini/config/skills/ (#3738),
|
||
* ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
|
||
* Local: ~/.claude/ → .agents/, ./.claude/ → ./.agents/
|
||
* Applied to ALL Antigravity content (skills, agents, engine files).
|
||
* @param {string} content - Source content to convert
|
||
* @param {boolean} [isGlobal=false] - Whether this is a global install
|
||
*/
|
||
function convertClaudeToAntigravityContent(content, isGlobal = false) {
|
||
let c = filterRuntimeNotesForTarget(content, 'antigravity');
|
||
if (isGlobal) {
|
||
// #3738: global skills install under ~/.gemini/config/skills (the dir AGY
|
||
// scans for global discovery), so skills-path references must divert there
|
||
// — BEFORE the configHome rewrite below, which is correct for msd-core
|
||
// runtime-file references (settings, workflows, VERSION) but wrong for the
|
||
// skills dir itself. Mirrors src/runtime-artifact-conversion.cts (ADR-1508
|
||
// keeps bin/install.js hand-authored; the two copies must stay in sync).
|
||
c = c.replace(/\$HOME\/\.claude\/skills\//g, '$HOME/.gemini/config/skills/');
|
||
c = c.replace(/~\/\.claude\/skills\//g, '~/.gemini/config/skills/');
|
||
// Bare skills form (no trailing slash) — must also precede the generic
|
||
// slash rule, which would otherwise divert it to the retired configHome
|
||
// path ($HOME/.gemini/antigravity/skills).
|
||
c = c.replace(/\$HOME\/\.claude\/skills\b/g, '$HOME/.gemini/config/skills');
|
||
c = c.replace(/~\/\.claude\/skills\b/g, '~/.gemini/config/skills');
|
||
c = c.replace(/\$HOME\/\.claude\//g, '$HOME/.gemini/antigravity/');
|
||
c = c.replace(/~\/\.claude\//g, '~/.gemini/antigravity/');
|
||
// Bare form (no trailing slash) — must come after slash form to avoid double-replace
|
||
c = c.replace(/\$HOME\/\.claude\b/g, '$HOME/.gemini/antigravity');
|
||
c = c.replace(/~\/\.claude\b/g, '~/.gemini/antigravity');
|
||
} else {
|
||
c = c.replace(/\$HOME\/\.claude\//g, '.agents/');
|
||
c = c.replace(/~\/\.claude\//g, '.agents/');
|
||
// Bare form (no trailing slash) — must come after slash form to avoid double-replace
|
||
c = c.replace(/\$HOME\/\.claude\b/g, '.agents');
|
||
c = c.replace(/~\/\.claude\b/g, '.agents');
|
||
}
|
||
c = c.replace(/\.\/\.claude\//g, './.agents/');
|
||
c = c.replace(/\.claude\//g, '.agents/');
|
||
// Command name conversion (all msd: references → msd-)
|
||
c = c.replace(/msd:/g, 'msd-');
|
||
// Runtime-neutral agent name replacement (#766)
|
||
c = neutralizeAgentReferences(c, 'GEMINI.md');
|
||
return c;
|
||
}
|
||
|
||
// isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
|
||
/**
|
||
* Convert a Claude command (.md) to an Antigravity skill (SKILL.md).
|
||
* Transforms frontmatter to minimal name + description only.
|
||
* Body passes through with path/command conversions applied.
|
||
*/
|
||
function convertClaudeCommandToAntigravitySkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
|
||
const converted = convertClaudeToAntigravityContent(content, isGlobal);
|
||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||
if (!frontmatter) return converted;
|
||
|
||
const name = skillName || extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||
|
||
// #2876: quote description so YAML flow indicators in the source
|
||
// (e.g. `[BETA] …`) don't break downstream frontmatter parsers.
|
||
const fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n---`;
|
||
return `${fm}\n${body}`;
|
||
}
|
||
|
||
/**
|
||
* Convert a Claude agent (.md) to an Antigravity agent.
|
||
* Uses Gemini tool names since Antigravity runs on Gemini 3 backend.
|
||
*/
|
||
function convertClaudeAgentToAntigravityAgent(content, isGlobal = false) {
|
||
const converted = convertClaudeToAntigravityContent(content, isGlobal);
|
||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||
if (!frontmatter) return converted;
|
||
|
||
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||
const color = extractFrontmatterField(frontmatter, 'color');
|
||
const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || '';
|
||
|
||
// Map tools to Antigravity equivalents (reuse existing convertAntigravityToolName)
|
||
const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean);
|
||
const mappedTools = claudeTools.map(t => convertAntigravityToolName(t)).filter(Boolean);
|
||
|
||
// #2876: quote description for the same reason as the skill variant.
|
||
// #4705: tools is a YAML SEQUENCE of native names (see the src twin).
|
||
const toolsBlock = mappedTools.length > 0
|
||
? `tools:\n${mappedTools.map((t) => `- ${t}`).join('\n')}\n`
|
||
: 'tools: []\n';
|
||
let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n${toolsBlock}`;
|
||
if (color) fm += `color: ${color}\n`;
|
||
fm += '---';
|
||
|
||
return `${fm}\n${body}`;
|
||
}
|
||
|
||
function toSingleLine(value) {
|
||
return value.replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function yamlQuote(value) {
|
||
return JSON.stringify(value);
|
||
}
|
||
|
||
function yamlIdentifier(value) {
|
||
const text = String(value).trim();
|
||
if (/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(text)) {
|
||
return text;
|
||
}
|
||
return yamlQuote(text);
|
||
}
|
||
|
||
function extractFrontmatterAndBody(content) {
|
||
if (!content.startsWith('---')) {
|
||
return { frontmatter: null, body: content };
|
||
}
|
||
|
||
const endIndex = content.indexOf('---', 3);
|
||
if (endIndex === -1) {
|
||
return { frontmatter: null, body: content };
|
||
}
|
||
|
||
return {
|
||
frontmatter: content.substring(3, endIndex).trim(),
|
||
body: content.substring(endIndex + 3),
|
||
};
|
||
}
|
||
|
||
function extractFrontmatterField(frontmatter, fieldName) {
|
||
const regex = new RegExp(`^${fieldName}:\\s*(.+)$`, 'm');
|
||
const match = frontmatter.match(regex);
|
||
if (!match) return null;
|
||
return match[1].trim().replace(/^['"]|['"]$/g, '');
|
||
}
|
||
|
||
// Tool name mapping from Claude Code to Cursor CLI
|
||
const claudeToCursorTools = {
|
||
Bash: 'Shell',
|
||
Edit: 'StrReplace',
|
||
AskUserQuestion: null, // No direct equivalent — use conversational prompting
|
||
SlashCommand: null, // No equivalent — skills are auto-discovered
|
||
};
|
||
|
||
function convertSlashCommandsToCursorSkillMentions(content) {
|
||
// Keep leading "/" for slash commands; only normalize msd: -> msd-.
|
||
// This preserves rendered "next step" commands like "/msd-execute-phase 17".
|
||
return content.replace(/msd:/gi, 'msd-');
|
||
}
|
||
|
||
function convertClaudeToCursorMarkdown(content) {
|
||
let converted = convertSlashCommandsToCursorSkillMentions(filterRuntimeNotesForTarget(content, 'cursor'));
|
||
// Replace tool name references in body text
|
||
converted = converted.replace(/\bBash\(/g, 'Shell(');
|
||
converted = converted.replace(/\bEdit\(/g, 'StrReplace(');
|
||
converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting');
|
||
// Replace subagent_type from Claude to Cursor format
|
||
converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
|
||
converted = converted.replace(/\$ARGUMENTS\b/g, '{{MSD_ARGS}}');
|
||
// Replace project-level Claude conventions with Cursor equivalents
|
||
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.cursor/rules/`');
|
||
converted = converted.replace(/\.\/CLAUDE\.md/g, '.cursor/rules/');
|
||
converted = converted.replace(/`CLAUDE\.md`/g, '`.cursor/rules/`');
|
||
converted = converted.replace(/\bCLAUDE\.md\b/g, '.cursor/rules/');
|
||
converted = converted.replace(/\.claude\/skills\//g, '.cursor/skills/');
|
||
// Remove Claude Code-specific bug workarounds before brand replacement
|
||
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
|
||
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
|
||
// Replace "Claude Code" brand references with "Cursor" — #2284(b): skips
|
||
// <runtime_compatibility> comparison-table content (protected region).
|
||
converted = applyClaudeCodeBrandSwap(converted, 'Cursor');
|
||
return converted;
|
||
}
|
||
|
||
function getCursorSkillAdapterHeader(skillName) {
|
||
return `<cursor_skill_adapter>
|
||
## A. Skill Invocation
|
||
- This skill is invoked when the user mentions \`${skillName}\` or describes a task matching this skill.
|
||
- Treat all user text after the skill mention as \`{{MSD_ARGS}}\`.
|
||
- If no arguments are present, treat \`{{MSD_ARGS}}\` as empty.
|
||
|
||
## B. User Prompting
|
||
When the workflow needs user input, prompt the user conversationally:
|
||
- Present options as a numbered list in your response text
|
||
- Ask the user to reply with their choice
|
||
- For multi-select, ask for comma-separated numbers
|
||
|
||
## C. Tool Usage
|
||
Use these Cursor tools when executing MSD workflows:
|
||
- \`Shell\` for running commands (terminal operations)
|
||
- \`StrReplace\` for editing existing files
|
||
- \`Read\`, \`Write\`, \`Glob\`, \`Grep\`, \`Task\`, \`WebSearch\`, \`WebFetch\`, \`TodoWrite\` as needed
|
||
|
||
## D. Subagent Spawning
|
||
When the workflow needs to spawn a subagent:
|
||
- Use \`Task(subagent_type="generalPurpose", ...)\`
|
||
- The \`model\` parameter maps to Cursor's model options (e.g., "fast")
|
||
</cursor_skill_adapter>`;
|
||
}
|
||
|
||
function convertClaudeCommandToCursorSkill(content, skillName) {
|
||
const converted = convertClaudeToCursorMarkdown(content);
|
||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||
let description = `Run MSD workflow ${skillName}.`;
|
||
if (frontmatter) {
|
||
const maybeDescription = extractFrontmatterField(frontmatter, 'description');
|
||
if (maybeDescription) {
|
||
description = maybeDescription;
|
||
}
|
||
}
|
||
description = toSingleLine(description);
|
||
const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
|
||
const adapter = getCursorSkillAdapterHeader(skillName);
|
||
|
||
// Cursor skills are both slash-invocable and model-invocable. Do not emit the
|
||
// unsupported `user-invocable` field: it is ignored by Cursor and previously
|
||
// hid the real cause of duplicate entries, the parallel commands/ surface
|
||
// retired in #2644.
|
||
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
|
||
}
|
||
|
||
/**
|
||
* Convert Claude Code agent markdown to Cursor agent format.
|
||
* Strips frontmatter fields Cursor doesn't support (color, skills),
|
||
* converts tool references, and adds a role context header.
|
||
*/
|
||
function convertClaudeAgentToCursorAgent(content) {
|
||
let converted = convertClaudeToCursorMarkdown(content);
|
||
|
||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||
if (!frontmatter) return converted;
|
||
|
||
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||
|
||
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
|
||
|
||
return `${cleanFrontmatter}\n${body}`;
|
||
}
|
||
|
||
function convertSlashCommandsToCodexSkillMentions(content) {
|
||
// Colon-style /msd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below).
|
||
let converted = content.replace(/\/msd:([a-z0-9-]+)/gi, (_, commandName) => {
|
||
return `$msd-${String(commandName).toLowerCase()}`;
|
||
});
|
||
// Convert hyphen-style command references (workflow output) to Codex $ prefix.
|
||
// A real /msd-<cmd> MENTION is defined positively by two boundaries, so any
|
||
// in-path occurrence is excluded by construction (no denylist of preceding
|
||
// chars to maintain — see #712, supersedes the #637/#704 lookbehind treadmill):
|
||
// 1. Left boundary: opens at start-of-string, whitespace, or an inline-prose
|
||
// delimiter (backtick/quote/paren/bracket) — e.g. `/msd-execute-phase`.
|
||
// 2. Right boundary: the command token is NOT followed by a path separator
|
||
// `/` (a path continues: `/msd-core/bin/...`; a command does not). The
|
||
// `(?![a-z0-9/-])` also blocks regex backtracking to a shorter command.
|
||
// This converts backtick-wrapped MENTIONS (`/msd-foo`) while leaving backtick-
|
||
// wrapped PATHS (`/msd-core/workflows/update.md`) untouched (#712).
|
||
converted = converted.replace(/(?<=^|[\s`"'([])\/msd-([a-z0-9-]+)(?![a-z0-9/-])/gi, (_, commandName) => {
|
||
return `$msd-${String(commandName).toLowerCase()}`;
|
||
});
|
||
return converted;
|
||
}
|
||
|
||
const CODEX_MSD_TOOLS_INVOCATION = 'node "$HOME/.codex/msd-core/bin/msd-tools.cjs"';
|
||
|
||
function rewriteBareMsdToolsCommandsForCodex(content) {
|
||
return content
|
||
.replace(/(^[ \t]*)msd-tools(?=\s)/gm, `$1${CODEX_MSD_TOOLS_INVOCATION}`)
|
||
.replace(/(\$\(\s*)msd-tools(?=\s)/g, `$1${CODEX_MSD_TOOLS_INVOCATION}`)
|
||
.replace(/(`\s*)msd-tools(?=\s)/g, `$1${CODEX_MSD_TOOLS_INVOCATION}`)
|
||
.replace(/((?:&&|\|\||[;|])\s*)msd-tools(?=\s)/g, `$1${CODEX_MSD_TOOLS_INVOCATION}`);
|
||
}
|
||
|
||
function convertClaudeToCodexMarkdown(content) {
|
||
let converted = convertSlashCommandsToCodexSkillMentions(filterRuntimeNotesForTarget(content, 'codex'));
|
||
converted = converted.replace(/\$ARGUMENTS\b/g, '{{MSD_ARGS}}');
|
||
// Remove /clear references — Codex has no equivalent command
|
||
// Handle backtick-wrapped: `\/clear` then: → (removed)
|
||
converted = converted.replace(/`\/clear`\s*,?\s*then:?\s*\n?/gi, '');
|
||
// Handle bare: /clear then: → (removed)
|
||
converted = converted.replace(/\/clear\s*,?\s*then:?\s*\n?/gi, '');
|
||
// Handle standalone /clear on its own line
|
||
converted = converted.replace(/^\s*`?\/clear`?\s*$/gm, '');
|
||
// Path replacement: .claude → .codex (#1430)
|
||
converted = converted.replace(/\$HOME\/\.claude\//g, '$HOME/.codex/');
|
||
converted = converted.replace(/~\/\.claude\//g, '~/.codex/');
|
||
converted = converted.replace(/\.\/\.claude\//g, './.codex/');
|
||
// Bare ~/.claude without trailing slash (e.g. configDir = ~/.claude)
|
||
converted = converted.replace(/\$HOME\/\.claude\b/g, '$HOME/.codex');
|
||
converted = converted.replace(/~\/\.claude\b/g, '~/.codex');
|
||
// Bare/project-relative .claude/... references (#2639). Covers strings like
|
||
// "check `.claude/skills/`" where there is no ~/, $HOME/, or ./ anchor.
|
||
// Negative lookbehind prevents double-replacing already-anchored forms and
|
||
// avoids matching inside URLs or other slash-prefixed paths.
|
||
converted = converted.replace(/(?<![A-Za-z0-9_\-./~$])\.claude\//g, '.codex/');
|
||
// `.claudeignore` → `.codexignore` (#2639). Codex honors its own ignore
|
||
// file; leaving the Claude-specific name is misleading in agent prompts.
|
||
converted = converted.replace(/\.claudeignore\b/g, '.codexignore');
|
||
// Codex installs the tools shim under ~/.codex but does not guarantee a
|
||
// bare `msd-tools` binary on PATH. Keep resolver probes such as
|
||
// `command -v msd-tools` intact; rewrite only command invocations.
|
||
converted = rewriteBareMsdToolsCommandsForCodex(converted);
|
||
// Runtime-neutral agent name replacement (#766)
|
||
converted = neutralizeAgentReferences(converted, 'AGENTS.md');
|
||
return converted;
|
||
}
|
||
|
||
function getCodexSkillAdapterHeader(skillName) {
|
||
const invocation = `$${skillName}`;
|
||
return `<codex_skill_adapter>
|
||
## A. Skill Invocation
|
||
- This skill is invoked by mentioning \`${invocation}\`.
|
||
- Treat all user text after \`${invocation}\` as \`{{MSD_ARGS}}\`.
|
||
- If no arguments are present, treat \`{{MSD_ARGS}}\` as empty.
|
||
|
||
## B. AskUserQuestion → request_user_input Mapping
|
||
MSD workflows use \`AskUserQuestion\` (Claude Code syntax). Translate to Codex \`request_user_input\`:
|
||
|
||
Parameter mapping:
|
||
- \`header\` → \`header\`
|
||
- \`question\` → \`question\`
|
||
- Options formatted as \`"Label" — description\` → \`{label: "Label", description: "description"}\`
|
||
- Generate \`id\` from header: lowercase, replace spaces with underscores
|
||
|
||
Batched calls:
|
||
- \`AskUserQuestion([q1, q2])\` → single \`request_user_input\` with multiple entries in \`questions[]\`
|
||
|
||
Multi-select workaround:
|
||
- Codex has no \`multiSelect\`. Use sequential single-selects, or present a numbered freeform list asking the user to enter comma-separated numbers.
|
||
|
||
Execute mode fallback:
|
||
- When \`request_user_input\` is rejected or unavailable, activate TEXT_MODE: append \`--text\` to \`{{MSD_ARGS}}\` so the workflow's built-in text-mode branching takes over. Present every \`AskUserQuestion\` call as a plain-text numbered list, then stop and wait for the user's reply. Do NOT pick a default and continue (#3018 / #3808).
|
||
- You may only proceed without a user answer when one of these is true:
|
||
(a) the invocation included an explicit non-interactive flag (\`--auto\` or \`--all\`),
|
||
(b) the user has explicitly approved a specific default for this question, or
|
||
(c) the workflow's documented contract says defaults are safe (e.g. autonomous lifecycle paths).
|
||
- Do NOT write workflow artifacts (CONTEXT.md, DISCUSSION-LOG.md, PLAN.md, checkpoint files) until the user has answered the plain-text questions or one of (a)-(c) above applies. Surfacing the questions and waiting is the correct response — silently defaulting and writing artifacts is the #3018 failure mode.
|
||
|
||
## C. Task() → spawn_agent Mapping
|
||
MSD workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collaboration tools:
|
||
|
||
**Schema detection (required first step):** Before spawning, inspect the \`spawn_agent\`
|
||
tool's visible parameter schema (via \`tool_search\` or the tool list). Use the presence
|
||
of \`agent_type\` only to choose typed dispatch versus the generic-agent workaround.
|
||
Detect optional fields independently: \`model\`, \`reasoning_effort\`, \`task_name\`,
|
||
\`fork_turns\`, and \`fork_context\` may be added or removed without \`agent_type\` changing.
|
||
Never infer one field from a schema/version label or from the presence of another field.
|
||
|
||
- **agent_type-capable schema:** \`spawn_agent\` advertises \`agent_type\` — typed MSD agent dispatch is available.
|
||
- **Generic schema:** \`spawn_agent\` does not advertise \`agent_type\` — typed MSD agent dispatch is unavailable in this session, even if other optional fields are present.
|
||
|
||
Typed mapping (agent_type-capable schema only):
|
||
- \`Task(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
|
||
- \`Agent(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
|
||
- \`Task(model="{resolved_model}")\` → pass \`model="{resolved_model}"\` when the
|
||
visible \`spawn_agent\` schema advertises \`model\` and the resolved value is explicit.
|
||
This is how \`model_profile\` tier routing, including \`adaptive\`, reaches the child agent.
|
||
Omit \`model\` only when the schema does not advertise \`model\`, or when the value is
|
||
missing, empty, or \`"inherit"\`; omission deliberately inherits the session/static agent
|
||
configuration. Explicit \`model_overrides\` may also be embedded in agent \`.toml\` files,
|
||
but ordinary profile-resolved models are not, so a TOML file is not a reason to discard
|
||
an available inline value.
|
||
- Before each typed spawn, obtain the paired effort for its role with
|
||
\`msd_run query resolve-model <subagent_type> --pick effort\` when the workflow has not
|
||
already exposed it. The resolver's unified \`effort\` field maps to the Codex spawn argument
|
||
\`reasoning_effort\`; do not look for a resolver field named \`reasoning_effort\`.
|
||
Pass it when the visible \`spawn_agent\` schema advertises \`reasoning_effort\`. Omit the
|
||
field when it is not advertised, or when the value is missing, empty, \`"inherit"\`, or
|
||
unsupported; do not invent one-off effort literals in workflow prose.
|
||
- \`fork_context: false\` by default — MSD agents load their own context via \`<required_reading>\` blocks
|
||
- \`task_name\` — when advertised, provide a descriptive name for each spawned task
|
||
- \`fork_turns\` — when advertised, controls turn-forking depth; coexists with \`fork_context\` (not a replacement)
|
||
- \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct \`spawn_agent\` mapping,
|
||
but Codex declares \`dispatch.isolation: orchestrator-worktree\` (#2584). Codex
|
||
\`spawn_agent\` still does not create or bind a git worktree; instead MSD itself
|
||
creates the worktree and process-spawns the executor into it with
|
||
\`codex exec --cd <dir>\`, performing every git operation on the executor's behalf
|
||
(its \`workspace-write\` sandbox makes \`.git\` read-only). Workflows must therefore
|
||
never fabricate a manual worktree protocol — route through the negotiated
|
||
isolation adapter, which still fails closed for hosts declaring \`none\` (#3360).
|
||
|
||
Generic-agent workaround (multi_agent_v1 schema — NO agent_type field):
|
||
When only the generic \`multi_agent_v1\` schema is available, typed MSD agent dispatch
|
||
(\`msd-planner\`, \`msd-executor\`, etc.) is NOT possible. This is a known Codex limitation
|
||
(openai/codex#15250). **This workaround is NOT equivalent to typed msd-planner/msd-executor
|
||
execution** — MSD agents carry project-aware prompts, audit logging, and workflow context
|
||
that a generic subagent lacks. Use the following fallback:
|
||
1. Resolve your active Codex config root — the directory that contains your \`config.toml\`.
|
||
This directory is determined in priority order: \`$CODEX_HOME\` (if set), the path given
|
||
by \`--config-dir\` (if passed on invocation), a local \`.codex\` directory in the current
|
||
project (if \`--local\` was used), or the default global config directory. Read
|
||
\`agents/<agent-name>.toml\` relative to that config root to extract the agent's system
|
||
instructions.
|
||
2. Inject those instructions as a role-preamble into a generic \`spawn_agent(message=...)\` call.
|
||
3. Label results and logs clearly as "generic-agent workaround" so the orchestrator and user
|
||
know full typed-agent guarantees are not in effect.
|
||
4. Where typed dispatch is mandatory for correctness (e.g. worktree isolation), fail closed
|
||
and report the schema limitation rather than silently degrading.
|
||
|
||
Spawn restriction:
|
||
- Codex restricts \`spawn_agent\` to cases where the user has explicitly
|
||
requested sub-agents. When automatic spawning is not permitted, do the
|
||
work inline in the current agent rather than attempting to force a spawn.
|
||
- In some Codex sessions, multi-agent tooling can be deferred. If \`spawn_agent\`
|
||
is not currently visible, discover tools first via \`tool_search\` before
|
||
defaulting to inline execution.
|
||
|
||
Parallel fan-out:
|
||
- Spawn multiple agents → collect agent IDs → \`collaboration.wait_agent(timeout_ms=...)\` for each to complete
|
||
- Do NOT use \`functions.wait(cell_id=...)\` — that is an unrelated exec-cell tool, not the collaboration wait
|
||
|
||
Result parsing:
|
||
- Look for structured markers in agent output: \`CHECKPOINT\`, \`PLAN COMPLETE\`, \`SUMMARY\`, etc.
|
||
- \`close_agent(id)\` after collecting results — but only if \`close_agent\` is visible in the current
|
||
tool schema (check via \`tool_search\` first, same schema-detection gate as \`spawn_agent\` above)
|
||
</codex_skill_adapter>`;
|
||
}
|
||
|
||
function convertClaudeCommandToCodexSkill(content, skillName) {
|
||
const converted = convertClaudeToCodexMarkdown(content);
|
||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||
let description = `Run MSD workflow ${skillName}.`;
|
||
if (frontmatter) {
|
||
const maybeDescription = extractFrontmatterField(frontmatter, 'description');
|
||
if (maybeDescription) {
|
||
description = maybeDescription;
|
||
}
|
||
}
|
||
description = toSingleLine(description);
|
||
const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
|
||
const adapter = getCodexSkillAdapterHeader(skillName);
|
||
|
||
return `---\nname: ${yamlQuote(skillName)}\ndescription: ${yamlQuote(description)}\nmetadata:\n short-description: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
|
||
}
|
||
|
||
/**
|
||
* Convert Claude Code agent markdown to Codex agent format.
|
||
* Applies base markdown conversions, then adds a <codex_agent_role> header
|
||
* and cleans up frontmatter (removes tools/color fields).
|
||
*/
|
||
function convertClaudeAgentToCodexAgent(content) {
|
||
let converted = convertClaudeToCodexMarkdown(content);
|
||
|
||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||
if (!frontmatter) return converted;
|
||
|
||
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||
const tools = extractFrontmatterField(frontmatter, 'tools') || '';
|
||
|
||
const roleHeader = `<codex_agent_role>
|
||
role: ${name}
|
||
tools: ${tools}
|
||
purpose: ${toSingleLine(description)}
|
||
</codex_agent_role>`;
|
||
|
||
const cleanFrontmatter = `---\nname: ${yamlQuote(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
|
||
|
||
return `${cleanFrontmatter}\n\n${roleHeader}\n${body}`;
|
||
}
|
||
|
||
/**
|
||
* #2310 — True if `model` is an Anthropic-flavored value that must never appear as a
|
||
* Codex agent `.toml` `model`. Two forms: (a) a bare Claude Agent-tool tier alias
|
||
* (opus/sonnet/haiku/fable — the canonical CLAUDE_AGENT_ALIASES); (b) any Claude model
|
||
* id in any provider namespacing — `claude-*`, `anthropic/claude-*`, `us.anthropic.claude-*`
|
||
* (the forms the catalog assigns to opencode, reachable on a Codex .toml via
|
||
* the runtime-resolver path). No OpenAI/Codex model id contains "claude", so a
|
||
* case-insensitive substring test is a safe, exhaustive guard for (b). Codex/ChatGPT
|
||
* rejects all of these.
|
||
*
|
||
* #3241 — thin delegation to the shared predicate on src/model-catalog.cts (moved there
|
||
* so it can't diverge across Codex-posture surfaces); kept as a local name because it
|
||
* reads better at the call sites below.
|
||
*/
|
||
function _isAnthropicFlavoredModel(model) {
|
||
return msdIsAnthropicFlavoredModel(model);
|
||
}
|
||
|
||
// #2310 — dedupe stderr warnings so repeated agent emits don't spam (mirrors the
|
||
// #2041/#1133 model-resolver warn-dedupe). Value is length-capped so an oversized
|
||
// or secret-shaped override cannot leak in full to logs.
|
||
const _codexModelOverrideDroppedWarned = new Set();
|
||
function _warnCodexModelOverrideDropped(agentName, value) {
|
||
const key = `${agentName}::${value}`;
|
||
if (_codexModelOverrideDroppedWarned.has(key)) return;
|
||
_codexModelOverrideDroppedWarned.add(key);
|
||
const safe = String(value).length > 64 ? `${String(value).slice(0, 64)}…` : String(value);
|
||
process.stderr.write(
|
||
`msd: warning — Codex agent "${agentName}" model "${safe}" is not a valid Codex model ` +
|
||
`(Anthropic alias/id); dropping it so Codex uses a valid default. ` +
|
||
`Set runtime:"codex" or pin a gpt-* model to route it.\n`,
|
||
);
|
||
}
|
||
|
||
// #3241 — one-time per-install deprecation notice: the automatic runtime-resolver
|
||
// per-tier Codex model embed was removed (D1/D5, ADR-2313 passive-posture epic). When
|
||
// the resolver *would have* supplied a model and nothing else ends up pinned, this
|
||
// notice points the user at model_overrides as the explicit-pin replacement. Dedupes
|
||
// with a module-level boolean (mirrors _codexModelOverrideDroppedWarned's Set above)
|
||
// so a multi-agent install — every Codex agent hits this condition simultaneously —
|
||
// emits exactly one line, not one per agent. Reset once per install() call (see
|
||
// install()) so the "at most once" window is per-install, not per-process. That
|
||
// reset lives ONLY inside install() (~:10116) — generateCodexAgentToml is also
|
||
// exported standalone (~:13460), and a caller invoking it directly/repeatedly
|
||
// outside install() gets process-lifetime dedupe instead of per-install. No
|
||
// current test depends on the standalone caller's dedupe window.
|
||
let _codexResolverModelOmittedWarned = false;
|
||
function _warnCodexResolverModelOmitted() {
|
||
if (_codexResolverModelOmittedWarned) return;
|
||
_codexResolverModelOmittedWarned = true;
|
||
process.stderr.write(
|
||
'msd: notice — Codex agents no longer auto-pin a per-tier model from the runtime ' +
|
||
'resolver; set model_overrides for an agent if you want a specific Codex model ' +
|
||
'instead of the session model.\n',
|
||
);
|
||
}
|
||
|
||
// Test seam only — bin/install.js deliberately keeps per-install warning/notice
|
||
// dedupe in module scope (both the _codexModelOverrideDroppedWarned Set above and
|
||
// the _codexResolverModelOmittedWarned boolean; install() resets the latter at
|
||
// ~:10116). A unit test that drives generateCodexAgentToml() directly, without
|
||
// going through install(), has no other way to reset either store between
|
||
// assertions without busting the require.cache (which breaks module-instance
|
||
// sharing with the rest of the suite). This is the single sanctioned way for a
|
||
// unit test to clear both dedupe stores — exported so tests can call it instead.
|
||
function _resetCodexWarningDedupeForTests() {
|
||
_codexModelOverrideDroppedWarned.clear();
|
||
_codexResolverModelOmittedWarned = false;
|
||
}
|
||
|
||
/**
|
||
* Generate a per-agent .toml config file for Codex.
|
||
* Sets required agent metadata, sandbox_mode, and developer_instructions
|
||
* from the agent markdown content.
|
||
*
|
||
* @param {string} agentName
|
||
* @param {string} agentContent
|
||
* @param {object|null} modelOverrides
|
||
* @param {object|null} runtimeResolver — runtime-aware tier resolver from readMsdRuntimeProfileResolver
|
||
* @param {object|null} effortCfg — #443: merged effort config from readMsdEffectiveEffortConfig
|
||
*/
|
||
function generateCodexAgentToml(agentName, agentContent, modelOverrides = null, runtimeResolver = null, effortCfg = null, sandboxTier = 'codex-agent-sandbox') {
|
||
const { frontmatter, body } = extractFrontmatterAndBody(agentContent);
|
||
const frontmatterText = frontmatter || '';
|
||
// #3897 list-form parse fix, Fix 3: `toolsRaw` MUST come from the same
|
||
// shared `extractToolsValue` reader `checkCodexSandboxPosture` uses, not
|
||
// this file's own `extractFrontmatterField` — the two used to disagree on
|
||
// YAML block-list `tools:` form (`extractFrontmatterField`'s single-line
|
||
// regex read only the first list item), which is exactly the generative-
|
||
// fix-divergence shape CLAUDE.md warns about for two paths feeding one
|
||
// derivation. `extractToolsValue` does its own `---`-delimited frontmatter
|
||
// scan of the full `agentContent`, so it is not re-derived from
|
||
// `frontmatterText` here.
|
||
const toolsRaw = extractToolsValue(agentContent) ?? '';
|
||
const resolvedName = extractFrontmatterField(frontmatterText, 'name') || agentName;
|
||
// #3897 rung 3 — derived from the role's own tool contract (HALT.md option
|
||
// 2). The former hand-maintained CODEX_AGENT_SANDBOX map is deleted (ADR-3473
|
||
// §8.3): it was fully redundant with this derivation, zero disagreements
|
||
// across all 11 entries. Never a silent `|| 'read-only'` fallback either.
|
||
// Derivation itself lives in codex-agent-toml.cjs, which does no frontmatter
|
||
// parsing of its own (no third copy of that extraction) — it takes the
|
||
// already-resolved `tools:` value, extracted via the shared reader above.
|
||
//
|
||
// #3897 security review F1 (blocker): pass BOTH candidate identities —
|
||
// `agentName` (the caller's filename-stem identity) AND `resolvedName`
|
||
// (the frontmatter `name:` this function's OWN emitted `name = ...` line
|
||
// uses, and what `installCodexConfig`'s caller keys the output PATH on) —
|
||
// never just one. Deciding the sandbox for `agentName` alone and applying
|
||
// it to an artifact that a DIFFERENT identity (`resolvedName`) names is
|
||
// exactly how a held role's own `.toml` could end up `workspace-write`
|
||
// (rename the source file, or plant a sibling whose `name:` collides with
|
||
// a held role). `deriveCodexSandboxMode` takes the most restrictive result
|
||
// across every candidate — see its doc and `isSandboxHeld` in
|
||
// codex-agent-toml.cjs.
|
||
const sandboxMode = deriveCodexSandboxMode([agentName, resolvedName], toolsRaw);
|
||
const resolvedDescription = toSingleLine(
|
||
extractFrontmatterField(frontmatterText, 'description') || `MSD agent ${resolvedName}`
|
||
);
|
||
const instructions = body.trim();
|
||
|
||
const lines = [
|
||
`name = ${JSON.stringify(resolvedName)}`,
|
||
`description = ${JSON.stringify(resolvedDescription)}`,
|
||
];
|
||
if (sandboxTier != null && sandboxTier !== 'none') {
|
||
lines.push(`sandbox_mode = "${sandboxMode}"`);
|
||
}
|
||
|
||
// Embed model override when configured in ~/.msd/defaults.json so that
|
||
// model_overrides is respected on Codex (which uses static TOML, not inline
|
||
// Task() model parameters). See #2256.
|
||
// #2310 — a Codex .toml `model` MUST be a real Codex/OpenAI model id. Codex is a
|
||
// passive/session-only model host (ADR-1239): MSD cannot reliably route per-agent
|
||
// tiers, and a bare MSD/Claude tier alias (opus/sonnet/haiku/fable) or a claude-*
|
||
// id 400s on a ChatGPT-account Codex ("The 'sonnet' model is not supported when
|
||
// using Codex with a ChatGPT account"). So: embed ONLY an explicit real-Codex
|
||
// model pin from model_overrides; omit anything Anthropic-flavored so the agent
|
||
// inherits the always-available session model.
|
||
// #3241 (D1/D5) — the runtime-aware tier-resolver auto-embed that used to fall
|
||
// through here when model_overrides had nothing was removed: Codex is passive by
|
||
// default now, and only an explicit model_overrides pin survives. See the
|
||
// deprecation-notice block below for the population that used to get a pin from
|
||
// the resolver and no longer does.
|
||
const rawModelOverride = modelOverrides?.[resolvedName] || modelOverrides?.[agentName];
|
||
let pinnedModel = null;
|
||
if (rawModelOverride) {
|
||
// Trim before the truthiness test (#3241 defect fix): a whitespace-only value
|
||
// (e.g. ' ') is a truthy JS string but not a model id — it must not be
|
||
// embedded verbatim (`model = " "`, a live pre-fix defect) or routed to
|
||
// _warnCodexModelOverrideDropped, whose "is not a valid Codex model
|
||
// (Anthropic alias/id)" text would misdescribe a blank config field. It is
|
||
// silently dropped, matching how '' already behaves (no pin, no warning).
|
||
const trimmedOverride = typeof rawModelOverride === 'string' ? rawModelOverride.trim() : rawModelOverride;
|
||
if (typeof trimmedOverride === 'string' && trimmedOverride && !_isAnthropicFlavoredModel(trimmedOverride)) {
|
||
pinnedModel = trimmedOverride; // explicit real-Codex model pin → embed verbatim (#2256)
|
||
} else if (typeof rawModelOverride === 'string' && trimmedOverride === '') {
|
||
// whitespace-only override — no pin, no warning (#3241).
|
||
} else {
|
||
_warnCodexModelOverrideDropped(resolvedName, rawModelOverride); // alias/claude-* → omit
|
||
}
|
||
}
|
||
// #2310 — final safety gate: never emit an Anthropic-flavored model into a Codex
|
||
// .toml, even one that reached here through some other path than the override
|
||
// check above.
|
||
if (pinnedModel && _isAnthropicFlavoredModel(pinnedModel)) {
|
||
_warnCodexModelOverrideDropped(resolvedName, pinnedModel);
|
||
pinnedModel = null;
|
||
}
|
||
// #3241 — one-time deprecation notice: if nothing ends up pinned but the
|
||
// runtime resolver would have supplied a per-tier model that would actually
|
||
// have been EMBEDDED (the population that loses a pin now that the
|
||
// auto-embed above is gone), point the user at model_overrides. The would-be
|
||
// model must also clear the #2310 Anthropic-flavored gate above — if it
|
||
// wouldn't have survived that gate, the user never had that pin pre-Phase-1
|
||
// either, and the notice would be false. Never fires when the resolver is
|
||
// null, resolves to nothing, resolves to an Anthropic-flavored model, or an
|
||
// explicit real-Codex pin survived — in all of those cases nothing was lost.
|
||
if (!pinnedModel && runtimeResolver) {
|
||
const wouldHavePinned = runtimeResolver.resolve(resolvedName) || runtimeResolver.resolve(agentName);
|
||
if (wouldHavePinned?.model && !_isAnthropicFlavoredModel(wouldHavePinned.model)) {
|
||
_warnCodexResolverModelOmitted();
|
||
}
|
||
}
|
||
let hasPinnedModel = false;
|
||
if (pinnedModel) {
|
||
lines.push(`model = ${JSON.stringify(pinnedModel)}`);
|
||
hasPinnedModel = true;
|
||
// model is resolved here; reasoning_effort from catalog tier is REPLACED by the
|
||
// unified effort resolver below (#443). Do NOT emit entry.reasoning_effort here.
|
||
}
|
||
|
||
// #443 — Unified effort for Codex .toml. Uses the same config-driven precedence chain
|
||
// as the Claude .md effort injection (resolveInstallTimeEffort), so both runtimes read
|
||
// from the same effort.agent_overrides / effort.routing_tier_defaults / effort.default
|
||
// config source. #3007 — Codex advertises supported_reasoning_levels per model, so the
|
||
// pinned model id is passed through and the value is resolved against that model's own
|
||
// set: 'max' now passes, 'minimal' clamps up to 'low', and 'ultra' is refused (no key
|
||
// emitted) rather than clamped to a fabricated level.
|
||
// #838 — Do not pin effort when Codex is intentionally inheriting the parent
|
||
// chat model. A TOML with no `model` but a static `model_reasoning_effort`
|
||
// creates confusing partial routing: model follows the Codex UI while effort
|
||
// follows MSD. Keep those knobs coupled unless MSD also pins the model.
|
||
if (hasPinnedModel) {
|
||
const _universalEffortCodex = resolveInstallTimeEffort(effortCfg, resolvedName !== agentName ? resolvedName : agentName);
|
||
// #3533 (10d): 'inherit' means OMIT the pin — the agent follows the host's
|
||
// own effort default. Never write the literal.
|
||
if (_universalEffortCodex !== 'inherit') {
|
||
const _renderedEffortCodex = _getMsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex, pinnedModel).value;
|
||
// #3007 — 'ultra' is rejected by the model's supported_reasoning_levels and
|
||
// renders as null. Omit the key entirely rather than write a literal `null`.
|
||
if (_renderedEffortCodex !== null) {
|
||
lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// #774 — Emit service_tier and model_verbosity for light-tier agents.
|
||
// Light-tier agents (routingTier: "light" in model-catalog.json) are haiku-equivalent
|
||
// and benefit from Codex's "flex" service tier (lower cost, background processing)
|
||
// and "low" verbosity (reduced token output). Both fields are validated against the
|
||
// Codex ConfigProfile schema (codex-rs/config/src/profile_toml.rs):
|
||
// service_tier: Option<String> — "flex" | "fast" (legacy)
|
||
// model_verbosity: Option<Verbosity> — "low" | "medium" | "high"
|
||
const { AGENT_DEFAULT_TIERS: _agentTiers } = _getMsdEffortCatalog();
|
||
const _agentRoutingTier = _agentTiers?.[resolvedName] || _agentTiers?.[agentName];
|
||
if (_agentRoutingTier === 'light') {
|
||
lines.push(`service_tier = "flex"`);
|
||
lines.push(`model_verbosity = "low"`);
|
||
}
|
||
|
||
// Agent prompts contain raw backslashes in regexes and shell snippets.
|
||
// TOML literal multiline strings preserve them without escape parsing.
|
||
lines.push(`developer_instructions = '''`);
|
||
lines.push(instructions);
|
||
lines.push(`'''`);
|
||
|
||
return lines.join('\n') + '\n';
|
||
}
|
||
|
||
/**
|
||
* Remove stale agents/openai.yaml sidecar files from MSD-managed Codex skill dirs.
|
||
*
|
||
* Prior to #1326, MSD's Codex install path wrote an agents/openai.yaml file
|
||
* alongside each msd-* SKILL.md. Recent Codex builds index BOTH SKILL.md and
|
||
* the sidecar, causing each MSD skill to appear twice in autocomplete. This
|
||
* function removes those stale sidecars and — if the agents/ subdirectory is
|
||
* now empty — prunes it too.
|
||
*
|
||
* Behaviour:
|
||
* - Returns immediately if skillsDir does not exist (fails open).
|
||
* - Only touches directories whose names start with "msd-".
|
||
* - Skips user-owned dirs (msd-dev-preferences) — their agents/ content is
|
||
* never modified, mirroring the same USER_OWNED_SKILL_DIRS guard used by
|
||
* installOpencodeFamilySkills.
|
||
* - For each managed msd-* dir, if agents/openai.yaml exists, deletes it.
|
||
* - If agents/ is now empty, removes the directory; if it still contains
|
||
* other files (e.g. user-added content), leaves it in place.
|
||
* - Non-msd-* dirs and their agents/ content are never touched.
|
||
* - Individual failures are caught and swallowed so a single bad dir cannot
|
||
* block the install (fail-open, matching the original design).
|
||
*
|
||
* @param {string} skillsDir - Path to the skills/ directory (e.g. ~/.codex/skills)
|
||
*/
|
||
function cleanupCodexSkillMetadataSidecars(skillsDir) {
|
||
if (!fs.existsSync(skillsDir)) return;
|
||
// Mirror the user-owned list from installOpencodeFamilySkills (#2973).
|
||
// We MUST skip these dirs — their contents are user-generated and must
|
||
// never be modified by MSD's install path.
|
||
const _userOwnedSkillDirs = new Set(['msd-dev-preferences']);
|
||
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
||
if (!entry.isDirectory() || !entry.name.startsWith('msd-')) continue;
|
||
if (_userOwnedSkillDirs.has(entry.name)) continue; // preserve user content
|
||
const agentsSubdir = path.join(skillsDir, entry.name, 'agents');
|
||
const sidecarPath = path.join(agentsSubdir, 'openai.yaml');
|
||
try {
|
||
// Symlink guard: if agents/ is a symlink pointing outside the skills tree,
|
||
// deleting through it could escape the tree. Skip this dir entirely.
|
||
let agentsStat;
|
||
try { agentsStat = fs.lstatSync(agentsSubdir); } catch (_e) { continue; }
|
||
if (agentsStat.isSymbolicLink()) continue;
|
||
if (fs.existsSync(sidecarPath)) {
|
||
fs.rmSync(sidecarPath);
|
||
}
|
||
// Prune the agents/ dir only if it is now empty (leave it if other files remain).
|
||
if (fs.existsSync(agentsSubdir) && fs.readdirSync(agentsSubdir).length === 0) {
|
||
fs.rmdirSync(agentsSubdir);
|
||
}
|
||
} catch (_err) {
|
||
// Fail open — a single bad dir must not block the install.
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Migrate a skills kind that moved to an alternate `home` (ADR-1239 split-home):
|
||
* remove now-stale `<prefix>*` skill dirs left at the OLD configDir-rooted
|
||
* location by installs from before the move. Without this, upgrading (e.g. Codex
|
||
* relocating skills to ~/.agents/skills) orphans the pre-move dirs at
|
||
* ~/.codex/skills. Only managed `<prefix>*` dirs are touched; user-owned content
|
||
* (non-prefixed dirs, msd-dev-preferences, symlinks) is preserved. Fail-open.
|
||
* @param {string} oldSkillsDir absolute path to the pre-move skills location
|
||
* @param {string} prefix managed skill-dir prefix (e.g. 'msd-')
|
||
* @returns {number} count of stale dirs removed
|
||
*/
|
||
function cleanupMovedSkillsOldLocation(oldSkillsDir, prefix) {
|
||
if (!fs.existsSync(oldSkillsDir)) return 0;
|
||
|
||
// Mirror the user-owned list from cleanupCodexSkillMetadataSidecars (#2973).
|
||
const _userOwnedSkillDirs = new Set(['msd-dev-preferences']);
|
||
let removed = 0;
|
||
|
||
for (const entry of fs.readdirSync(oldSkillsDir, { withFileTypes: true })) {
|
||
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
|
||
if (_userOwnedSkillDirs.has(entry.name)) continue;
|
||
|
||
const dirToRemove = path.join(oldSkillsDir, entry.name);
|
||
try {
|
||
// Symlink guard: never delete
|
||
// through a symlinked msd-* dir — it could escape the tree.
|
||
const stat = fs.lstatSync(dirToRemove);
|
||
if (stat.isSymbolicLink()) continue;
|
||
|
||
fs.rmSync(dirToRemove, { recursive: true, force: true });
|
||
removed++;
|
||
} catch (_err) {
|
||
// Fail open — a single bad dir must not block install/uninstall.
|
||
}
|
||
}
|
||
|
||
// Prune the old skills dir if now empty — leaves the configHome clean.
|
||
// Never remove a non-empty container (user may keep other content there).
|
||
try {
|
||
if (fs.existsSync(oldSkillsDir) && fs.readdirSync(oldSkillsDir).length === 0) {
|
||
fs.rmdirSync(oldSkillsDir);
|
||
}
|
||
} catch (_err) {
|
||
// best-effort container cleanup
|
||
}
|
||
|
||
return removed;
|
||
}
|
||
|
||
/**
|
||
* When a runtime's skills kind declares an alternate `home` (split-home move),
|
||
* return the now-stale configDir-rooted skills location that installs before the
|
||
* move used; null when no move is in effect (no home override, or home resolves
|
||
* to the same path). Descriptor-driven — no per-runtime hardcoding.
|
||
* @returns {string|null}
|
||
*/
|
||
function _resolveMovedSkillsOldDir(runtime, targetDir, scope) {
|
||
try {
|
||
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
|
||
const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
|
||
if (skillsKind && skillsKind.home) {
|
||
const oldDir = path.join(targetDir, skillsKind.destSubpath);
|
||
const newDir = path.join(skillsKind.home, skillsKind.destSubpath);
|
||
if (path.resolve(oldDir) !== path.resolve(newDir)) return oldDir;
|
||
}
|
||
} catch (_e) {
|
||
// No migration when the layout can't resolve — never block on this.
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Generate the MSD config block for Codex config.toml.
|
||
*
|
||
* #2406 — standalone per-agent TOMLs (written by installCodexConfig to
|
||
* `$CODEX_HOME/agents/<name>.toml`) are auto-discovered by Codex and are the
|
||
* SOLE canonical registration source for each role. This block therefore no
|
||
* longer emits `[agents.<name>]` role tables that point `config_file` back at
|
||
* those same standalone TOMLs — that was a second, redundant declaration of
|
||
* the same role in one config layer, and Codex logged "Ignoring malformed
|
||
* agent role definition: duplicate agent role name" once per agent as a
|
||
* result. Only the bare `[agents]` dispatch-tuning scalar table is emitted
|
||
* here; role name/description/model/reasoning-effort/sandbox settings remain
|
||
* fully discoverable through the standalone TOML alone.
|
||
* @param {Array<{name: string, description: string}>} _agents unused — kept
|
||
* in the signature for call-site compatibility (installCodexConfig and
|
||
* existing tests still pass it positionally); per-agent role tables are no
|
||
* longer generated from it.
|
||
* @param {string} [_targetDir] unused — the standalone-TOML `config_file`
|
||
* path it used to resolve is no longer emitted here; kept for the same
|
||
* call-site-compatibility reason as `_agents`.
|
||
*/
|
||
function generateCodexConfigBlock(_agents, _targetDir) {
|
||
const lines = [
|
||
MSD_CODEX_MARKER,
|
||
'',
|
||
];
|
||
|
||
// ADR-1239 upgrade 2 / #2088 — explicit dispatch tuning. Pin `max_depth` on the
|
||
// `[agents]` (AgentsToml) table rather than relying on codex-cli's implicit
|
||
// default, realizing the negotiated `dispatch.maxDepth: 1` axis. This bare
|
||
// `[agents]` scalar table is validated by validateCodexConfigSchema, which
|
||
// permits a known-scalar-only `[agents]`.
|
||
lines.push('[agents]');
|
||
lines.push(`max_depth = ${MSD_CODEX_AGENTS_MAX_DEPTH}`);
|
||
lines.push('');
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Extract a user's pre-existing AgentsToml scalar assignments from a bare
|
||
* `[agents]` table — every known scalar EXCEPT `max_depth` (which MSD manages
|
||
* and always re-emits as 1). Returned as raw `key = value` line strings so
|
||
* mergeCodexConfig can PRESERVE them in the managed block instead of silently
|
||
* dropping the user's tuning when the bare `[agents]` table is purged (#2088
|
||
* review finding: the loosened validator declares such a table legitimate, so
|
||
* install must not destroy it). Only the first bare `[agents]` section is read;
|
||
* `[agents.<name>]` role tables are ignored. Fail-open → [].
|
||
* @returns {string[]}
|
||
*/
|
||
function extractCodexUserAgentsScalars(content) {
|
||
const preserved = [];
|
||
let section;
|
||
try {
|
||
section = getTomlTableSections(content).find((s) => !s.array && s.path === 'agents');
|
||
} catch (_e) {
|
||
return preserved;
|
||
}
|
||
if (!section) return preserved;
|
||
const body = content.slice(section.headerEnd, section.end);
|
||
for (const record of getTomlLineRecords(body)) {
|
||
if (record.startsInMultilineString || record.tableHeader) continue;
|
||
const trimmed = record.text.trim();
|
||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||
if (!record.keySegments || record.keySegments.length !== 1) continue;
|
||
const key = record.keySegments[0];
|
||
if (key === 'max_depth') continue; // MSD-managed — MSD's value wins.
|
||
if (!CODEX_AGENTS_TOML_SCALAR_KEYS.has(key)) continue;
|
||
preserved.push(trimmed);
|
||
}
|
||
return preserved;
|
||
}
|
||
|
||
/**
|
||
* Splice preserved user AgentsToml scalar lines into the managed MSD config
|
||
* block, immediately after the `[agents]` header and before MSD's `max_depth`
|
||
* line. Operates on the pre-EOL-normalization block (LF joins), matching only
|
||
* the bare `[agents]` header (never `[agents.<name>]`). Returns the block
|
||
* unchanged when there is nothing to preserve or the anchor is absent.
|
||
*/
|
||
function spliceCodexAgentsScalars(block, scalarLines) {
|
||
if (!scalarLines || scalarLines.length === 0) return block;
|
||
return block.replace(/(\n\[agents\]\n)(max_depth = )/, `$1${scalarLines.join('\n')}\n$2`);
|
||
}
|
||
|
||
/**
|
||
* Strip any managed MSD agent sections from a TOML string.
|
||
*
|
||
* Used by the uninstall path (`stripMsdFromCodexConfig`). Removes only what MSD
|
||
* owns; user-authored `[agents.<name>]` and `[[agents]]` entries are preserved
|
||
* so uninstall returns the file to its pre-MSD shape.
|
||
*
|
||
* Handles BOTH shapes so reinstall self-heals configs from all MSD versions:
|
||
* - Current (#2727): `[agents.msd-*]` struct tables (Codex 0.120.0+).
|
||
* - Legacy (#2645): `[[agents]]` array-of-tables whose `name = "msd-*"`.
|
||
*
|
||
* A section runs from its header to the next `[` header or EOF.
|
||
*/
|
||
function stripCodexMsdAgentSections(content) {
|
||
// Use the TOML-aware section parser so we never absorb adjacent user-authored
|
||
// tables — even if their headers are indented or otherwise oddly placed.
|
||
const sections = getTomlTableSections(content).filter((section) => {
|
||
// Current `[agents.msd-<name>]` struct tables (#2727, Codex 0.120.0+).
|
||
if (!section.array && /^agents\.msd-/.test(section.path)) {
|
||
return true;
|
||
}
|
||
|
||
// MSD's managed `[agents]` scalar block (ADR-1239 upgrade 2 / #2088 — the
|
||
// `max_depth` dispatch-tuning table). Install purges any pre-existing bare
|
||
// `[agents]` and writes its own, so a known-scalar-only bare `[agents]` is
|
||
// MSD-owned; strip it on uninstall. (The marker path already removes it via
|
||
// the marker-to-EOF cut; this covers the no-marker fallback.)
|
||
if (!section.array && section.path === 'agents') {
|
||
const body = content.slice(section.headerEnd, section.end);
|
||
return codexBareAgentsHasOnlyKnownScalars(body);
|
||
}
|
||
|
||
// Legacy `[[agents]]` array-of-tables (#2645) — only strip blocks whose
|
||
// `name = "msd-..."`, preserving user-authored [[agents]] entries.
|
||
if (section.array && section.path === 'agents') {
|
||
const body = content.slice(section.headerEnd, section.end);
|
||
const nameMatch = body.match(/^[ \t]*name[ \t]*=[ \t]*["']([^"']+)["']/m);
|
||
return Boolean(nameMatch && /^msd-/.test(nameMatch[1]));
|
||
}
|
||
|
||
return false;
|
||
});
|
||
|
||
return removeContentRanges(
|
||
content,
|
||
sections.map(({ start, end }) => ({ start, end })),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Strip MSD sections from Codex config.toml content.
|
||
* Returns cleaned content, or null if file would be empty.
|
||
*/
|
||
function stripMsdFromCodexConfig(content) {
|
||
const eol = detectLineEnding(content);
|
||
const markerIndex = content.indexOf(MSD_CODEX_MARKER);
|
||
const codexHooksOwnership = getManagedCodexHooksOwnership(content);
|
||
|
||
if (markerIndex !== -1) {
|
||
// Has MSD marker — remove everything from marker to EOF. First recover the
|
||
// user's own AgentsToml scalars (max_threads etc.) that install folded into
|
||
// the managed [agents] block (#2088), so a full install→uninstall cycle
|
||
// round-trips the user's tuning. MSD-managed max_depth is dropped.
|
||
const preservedScalars = extractCodexUserAgentsScalars(content.slice(markerIndex));
|
||
let before = content.substring(0, markerIndex);
|
||
before = stripCodexHooksFeatureAssignments(before, codexHooksOwnership);
|
||
// Also strip MSD-injected feature keys above the marker (Case 3 inject)
|
||
before = before.replace(/^multi_agent\s*=\s*true\s*(?:\r?\n)?/m, '');
|
||
before = before.replace(/^default_mode_request_user_input\s*=\s*true\s*(?:\r?\n)?/m, '');
|
||
before = before.replace(/^\[features\]\s*\n(?=\[|$)/m, '');
|
||
before = before.replace(/^\[agents\]\s*\n(?=\[|$)/m, '');
|
||
before = before.replace(/^(?:\r?\n)+/, '').trimEnd();
|
||
if (preservedScalars.length > 0) {
|
||
before = (before ? before + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol);
|
||
}
|
||
if (!before) return null;
|
||
return before + eol;
|
||
}
|
||
|
||
// No marker but may have MSD-injected feature keys
|
||
let cleaned = content;
|
||
cleaned = stripCodexHooksFeatureAssignments(cleaned, codexHooksOwnership);
|
||
cleaned = cleaned.replace(/^multi_agent\s*=\s*true\s*(?:\r?\n)?/m, '');
|
||
cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*(?:\r?\n)?/m, '');
|
||
|
||
// #2088: recover the user's own AgentsToml scalars before the [agents] table is
|
||
// stripped, so they survive uninstall even in the no-marker fallback path.
|
||
const preservedScalars = extractCodexUserAgentsScalars(cleaned);
|
||
|
||
// Remove [agents.msd-*] sections + the managed known-scalar [agents] table.
|
||
cleaned = stripCodexMsdAgentSections(cleaned);
|
||
|
||
// Remove [features] section if now empty (only header, no keys before next section)
|
||
cleaned = cleaned.replace(/^\[features\]\s*\n(?=\[|$)/m, '');
|
||
|
||
// Remove [agents] section if now empty
|
||
cleaned = cleaned.replace(/^\[agents\]\s*\n(?=\[|$)/m, '');
|
||
|
||
cleaned = cleaned.replace(/^(?:\r?\n)+/, '').trimEnd();
|
||
|
||
if (preservedScalars.length > 0) {
|
||
cleaned = (cleaned ? cleaned + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol);
|
||
}
|
||
|
||
if (!cleaned) return null;
|
||
return cleaned + eol;
|
||
}
|
||
|
||
function detectLineEnding(content) {
|
||
const firstNewlineIndex = content.indexOf('\n');
|
||
if (firstNewlineIndex === -1) {
|
||
return '\n';
|
||
}
|
||
return firstNewlineIndex > 0 && content[firstNewlineIndex - 1] === '\r' ? '\r\n' : '\n';
|
||
}
|
||
|
||
function splitTomlLines(content) {
|
||
const lines = [];
|
||
let start = 0;
|
||
|
||
while (start < content.length) {
|
||
const newlineIndex = content.indexOf('\n', start);
|
||
if (newlineIndex === -1) {
|
||
lines.push({
|
||
start,
|
||
end: content.length,
|
||
text: content.slice(start),
|
||
eol: '',
|
||
});
|
||
break;
|
||
}
|
||
|
||
const hasCr = newlineIndex > start && content[newlineIndex - 1] === '\r';
|
||
const end = hasCr ? newlineIndex - 1 : newlineIndex;
|
||
lines.push({
|
||
start,
|
||
end,
|
||
text: content.slice(start, end),
|
||
eol: hasCr ? '\r\n' : '\n',
|
||
});
|
||
start = newlineIndex + 1;
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
|
||
function findTomlCommentStart(line) {
|
||
let i = 0;
|
||
let multilineState = null;
|
||
|
||
while (i < line.length) {
|
||
if (multilineState === 'literal') {
|
||
const closeIndex = line.indexOf('\'\'\'', i);
|
||
if (closeIndex === -1) {
|
||
return -1;
|
||
}
|
||
i = closeIndex + 3;
|
||
multilineState = null;
|
||
continue;
|
||
}
|
||
|
||
if (multilineState === 'basic') {
|
||
const closeIndex = findMultilineBasicStringClose(line, i);
|
||
if (closeIndex === -1) {
|
||
return -1;
|
||
}
|
||
i = closeIndex + 3;
|
||
multilineState = null;
|
||
continue;
|
||
}
|
||
|
||
const ch = line[i];
|
||
|
||
if (ch === '#') {
|
||
return i;
|
||
}
|
||
|
||
if (ch === '\'') {
|
||
if (line.startsWith('\'\'\'', i)) {
|
||
multilineState = 'literal';
|
||
i += 3;
|
||
continue;
|
||
}
|
||
const close = line.indexOf('\'', i + 1);
|
||
if (close === -1) return -1;
|
||
i = close + 1;
|
||
continue;
|
||
}
|
||
|
||
if (ch === '"') {
|
||
if (line.startsWith('"""', i)) {
|
||
multilineState = 'basic';
|
||
i += 3;
|
||
continue;
|
||
}
|
||
i += 1;
|
||
while (i < line.length) {
|
||
if (line[i] === '\\') {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (line[i] === '"') {
|
||
i += 1;
|
||
break;
|
||
}
|
||
i += 1;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
i += 1;
|
||
}
|
||
|
||
return -1;
|
||
}
|
||
|
||
function isEscapedInBasicString(line, index) {
|
||
let slashCount = 0;
|
||
let cursor = index - 1;
|
||
|
||
while (cursor >= 0 && line[cursor] === '\\') {
|
||
slashCount += 1;
|
||
cursor -= 1;
|
||
}
|
||
|
||
return slashCount % 2 === 1;
|
||
}
|
||
|
||
function findMultilineBasicStringClose(line, startIndex) {
|
||
let searchIndex = startIndex;
|
||
|
||
while (searchIndex < line.length) {
|
||
const closeIndex = line.indexOf('"""', searchIndex);
|
||
if (closeIndex === -1) {
|
||
return -1;
|
||
}
|
||
if (!isEscapedInBasicString(line, closeIndex)) {
|
||
return closeIndex;
|
||
}
|
||
searchIndex = closeIndex + 1;
|
||
}
|
||
|
||
return -1;
|
||
}
|
||
|
||
function advanceTomlMultilineStringState(line, multilineState) {
|
||
let i = 0;
|
||
let state = multilineState;
|
||
|
||
while (i < line.length) {
|
||
if (state === 'literal') {
|
||
const closeIndex = line.indexOf('\'\'\'', i);
|
||
if (closeIndex === -1) {
|
||
return state;
|
||
}
|
||
i = closeIndex + 3;
|
||
state = null;
|
||
continue;
|
||
}
|
||
|
||
if (state === 'basic') {
|
||
const closeIndex = findMultilineBasicStringClose(line, i);
|
||
if (closeIndex === -1) {
|
||
return state;
|
||
}
|
||
i = closeIndex + 3;
|
||
state = null;
|
||
continue;
|
||
}
|
||
|
||
const ch = line[i];
|
||
|
||
if (ch === '#') {
|
||
return state;
|
||
}
|
||
|
||
if (ch === '\'') {
|
||
if (line.startsWith('\'\'\'', i)) {
|
||
state = 'literal';
|
||
i += 3;
|
||
continue;
|
||
}
|
||
const close = line.indexOf('\'', i + 1);
|
||
if (close === -1) {
|
||
return state;
|
||
}
|
||
i = close + 1;
|
||
continue;
|
||
}
|
||
|
||
if (ch === '"') {
|
||
if (line.startsWith('"""', i)) {
|
||
state = 'basic';
|
||
i += 3;
|
||
continue;
|
||
}
|
||
i += 1;
|
||
while (i < line.length) {
|
||
if (line[i] === '\\') {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (line[i] === '"') {
|
||
i += 1;
|
||
break;
|
||
}
|
||
i += 1;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
i += 1;
|
||
}
|
||
|
||
return state;
|
||
}
|
||
|
||
function parseTomlBracketHeader(line, array) {
|
||
let i = 0;
|
||
|
||
while (i < line.length && /\s/.test(line[i])) {
|
||
i += 1;
|
||
}
|
||
|
||
const open = array ? '[[' : '[';
|
||
const close = array ? ']]' : ']';
|
||
if (!line.startsWith(open, i)) {
|
||
return null;
|
||
}
|
||
|
||
i += open.length;
|
||
const start = i;
|
||
|
||
while (i < line.length) {
|
||
if (line[i] === '\'' || line[i] === '"') {
|
||
const quote = line[i];
|
||
i += 1;
|
||
|
||
while (i < line.length) {
|
||
if (quote === '"' && line[i] === '\\') {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
|
||
if (line[i] === quote) {
|
||
i += 1;
|
||
break;
|
||
}
|
||
|
||
i += 1;
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
if (line.startsWith(close, i)) {
|
||
const rawPath = line.slice(start, i).trim();
|
||
const segments = parseTomlKeyPath(rawPath);
|
||
if (!segments) {
|
||
return null;
|
||
}
|
||
|
||
i += close.length;
|
||
while (i < line.length && /\s/.test(line[i])) {
|
||
i += 1;
|
||
}
|
||
|
||
if (i < line.length && line[i] !== '#') {
|
||
return null;
|
||
}
|
||
|
||
return { path: segments.join('.'), segments, array };
|
||
}
|
||
|
||
if (line[i] === '#' || line[i] === '\r' || line[i] === '\n') {
|
||
return null;
|
||
}
|
||
|
||
i += 1;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function parseTomlTableHeader(line) {
|
||
return parseTomlBracketHeader(line, true) || parseTomlBracketHeader(line, false);
|
||
}
|
||
|
||
function findTomlAssignmentEquals(line) {
|
||
let i = 0;
|
||
|
||
while (i < line.length) {
|
||
const ch = line[i];
|
||
|
||
if (ch === '#') {
|
||
return -1;
|
||
}
|
||
|
||
if (ch === '\'') {
|
||
i += 1;
|
||
while (i < line.length) {
|
||
if (line[i] === '\'') {
|
||
i += 1;
|
||
break;
|
||
}
|
||
i += 1;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (ch === '"') {
|
||
i += 1;
|
||
while (i < line.length) {
|
||
if (line[i] === '\\') {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (line[i] === '"') {
|
||
i += 1;
|
||
break;
|
||
}
|
||
i += 1;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (ch === '=') {
|
||
return i;
|
||
}
|
||
|
||
i += 1;
|
||
}
|
||
|
||
return -1;
|
||
}
|
||
|
||
function parseTomlKeyPath(keyText) {
|
||
const segments = [];
|
||
let i = 0;
|
||
|
||
while (i < keyText.length) {
|
||
while (i < keyText.length && /\s/.test(keyText[i])) {
|
||
i += 1;
|
||
}
|
||
|
||
if (i >= keyText.length) {
|
||
break;
|
||
}
|
||
|
||
if (keyText[i] === '\'' || keyText[i] === '"') {
|
||
const quote = keyText[i];
|
||
let segment = '';
|
||
let closed = false;
|
||
i += 1;
|
||
|
||
while (i < keyText.length) {
|
||
if (quote === '"' && keyText[i] === '\\') {
|
||
if (i + 1 >= keyText.length) {
|
||
return null;
|
||
}
|
||
segment += keyText[i + 1];
|
||
i += 2;
|
||
continue;
|
||
}
|
||
|
||
if (keyText[i] === quote) {
|
||
i += 1;
|
||
closed = true;
|
||
break;
|
||
}
|
||
|
||
segment += keyText[i];
|
||
i += 1;
|
||
}
|
||
|
||
if (!closed) {
|
||
return null;
|
||
}
|
||
|
||
segments.push(segment);
|
||
} else {
|
||
const match = keyText.slice(i).match(/^[A-Za-z0-9_-]+/);
|
||
if (!match) {
|
||
return null;
|
||
}
|
||
segments.push(match[0]);
|
||
i += match[0].length;
|
||
}
|
||
|
||
while (i < keyText.length && /\s/.test(keyText[i])) {
|
||
i += 1;
|
||
}
|
||
|
||
if (i >= keyText.length) {
|
||
break;
|
||
}
|
||
|
||
if (keyText[i] !== '.') {
|
||
return null;
|
||
}
|
||
|
||
i += 1;
|
||
}
|
||
|
||
return segments.length > 0 ? segments : null;
|
||
}
|
||
|
||
function parseTomlKey(line) {
|
||
const header = parseTomlTableHeader(line);
|
||
if (header) {
|
||
return null;
|
||
}
|
||
|
||
const equalsIndex = findTomlAssignmentEquals(line);
|
||
if (equalsIndex === -1) {
|
||
return null;
|
||
}
|
||
|
||
const raw = line.slice(0, equalsIndex).trim();
|
||
const segments = parseTomlKeyPath(raw);
|
||
if (!segments) {
|
||
return null;
|
||
}
|
||
|
||
return { raw, segments };
|
||
}
|
||
|
||
function getTomlLineRecords(content) {
|
||
const lines = splitTomlLines(content);
|
||
const records = [];
|
||
let currentTablePath = null;
|
||
let multilineState = null;
|
||
|
||
for (const line of lines) {
|
||
const startsInMultilineString = multilineState !== null;
|
||
const record = {
|
||
...line,
|
||
startsInMultilineString,
|
||
tablePath: currentTablePath,
|
||
tableHeader: null,
|
||
keySegments: null,
|
||
};
|
||
|
||
if (!startsInMultilineString) {
|
||
const header = parseTomlTableHeader(line.text);
|
||
if (header) {
|
||
record.tableHeader = header;
|
||
currentTablePath = header.path;
|
||
} else {
|
||
const key = parseTomlKey(line.text);
|
||
record.keySegments = key ? key.segments : null;
|
||
record.keyRaw = key ? key.raw : null;
|
||
}
|
||
}
|
||
|
||
multilineState = advanceTomlMultilineStringState(line.text, multilineState);
|
||
records.push(record);
|
||
}
|
||
|
||
return records;
|
||
}
|
||
|
||
function getTomlTableSections(content) {
|
||
const headerLines = getTomlLineRecords(content).filter((record) => record.tableHeader);
|
||
|
||
return headerLines.map((record, index) => ({
|
||
path: record.tableHeader.path,
|
||
// segments preserves the true parsed key count so callers that need to
|
||
// distinguish a 2-segment path like hooks."before.tool" from a 3-segment
|
||
// path like hooks.SessionStart.hooks can do so without splitting on dots
|
||
// (which misclassifies quoted key names that contain dot characters).
|
||
segments: record.tableHeader.segments,
|
||
array: record.tableHeader.array,
|
||
start: record.start,
|
||
headerEnd: record.end + record.eol.length,
|
||
end: index + 1 < headerLines.length ? headerLines[index + 1].start : content.length,
|
||
}));
|
||
}
|
||
|
||
function collapseTomlBlankLines(content) {
|
||
const eol = detectLineEnding(content);
|
||
return content.replace(/(?:\r?\n){3,}/g, eol + eol);
|
||
}
|
||
|
||
function removeContentRanges(content, ranges) {
|
||
const normalizedRanges = ranges
|
||
.filter((range) => range && range.start < range.end)
|
||
.sort((a, b) => a.start - b.start);
|
||
|
||
if (normalizedRanges.length === 0) {
|
||
return content;
|
||
}
|
||
|
||
const mergedRanges = [{ ...normalizedRanges[0] }];
|
||
|
||
for (let i = 1; i < normalizedRanges.length; i += 1) {
|
||
const current = normalizedRanges[i];
|
||
const previous = mergedRanges[mergedRanges.length - 1];
|
||
|
||
if (current.start <= previous.end) {
|
||
previous.end = Math.max(previous.end, current.end);
|
||
continue;
|
||
}
|
||
|
||
mergedRanges.push({ ...current });
|
||
}
|
||
|
||
let cleaned = '';
|
||
let cursor = 0;
|
||
|
||
for (const range of mergedRanges) {
|
||
cleaned += content.slice(cursor, range.start);
|
||
cursor = range.end;
|
||
}
|
||
|
||
cleaned += content.slice(cursor);
|
||
return cleaned;
|
||
}
|
||
|
||
function stripCodexHooksFeatureAssignments(content, ownership = null) {
|
||
const lineRecords = getTomlLineRecords(content);
|
||
const tableSections = getTomlTableSections(content);
|
||
const removalRanges = [];
|
||
const featuresSection = tableSections.find((section) => !section.array && section.path === 'features');
|
||
const shouldStripSectionKey = ownership === 'section' || ownership === 'all';
|
||
const shouldStripRootDottedKey = ownership === 'root_dotted' || ownership === 'all';
|
||
|
||
if (featuresSection && shouldStripSectionKey) {
|
||
const sectionRecords = lineRecords.filter((record) =>
|
||
!record.tableHeader &&
|
||
record.start >= featuresSection.headerEnd &&
|
||
record.end + record.eol.length <= featuresSection.end
|
||
);
|
||
|
||
const codexHookRecords = sectionRecords.filter((record) =>
|
||
!record.startsInMultilineString &&
|
||
record.keySegments &&
|
||
record.keySegments.length === 1 &&
|
||
isCodexHooksFeatureKey(record.keySegments[0])
|
||
);
|
||
|
||
for (const record of codexHookRecords) {
|
||
removalRanges.push({
|
||
start: record.start,
|
||
end: findTomlAssignmentBlockEnd(content, record),
|
||
});
|
||
}
|
||
|
||
if (codexHookRecords.length > 0) {
|
||
const removedStarts = new Set(codexHookRecords.map((record) => record.start));
|
||
const hasRemainingContent = sectionRecords.some((record) => {
|
||
if (removedStarts.has(record.start)) {
|
||
return false;
|
||
}
|
||
|
||
const trimmed = record.text.trim();
|
||
return trimmed !== '' && !trimmed.startsWith('#');
|
||
});
|
||
const hasRemainingComments = sectionRecords.some((record) => {
|
||
if (removedStarts.has(record.start)) {
|
||
return false;
|
||
}
|
||
|
||
return record.text.trim().startsWith('#');
|
||
});
|
||
|
||
if (!hasRemainingContent && !hasRemainingComments) {
|
||
removalRanges.push({
|
||
start: featuresSection.start,
|
||
end: featuresSection.end,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (shouldStripRootDottedKey) {
|
||
const rootCodexHookRecords = lineRecords.filter((record) =>
|
||
!record.tableHeader &&
|
||
!record.startsInMultilineString &&
|
||
record.tablePath === null &&
|
||
record.keySegments &&
|
||
record.keySegments.length === 2 &&
|
||
record.keySegments[0] === 'features' &&
|
||
isCodexHooksFeatureKey(record.keySegments[1])
|
||
);
|
||
|
||
for (const record of rootCodexHookRecords) {
|
||
removalRanges.push({
|
||
start: record.start,
|
||
end: findTomlAssignmentBlockEnd(content, record),
|
||
});
|
||
}
|
||
}
|
||
|
||
return removeContentRanges(content, removalRanges);
|
||
}
|
||
|
||
function getManagedCodexHooksOwnership(content) {
|
||
const markerIndex = content.indexOf(MSD_CODEX_MARKER);
|
||
if (markerIndex === -1) {
|
||
return null;
|
||
}
|
||
|
||
const afterMarker = content.slice(markerIndex + MSD_CODEX_MARKER.length);
|
||
const match = afterMarker.match(/^\r?\n# MSD codex_hooks ownership: (section|root_dotted)\r?\n/);
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
function setManagedCodexHooksOwnership(content, ownership) {
|
||
const markerIndex = content.indexOf(MSD_CODEX_MARKER);
|
||
if (markerIndex === -1) {
|
||
return content;
|
||
}
|
||
|
||
const eol = detectLineEnding(content);
|
||
const markerEnd = markerIndex + MSD_CODEX_MARKER.length;
|
||
const afterMarker = content.slice(markerEnd);
|
||
const normalizedAfterMarker = afterMarker.replace(
|
||
/^\r?\n# MSD codex_hooks ownership: (?:section|root_dotted)\r?\n/,
|
||
eol
|
||
);
|
||
|
||
if (!ownership) {
|
||
return content.slice(0, markerEnd) + normalizedAfterMarker;
|
||
}
|
||
|
||
const remainder = normalizedAfterMarker.replace(/^\r?\n/, '');
|
||
return content.slice(0, markerEnd) +
|
||
eol +
|
||
`${MSD_CODEX_HOOKS_OWNERSHIP_PREFIX}${ownership}${eol}` +
|
||
remainder;
|
||
}
|
||
|
||
function isLegacyMsdAgentsSection(body) {
|
||
const lineRecords = getTomlLineRecords(body);
|
||
const legacyKeys = new Set(['max_threads', 'max_depth']);
|
||
let sawLegacyKey = false;
|
||
|
||
for (const record of lineRecords) {
|
||
if (record.startsInMultilineString) {
|
||
return false;
|
||
}
|
||
|
||
if (record.tableHeader) {
|
||
return false;
|
||
}
|
||
|
||
const trimmed = record.text.trim();
|
||
if (!trimmed || trimmed.startsWith('#')) {
|
||
continue;
|
||
}
|
||
|
||
if (!record.keySegments || record.keySegments.length !== 1 || !legacyKeys.has(record.keySegments[0])) {
|
||
return false;
|
||
}
|
||
|
||
sawLegacyKey = true;
|
||
}
|
||
|
||
return sawLegacyKey;
|
||
}
|
||
|
||
function stripLeakedMsdCodexSections(content) {
|
||
// Defensive precedence (#2760): we own the `agents` namespace under our
|
||
// managed `msd-*` names, and the legacy bare-table and sequence forms
|
||
// (`[agents]`, `[[agents]]`) are invalid in the current Codex schema —
|
||
// they trigger "invalid type: ..., expected struct AgentsToml" and break
|
||
// every Codex CLI invocation. They MUST never coexist with the new
|
||
// `[agents.<name>]` struct format we now emit, so install-time always
|
||
// purges them regardless of MSD marker presence. Users who had legitimate
|
||
// user-authored `[[agents]]` entries before are already broken on Codex
|
||
// ≥0.124 — purging is the only path to a loadable config.
|
||
const leakedSections = getTomlTableSections(content)
|
||
.filter((section) => {
|
||
// Legacy [agents.msd-<name>] map tables (pre-#2645).
|
||
if (!section.array && section.path.startsWith('agents.msd-')) return true;
|
||
|
||
// ANY bare [agents] single-bracket table — invalid in current Codex
|
||
// schema, always purged at install time (#2760). Previously gated
|
||
// on `isLegacyMsdAgentsSection`, which missed bare tables holding
|
||
// arbitrary user keys (`default = "..."`, etc.) that still produce
|
||
// the AgentsToml type error.
|
||
if (!section.array && section.path === 'agents') return true;
|
||
|
||
// ANY [[agents]] array-of-tables — invalid in current Codex schema,
|
||
// always purged at install time (#2760). Previously gated on
|
||
// `name = "msd-..."` which preserved user-authored entries that are
|
||
// themselves rejected by Codex 0.124+.
|
||
if (section.array && section.path === 'agents') return true;
|
||
|
||
return false;
|
||
});
|
||
|
||
if (leakedSections.length === 0) {
|
||
return content;
|
||
}
|
||
|
||
let cleaned = '';
|
||
let cursor = 0;
|
||
|
||
for (const section of leakedSections) {
|
||
cleaned += content.slice(cursor, section.start);
|
||
cursor = section.end;
|
||
}
|
||
|
||
cleaned += content.slice(cursor);
|
||
return collapseTomlBlankLines(cleaned);
|
||
}
|
||
|
||
/**
|
||
* Strip MSD-managed legacy Codex hook blocks from a config.toml string
|
||
* using the TOML AST already used elsewhere in this file
|
||
* (`getTomlTableSections` + `removeContentRanges`). The earlier regex-based
|
||
* implementation required a precise key order, exact single-space padding
|
||
* around `=`, and exactly one blank line between Shape 4's parent/child
|
||
* tables — any deviation (an extra blank line, key reorder, an added
|
||
* `timeout` key, `event="SessionStart"` without spaces) silently leaked the
|
||
* stale block, sometimes corrupting the file by leaving orphaned key=value
|
||
* lines outside any table.
|
||
*
|
||
* The structural approach: find every `hooks*` table whose body contains a
|
||
* `command = "...msd-(check-update|update-check).js"` value, remove its
|
||
* exact byte range, and additionally remove any orphaned parent
|
||
* `[[hooks.SessionStart]]` whose body becomes empty as a result (Shape 4).
|
||
* The leading `# MSD Hooks` header line is swallowed by extending the
|
||
* removal range backward through any single preceding comment line.
|
||
*
|
||
* Pure function, exported for test coverage. Returns the input unchanged
|
||
* if no MSD-managed hook section is present.
|
||
*/
|
||
function stripStaleMsdHookBlocks(configContent) {
|
||
const sections = getTomlTableSections(configContent);
|
||
const lineRecords = getTomlLineRecords(configContent);
|
||
const hookSections = sections.filter(
|
||
(s) => s.path === 'hooks' || s.path.startsWith('hooks.')
|
||
);
|
||
if (hookSections.length === 0) {
|
||
return configContent;
|
||
}
|
||
|
||
// A section is MSD-managed if any structural `command` key inside its
|
||
// body parses to a string whose basename matches `msd-(check-update|
|
||
// update-check).js`. The TOML line parser already classified each line's
|
||
// `keySegments`, so we never inspect raw text — this handles arbitrary
|
||
// whitespace, key reordering, and additional keys robustly.
|
||
function sectionHasStaleCommand(section) {
|
||
const records = lineRecords.filter(
|
||
(r) => !r.startsInMultilineString
|
||
&& !r.tableHeader
|
||
&& r.start >= section.headerEnd
|
||
&& r.end + r.eol.length <= section.end
|
||
&& r.keySegments
|
||
&& r.keySegments.length === 1
|
||
&& r.keySegments[0] === 'command'
|
||
);
|
||
for (const record of records) {
|
||
const equalsIndex = findTomlAssignmentEquals(record.text);
|
||
if (equalsIndex === -1) continue;
|
||
let parsed;
|
||
try {
|
||
parsed = parseTomlValue(record.text, equalsIndex + 1);
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (typeof parsed.value !== 'string') continue;
|
||
if (isManagedHookCommand(parsed.value, {
|
||
surface: 'codex-toml',
|
||
includeLegacyAliases: true,
|
||
})) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
const stale = new Set(hookSections.filter(sectionHasStaleCommand));
|
||
if (stale.size === 0) {
|
||
return configContent;
|
||
}
|
||
|
||
// Shape 4: a `[[hooks.SessionStart]]` event-table whose body is empty and
|
||
// whose immediately following section is a stale child handler table
|
||
// (`[[hooks.SessionStart.hooks]]`) becomes orphaned once the child is
|
||
// stripped. Detect emptiness via line records — no key/value lines and no
|
||
// non-blank, non-comment text between this section's header and the next.
|
||
function sectionBodyHasContent(section) {
|
||
return lineRecords.some(
|
||
(r) => !r.startsInMultilineString
|
||
&& !r.tableHeader
|
||
&& r.start >= section.headerEnd
|
||
&& r.end + r.eol.length <= section.end
|
||
&& r.text.trim() !== ''
|
||
&& !r.text.trim().startsWith('#')
|
||
);
|
||
}
|
||
for (let i = 0; i < sections.length; i += 1) {
|
||
const parent = sections[i];
|
||
if (stale.has(parent)) continue;
|
||
if (!parent.array || parent.path !== 'hooks.SessionStart') continue;
|
||
if (sectionBodyHasContent(parent)) continue;
|
||
const next = sections[i + 1];
|
||
if (next && stale.has(next) && next.path.startsWith('hooks.SessionStart.')) {
|
||
stale.add(parent);
|
||
}
|
||
}
|
||
|
||
// Each removal range starts at the table header. If the immediately
|
||
// preceding line is the MSD marker comment `# MSD Hooks` (and is not part
|
||
// of an already-removed section), extend the range backward to swallow it
|
||
// — preserves cleanliness on round-trip strip+rewrite.
|
||
const ranges = [];
|
||
for (const section of stale) {
|
||
let start = section.start;
|
||
const headerLineIdx = lineRecords.findIndex((r) => r.start === section.start);
|
||
const prev = headerLineIdx > 0 ? lineRecords[headerLineIdx - 1] : null;
|
||
if (prev && !prev.startsInMultilineString && prev.text.trim() === '# MSD Hooks') {
|
||
start = prev.start;
|
||
}
|
||
ranges.push({ start, end: section.end });
|
||
}
|
||
|
||
return collapseTomlBlankLines(removeContentRanges(configContent, ranges));
|
||
}
|
||
|
||
/**
|
||
* Migrate legacy Codex [hooks] map format to [[hooks]] array-of-tables format.
|
||
*
|
||
* Codex 0.124.0 changed from the old map-style hooks config:
|
||
* [hooks]
|
||
* [hooks.shell]
|
||
* command = "..."
|
||
*
|
||
* to the new array-of-tables format. #2760 CR5 finding 3 — emit the
|
||
* namespaced AoT shape directly so a mixed flat + namespaced layout never
|
||
* arises post-install:
|
||
* [[hooks.shell]]
|
||
* command = "..."
|
||
*
|
||
* This function detects any non-array hooks sections in the config and
|
||
* converts them to the namespaced `[[hooks.<TYPE>]]` array-of-tables form,
|
||
* preserving all key-value pairs and user comments. Bare [hooks] container
|
||
* sections (no key-value content) are dropped. User-authored AoT entries are
|
||
* left untouched.
|
||
*
|
||
* Returns the migrated content, or the original content unchanged if no
|
||
* legacy hooks sections were found.
|
||
*/
|
||
function migrateCodexHooksMapFormat(content) {
|
||
const sections = getTomlTableSections(content);
|
||
|
||
// Find all non-array hooks sections: bare [hooks] container or [hooks.TYPE] event tables.
|
||
// Use section.segments (parsed key count) rather than section.path.startsWith() so that
|
||
// nested handler tables like [hooks.SessionStart.hooks] (3 segments) are not mistakenly
|
||
// included and re-emitted as an event named "SessionStart.hooks".
|
||
// Exclude hooks.state and hooks.state.* — these are Codex's persistent hook-trust
|
||
// namespace (Codex CLI 0.130.0+) and use regular-table shape, never AoT.
|
||
const legacyMapSections = sections.filter(
|
||
(section) => !section.array && (
|
||
section.path === 'hooks' ||
|
||
(section.path.startsWith('hooks.') && section.segments.length === 2 &&
|
||
section.path !== 'hooks.state' && !section.path.startsWith('hooks.state.'))
|
||
)
|
||
);
|
||
|
||
// Find flat [[hooks]] array-of-tables entries (path === 'hooks', array === true).
|
||
// These are incompatible with [[hooks.<EVENT>]] namespaced form — both cannot
|
||
// coexist in the same TOML file because `hooks` cannot be simultaneously an
|
||
// array and a table. Migrate each flat entry to [[hooks.<EVENT>]] form using
|
||
// the `event` key as the event name.
|
||
const flatAotSections = sections.filter(
|
||
(section) => section.array && section.path === 'hooks'
|
||
);
|
||
|
||
// Find [[hooks.TYPE]] namespaced AoT entries that carry handler fields
|
||
// (command, type, timeout, statusMessage) at event-entry level but have no
|
||
// [[hooks.TYPE.hooks]] sub-table. This is the pre-#2773 single-block shape
|
||
// that Codex 0.124.0+ rejects. Promote them to the two-level nested form.
|
||
// Entries that already have a [[hooks.TYPE.hooks]] sub-table are left untouched.
|
||
// Matcher-only entries (no handler fields) are intentionally valid and skipped.
|
||
const STALE_HANDLER_FIELD_PATTERN = /^\s*(?:command|type|timeout|statusMessage)\s*=/m;
|
||
const staleNamespacedAotSections = sections.filter((section) => {
|
||
if (!section.array) return false;
|
||
if (!section.path.startsWith('hooks.')) return false;
|
||
// [[hooks.TYPE.hooks]] sub-tables have 3 parsed segments — skip them.
|
||
// Use section.segments (true parsed key count) rather than splitting
|
||
// section.path on '.', which misclassifies quoted event names that contain
|
||
// dots (e.g. [[hooks."before.tool"]] has segments ['hooks','before.tool']
|
||
// but path 'hooks.before.tool' would split into 3 parts).
|
||
if (section.segments.length !== 2) return false;
|
||
// Must carry at least one handler field at event-entry level.
|
||
const body = content.slice(section.headerEnd, section.end);
|
||
if (!STALE_HANDLER_FIELD_PATTERN.test(body)) return false;
|
||
// Don't migrate when the nested [[hooks.TYPE.hooks]] sub-table already exists.
|
||
const subPath = section.path + '.hooks';
|
||
return !sections.some((s) => s.array && s.path === subPath);
|
||
});
|
||
|
||
if (legacyMapSections.length === 0 && flatAotSections.length === 0 && staleNamespacedAotSections.length === 0) {
|
||
return content;
|
||
}
|
||
|
||
const eol = detectLineEnding(content);
|
||
|
||
// Helper: parse a hooks body into event-level and handler-level entries,
|
||
// returning { eventEntries, handlerEntries, hasExplicitType }.
|
||
// Event-level keys: matcher. Everything else is handler-level.
|
||
// The `event` key (used in flat [[hooks]] blocks) is consumed as the type
|
||
// name and excluded from both levels.
|
||
const EVENT_LEVEL_KEYS = new Set(['matcher']);
|
||
function parseHooksBody(body, skipKeys = new Set()) {
|
||
const bodyLines = body.split(/\r?\n/);
|
||
const eventEntries = [];
|
||
const handlerEntries = [];
|
||
let hasExplicitType = false;
|
||
for (const line of bodyLines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||
// Use parseTomlKey so hyphenated keys (e.g. status-message) and quoted
|
||
// keys are recognised — the old /^([\w.]+)\s*=/ regex silently dropped them.
|
||
const parsed = parseTomlKey(trimmed);
|
||
if (!parsed) continue;
|
||
// Hook body keys are always single-segment; use segments[0] for the name.
|
||
const key = parsed.segments[0];
|
||
if (skipKeys.has(key)) continue;
|
||
if (key === 'type') {
|
||
hasExplicitType = true;
|
||
handlerEntries.push(trimmed);
|
||
} else if (EVENT_LEVEL_KEYS.has(key)) {
|
||
eventEntries.push(trimmed);
|
||
} else {
|
||
handlerEntries.push(trimmed);
|
||
}
|
||
}
|
||
return { eventEntries, handlerEntries, hasExplicitType };
|
||
}
|
||
|
||
// TOML key quoting: bare keys may only contain [A-Za-z0-9_-]. Event names
|
||
// containing spaces, dots, or other punctuation must be wrapped in double-
|
||
// quoted TOML strings with backslash and double-quote characters escaped.
|
||
// Using raw event names in [[hooks.${type}]] headers produces invalid TOML
|
||
// for any non-bare-key character (e.g. "Before Tool" → [[hooks.Before Tool]]).
|
||
function tomlBareKey(key) {
|
||
if (/^[A-Za-z0-9_-]+$/.test(key)) return key;
|
||
return '"' + key.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
||
}
|
||
|
||
function buildNestedBlock(type, body, skipKeys = new Set()) {
|
||
const quotedType = tomlBareKey(type);
|
||
const { eventEntries, handlerEntries, hasExplicitType } = parseHooksBody(body, skipKeys);
|
||
const eventBody = eventEntries.length > 0 ? eventEntries.join(eol) + eol : '';
|
||
// If no handler fields were found (e.g. matcher-only entry), do not synthesise
|
||
// an empty [[hooks.TYPE.hooks]] block — that would produce structurally valid
|
||
// TOML but semantically broken output (a handler entry with no command).
|
||
if (handlerEntries.length === 0) {
|
||
return `[[hooks.${quotedType}]]${eol}${eventBody}`;
|
||
}
|
||
if (!hasExplicitType) handlerEntries.unshift('type = "command"');
|
||
const handlerBody = handlerEntries.join(eol) + eol;
|
||
return `[[hooks.${quotedType}]]${eol}${eventBody}${eol}[[hooks.${quotedType}.hooks]]${eol}${handlerBody}`;
|
||
}
|
||
|
||
// Extract the event name from a flat [[hooks]] section body.
|
||
// Returns null if no `event` key is found, if the value is an empty string, or if
|
||
// the quoting is unrecognised. Both TOML double-quoted ("...") and single-quoted
|
||
// ('...') strings are accepted. An empty event string (event = "" or event = '')
|
||
// is explicitly rejected — it cannot be meaningfully namespaced and is left untouched.
|
||
function extractFlatHookEventName(body) {
|
||
const TOML_EVENT_CAPTURE = /^\s*event\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')/m;
|
||
const m = body.match(TOML_EVENT_CAPTURE);
|
||
if (!m) return null;
|
||
const name = (m[1] ?? m[2] ?? '').trim();
|
||
return name || null;
|
||
}
|
||
|
||
const migratedFlatAotSections = flatAotSections.filter((section) => {
|
||
const body = content.slice(section.headerEnd, section.end);
|
||
return extractFlatHookEventName(body) !== null;
|
||
});
|
||
|
||
const legacyHooksSections = [...legacyMapSections, ...migratedFlatAotSections, ...staleNamespacedAotSections];
|
||
|
||
// Remove all legacy hooks sections from the content
|
||
let result = removeContentRanges(
|
||
content,
|
||
legacyHooksSections.map(({ start, end }) => ({ start, end })),
|
||
);
|
||
result = collapseTomlBlankLines(result);
|
||
|
||
// Map-format blocks ([hooks.TYPE]) are inserted at the position of the first
|
||
// remaining table section (preserving their relative placement in the file).
|
||
// Flat AoT blocks ([[hooks]] with event = "...") are always APPENDED because
|
||
// flat [[hooks]] entries only appear at the END of a TOML file (AoT cannot
|
||
// precede a regular table), and inserting before the first table would push
|
||
// them above [features] / [model] etc., corrupting relative ordering.
|
||
const mapOnlyBlocks = legacyMapSections
|
||
.filter((s) => s.path !== 'hooks') // skip bare [hooks] container
|
||
.map((s) => {
|
||
const body = content.slice(s.headerEnd, s.end);
|
||
// #3346: when the legacy `[hooks.<X>]` body declares `event = "..."`,
|
||
// prefer that as the event-name leaf key. The path segment <X> may be
|
||
// a `<file>:<event>:<line>:<col>` location identifier (Codex pre-AoT
|
||
// wrote those as table keys), which is not a valid leaf event name —
|
||
// emitting it verbatim produces a TOML key chain Codex 0.124.0+ rejects.
|
||
const bodyEvent = extractFlatHookEventName(body);
|
||
const type = bodyEvent !== null ? bodyEvent : s.path.slice('hooks.'.length);
|
||
const skipKeys = bodyEvent !== null ? new Set(['event']) : new Set();
|
||
return buildNestedBlock(type, body, skipKeys);
|
||
});
|
||
|
||
// Stale namespaced AoT blocks: [[hooks.TYPE]] entries with handler fields at
|
||
// event-entry level (no .hooks sub-table). Treated like map-format blocks —
|
||
// inserted before the first remaining table section.
|
||
const staleNamespacedAotBlocks = staleNamespacedAotSections.map((s) => {
|
||
const body = content.slice(s.headerEnd, s.end);
|
||
// #3346: see note in mapOnlyBlocks — body `event = "..."` wins over the
|
||
// raw path segment when both are present.
|
||
const bodyEvent = extractFlatHookEventName(body);
|
||
const type = bodyEvent !== null ? bodyEvent : s.path.slice('hooks.'.length);
|
||
const skipKeys = bodyEvent !== null ? new Set(['event']) : new Set();
|
||
return buildNestedBlock(type, body, skipKeys);
|
||
});
|
||
|
||
const flatAotBlocks = migratedFlatAotSections.map((s) => {
|
||
const body = content.slice(s.headerEnd, s.end);
|
||
const eventName = extractFlatHookEventName(body);
|
||
if (!eventName) return '';
|
||
return buildNestedBlock(eventName, body, new Set(['event']));
|
||
}).filter(Boolean);
|
||
|
||
// Insert map-format and stale-namespaced-AoT conversions before the first
|
||
// remaining table section (both share the same placement strategy).
|
||
const allMapStyleBlocks = [...mapOnlyBlocks, ...staleNamespacedAotBlocks];
|
||
if (allMapStyleBlocks.length > 0) {
|
||
const insertionText = allMapStyleBlocks.join('');
|
||
const remainingSections = getTomlTableSections(result);
|
||
if (remainingSections.length > 0) {
|
||
const firstTable = remainingSections[0];
|
||
const before = result.slice(0, firstTable.start);
|
||
const after = result.slice(firstTable.start);
|
||
const needsLeadingGap = before.length > 0 && !before.endsWith(eol + eol);
|
||
const needsTrailingGap = after.length > 0 && !insertionText.endsWith(eol + eol);
|
||
result = before +
|
||
(needsLeadingGap ? eol : '') +
|
||
insertionText +
|
||
(needsTrailingGap ? eol : '') +
|
||
after;
|
||
} else {
|
||
const needsGap = result.length > 0 && !result.endsWith(eol + eol);
|
||
result = result + (needsGap ? eol : '') + insertionText;
|
||
}
|
||
}
|
||
|
||
// Insert flat-AoT conversions before the MSD managed marker (if present) so
|
||
// the migrated user hooks stay in the "user" portion of the file and are not
|
||
// swept away when stripMsdFromCodexConfig strips from the marker to EOF.
|
||
// If no marker exists, append at the end of the file.
|
||
if (flatAotBlocks.length > 0) {
|
||
const insertionText = flatAotBlocks.join('');
|
||
const markerIdx = result.indexOf(MSD_CODEX_MARKER);
|
||
if (markerIdx !== -1) {
|
||
const before = result.slice(0, markerIdx).trimEnd();
|
||
const after = result.slice(markerIdx);
|
||
result = before + eol + eol + insertionText + eol + after;
|
||
} else {
|
||
const needsGap = result.length > 0 && !result.endsWith(eol + eol);
|
||
result = result + (needsGap ? eol : '') + insertionText;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Detect whether the user already uses the namespaced AoT hooks form
|
||
* (`[[hooks.<EVENT>]]`) for the given event in the config. When true,
|
||
* the MSD-managed hook block must be emitted in the same shape so it
|
||
* coexists cleanly — mixing `[[hooks]]` (flat) with `[[hooks.SessionStart]]`
|
||
* (namespaced) in the same file confuses round-trip writers and can
|
||
* produce a config that Codex rejects (#2760, defect 3).
|
||
*/
|
||
function hasUserNamespacedAotHooks(content, event) {
|
||
const sections = getTomlTableSections(content);
|
||
return sections.some(
|
||
(section) => section.array && section.path === `hooks.${event}`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Parse a TOML value RHS expression starting at index `i` of `text`.
|
||
* Returns { value, end } on success or throws on parse failure.
|
||
*
|
||
* Supports the value forms MSD emits or that real Codex configs commonly use:
|
||
* - basic strings ("…" with simple escapes)
|
||
* - literal strings ('…')
|
||
* - booleans (true / false)
|
||
* - integers (optional sign, decimal digits)
|
||
* - inline arrays of the above
|
||
* - inline tables { k = v, … }
|
||
*
|
||
* This is intentionally not a complete TOML implementation — it is the
|
||
* minimal value grammar required to validate Codex config structure and to
|
||
* back behavioral assertions in tests (#2760).
|
||
*/
|
||
function parseTomlValue(text, i) {
|
||
// Skip leading whitespace.
|
||
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) {
|
||
i += 1;
|
||
}
|
||
if (i >= text.length) {
|
||
throw new Error('expected value, got end of input');
|
||
}
|
||
|
||
const ch = text[i];
|
||
|
||
// Basic string
|
||
if (ch === '"') {
|
||
if (text.startsWith('"""', i)) {
|
||
const close = findMultilineBasicStringClose(text, i + 3);
|
||
if (close === -1) {
|
||
throw new Error('unterminated multi-line basic string');
|
||
}
|
||
const raw = text.slice(i + 3, close);
|
||
return { value: raw.replace(/^\r?\n/, ''), end: close + 3 };
|
||
}
|
||
let j = i + 1;
|
||
let out = '';
|
||
while (j < text.length) {
|
||
const c = text[j];
|
||
if (c === '\\') {
|
||
const next = text[j + 1];
|
||
if (next === 'n') { out += '\n'; j += 2; continue; }
|
||
if (next === 't') { out += '\t'; j += 2; continue; }
|
||
if (next === 'r') { out += '\r'; j += 2; continue; }
|
||
if (next === '\\') { out += '\\'; j += 2; continue; }
|
||
if (next === '"') { out += '"'; j += 2; continue; }
|
||
if (next === '/') { out += '/'; j += 2; continue; }
|
||
// Pass-through unrecognized escape (Codex/MSD don't use these).
|
||
out += next === undefined ? '' : next;
|
||
j += 2;
|
||
continue;
|
||
}
|
||
if (c === '"') {
|
||
return { value: out, end: j + 1 };
|
||
}
|
||
out += c;
|
||
j += 1;
|
||
}
|
||
throw new Error('unterminated basic string');
|
||
}
|
||
|
||
// Literal string
|
||
if (ch === '\'') {
|
||
if (text.startsWith('\'\'\'', i)) {
|
||
const close = text.indexOf('\'\'\'', i + 3);
|
||
if (close === -1) throw new Error('unterminated multi-line literal string');
|
||
return { value: text.slice(i + 3, close).replace(/^\r?\n/, ''), end: close + 3 };
|
||
}
|
||
const close = text.indexOf('\'', i + 1);
|
||
if (close === -1) throw new Error('unterminated literal string');
|
||
return { value: text.slice(i + 1, close), end: close + 1 };
|
||
}
|
||
|
||
// Boolean
|
||
if (text.startsWith('true', i) && !/[A-Za-z0-9_-]/.test(text[i + 4] || '')) {
|
||
return { value: true, end: i + 4 };
|
||
}
|
||
if (text.startsWith('false', i) && !/[A-Za-z0-9_-]/.test(text[i + 5] || '')) {
|
||
return { value: false, end: i + 5 };
|
||
}
|
||
|
||
// Inline array
|
||
if (ch === '[') {
|
||
const arr = [];
|
||
let j = i + 1;
|
||
while (true) {
|
||
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
|
||
if (j >= text.length) throw new Error('unterminated inline array');
|
||
if (text[j] === ']') return { value: arr, end: j + 1 };
|
||
if (text[j] === '#') {
|
||
const nl = text.indexOf('\n', j);
|
||
j = nl === -1 ? text.length : nl + 1;
|
||
continue;
|
||
}
|
||
const parsed = parseTomlValue(text, j);
|
||
arr.push(parsed.value);
|
||
j = parsed.end;
|
||
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
|
||
if (j < text.length && text[j] === ',') {
|
||
j += 1;
|
||
continue;
|
||
}
|
||
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
|
||
if (text[j] === ']') return { value: arr, end: j + 1 };
|
||
throw new Error(`expected , or ] in inline array at offset ${j}`);
|
||
}
|
||
}
|
||
|
||
// Inline table
|
||
if (ch === '{') {
|
||
const obj = {};
|
||
let j = i + 1;
|
||
while (true) {
|
||
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
|
||
if (text[j] === '}') return { value: obj, end: j + 1 };
|
||
const keyMatch = text.slice(j).match(/^([A-Za-z0-9_-]+|"[^"]*"|'[^']*')\s*=\s*/);
|
||
if (!keyMatch) throw new Error(`expected key in inline table at offset ${j}`);
|
||
let rawKey = keyMatch[1];
|
||
if ((rawKey.startsWith('"') && rawKey.endsWith('"')) || (rawKey.startsWith('\'') && rawKey.endsWith('\''))) {
|
||
rawKey = rawKey.slice(1, -1);
|
||
}
|
||
j += keyMatch[0].length;
|
||
const parsed = parseTomlValue(text, j);
|
||
obj[rawKey] = parsed.value;
|
||
j = parsed.end;
|
||
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
|
||
if (text[j] === ',') { j += 1; continue; }
|
||
if (text[j] === '}') return { value: obj, end: j + 1 };
|
||
throw new Error(`expected , or } in inline table at offset ${j}`);
|
||
}
|
||
}
|
||
|
||
// Number — integer or TOML 1.0 float. (#2760 CR4 finding 3 required explicit
|
||
// rejection of floats; #3245 inverts that: Codex CLI's serde schema requires
|
||
// f64 for tool_timeout_sec / startup_timeout_sec, so integers are what Codex
|
||
// rejects. Accept TOML floats and store as JS Number.)
|
||
//
|
||
// Still rejected: date/time literals (`-`, `:`, `T`, `Z` after integer prefix)
|
||
// and hex/oct/bin literals (`0x`, `0o`, `0b` — `x`, `o`, `b` fall through to
|
||
// the unsupported-value throw below because the integer-part pattern won't match `x`).
|
||
// TOML 1.0 §2: underscores in numeric literals are only allowed BETWEEN
|
||
// digits (each underscore must have a digit on both sides). The pre-check
|
||
// regex uses (?:_?\d)* rather than [\d_]* so `1__0`, `1_.0`, and `1._0`
|
||
// are rejected before normalization silently hides them.
|
||
//
|
||
// TOML 1.0 §2 (integer part): the integer part of a number must follow
|
||
// decimal-integer rules — no leading zeros except the value 0 itself.
|
||
// `01`, `00`, `01.5`, `00e2`, `+01`, `-01` are therefore all invalid.
|
||
// The pre-check and float regexes use (0|[1-9](?:_?\d)*) for the integer
|
||
// part so that `01` and `00` are rejected (k021 sibling rule).
|
||
const numMatch = text.slice(i).match(/^[+-]?(0|[1-9](?:_?\d)*)/);
|
||
if (numMatch) {
|
||
const afterInt = text[i + numMatch[0].length];
|
||
// Reject date/time separators that cannot be part of a float.
|
||
if (afterInt !== undefined && /[:\-TZ]/.test(afterInt)) {
|
||
throw new Error(
|
||
`unsupported TOML value at offset ${i}: dates and times are not supported (got ${text.slice(i, i + 20)})`
|
||
);
|
||
}
|
||
// Accept float: optional decimal part, optional exponent part.
|
||
// Each segment uses (?:_?\d)* so underscores are only between digits.
|
||
// Integer part uses (0|[1-9](?:_?\d)*) to reject leading zeros per TOML 1.0.
|
||
const floatMatch = text.slice(i).match(
|
||
/^[+-]?(0|[1-9](?:_?\d)*)(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?/
|
||
);
|
||
const raw = floatMatch ? floatMatch[0] : numMatch[0];
|
||
const normalized = raw.replace(/_/g, '');
|
||
const n = Number(normalized);
|
||
if (!Number.isFinite(n)) throw new Error(`invalid number: ${raw}`);
|
||
return { value: n, end: i + raw.length };
|
||
}
|
||
|
||
throw new Error(`unsupported value at offset ${i}: ${text.slice(i, i + 20)}`);
|
||
}
|
||
|
||
/**
|
||
* Parse TOML content into a JavaScript object. Throws on malformed input.
|
||
*
|
||
* Handles `[table]`, `[[array.of.tables]]`, dotted key paths, and the value
|
||
* forms supported by parseTomlValue. Sufficient for validating Codex config
|
||
* structure and for behavioral test assertions in #2760 — not a general
|
||
* TOML implementation.
|
||
*/
|
||
function parseTomlToObject(content) {
|
||
const root = {};
|
||
const records = getTomlLineRecords(content);
|
||
// Tracks the *object* (not path) that subsequent key=value lines target.
|
||
let currentTable = root;
|
||
|
||
// #2760 CR5 finding 2 — track shape and definition status of every path so
|
||
// we can reject duplicate header redeclarations, shape mismatches, and
|
||
// duplicate keys per real TOML 1.0 semantics. Without this, walkPath
|
||
// silently reuses existing tables and assignment overwrites existing keys —
|
||
// a real TOML parser would refuse the file.
|
||
//
|
||
// pathShape: dotted path -> 'table' | 'array' | 'inline_parent' | 'key'
|
||
// - 'table' — declared via [a.b]
|
||
// - 'array' — declared via [[a.b]] (path is the array itself; each
|
||
// element is its own implicit table)
|
||
// - 'inline_parent' — created implicitly while walking parents
|
||
// - 'key' — assigned a scalar value
|
||
// declaredHeaders: set of dotted paths explicitly declared via [hdr] (not
|
||
// [[arr]]) — used to reject duplicate [a] / [a] sections.
|
||
// tableKeys: dotted-path -> Set<string> of keys assigned in that exact
|
||
// table instance. For [[arr]] elements we use a per-element marker.
|
||
const pathShape = new Map();
|
||
const declaredHeaders = new Set();
|
||
const tableKeys = new Map();
|
||
// currentTableId — string identifier for the current table instance, used
|
||
// as the key into tableKeys so that key uniqueness is per-table-instance
|
||
// (each [[arr]] element gets its own id).
|
||
let currentTableId = '__root__';
|
||
pathShape.set('__root__', 'table');
|
||
tableKeys.set('__root__', new Set());
|
||
|
||
function ensureKeySet(id) {
|
||
if (!tableKeys.has(id)) tableKeys.set(id, new Set());
|
||
return tableKeys.get(id);
|
||
}
|
||
|
||
function walkPath(segments, { creatingArrayElement = false } = {}) {
|
||
let node = root;
|
||
const parents = segments.slice(0, -1);
|
||
const last = segments[segments.length - 1];
|
||
|
||
for (let p = 0; p < parents.length; p += 1) {
|
||
const seg = parents[p];
|
||
const partialPath = parents.slice(0, p + 1).join('.');
|
||
if (node[seg] === undefined) {
|
||
node[seg] = {};
|
||
if (!pathShape.has(partialPath)) {
|
||
pathShape.set(partialPath, 'inline_parent');
|
||
}
|
||
} else if (Array.isArray(node[seg])) {
|
||
// Walk into the latest element of an array-of-tables.
|
||
node = node[seg][node[seg].length - 1];
|
||
continue;
|
||
} else if (typeof node[seg] !== 'object' || node[seg] === null) {
|
||
throw new Error(`path segment ${seg} is not a table`);
|
||
}
|
||
node = node[seg];
|
||
}
|
||
|
||
const fullPath = segments.join('.');
|
||
|
||
if (creatingArrayElement) {
|
||
const existingShape = pathShape.get(fullPath);
|
||
if (node[last] === undefined) {
|
||
node[last] = [];
|
||
pathShape.set(fullPath, 'array');
|
||
} else if (!Array.isArray(node[last])) {
|
||
throw new Error(
|
||
`duplicate or shape-mismatched table header at ${fullPath}: ` +
|
||
`cannot redefine as array of tables (previously seen as ${existingShape || 'table'})`
|
||
);
|
||
} else if (existingShape && existingShape !== 'array') {
|
||
throw new Error(
|
||
`duplicate or shape-mismatched table header at ${fullPath}: ` +
|
||
`previously seen as ${existingShape}, cannot extend as array of tables`
|
||
);
|
||
}
|
||
const elem = {};
|
||
node[last].push(elem);
|
||
const elemId = `${fullPath}[${node[last].length - 1}]`;
|
||
pathShape.set(elemId, 'array_element');
|
||
tableKeys.set(elemId, new Set());
|
||
currentTableId = elemId;
|
||
return elem;
|
||
}
|
||
|
||
// Plain [table] header.
|
||
if (node[last] === undefined) {
|
||
node[last] = {};
|
||
pathShape.set(fullPath, 'table');
|
||
declaredHeaders.add(fullPath);
|
||
tableKeys.set(fullPath, new Set());
|
||
} else if (Array.isArray(node[last])) {
|
||
throw new Error(
|
||
`duplicate or shape-mismatched table header at ${fullPath}: ` +
|
||
`previously declared as array of tables ([[${fullPath}]]), cannot redeclare as table ([${fullPath}])`
|
||
);
|
||
} else if (typeof node[last] !== 'object') {
|
||
throw new Error(`cannot redefine ${fullPath} as table`);
|
||
} else if (declaredHeaders.has(fullPath)) {
|
||
throw new Error(
|
||
`duplicate or shape-mismatched table header at ${fullPath}: ` +
|
||
`[${fullPath}] declared more than once`
|
||
);
|
||
} else {
|
||
// Implicitly created earlier (e.g., as a parent path); first explicit
|
||
// declaration is allowed.
|
||
pathShape.set(fullPath, 'table');
|
||
declaredHeaders.add(fullPath);
|
||
if (!tableKeys.has(fullPath)) tableKeys.set(fullPath, new Set());
|
||
}
|
||
currentTableId = fullPath;
|
||
return node[last];
|
||
}
|
||
|
||
for (let idx = 0; idx < records.length; idx += 1) {
|
||
const rec = records[idx];
|
||
if (rec.startsInMultilineString) continue;
|
||
if (rec.tableHeader) {
|
||
const segs = rec.tableHeader.segments;
|
||
currentTable = walkPath(segs, { creatingArrayElement: rec.tableHeader.array });
|
||
continue;
|
||
}
|
||
|
||
const trimmed = rec.text.trim();
|
||
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
||
|
||
const equalsIndex = findTomlAssignmentEquals(rec.text);
|
||
if (equalsIndex === -1) continue;
|
||
|
||
const keyText = rec.text.slice(0, equalsIndex).trim();
|
||
const segments = parseTomlKeyPath(keyText);
|
||
if (!segments) {
|
||
throw new Error(`invalid TOML key on line ${idx + 1}: ${rec.text}`);
|
||
}
|
||
|
||
// Value RHS may span multiple lines (inline arrays, multi-line strings,
|
||
// inline tables). Parse from the absolute content offset right after `=`.
|
||
const valueStartAbs = rec.start + equalsIndex + 1;
|
||
const parsed = parseTomlValue(content, valueStartAbs);
|
||
|
||
// #2760 CR4 finding 3 — verify the full RHS was consumed. Anything other
|
||
// than whitespace + optional # comment between parsed.end and the next
|
||
// newline (or EOF) means the parser silently accepted a prefix and
|
||
// dropped trailing bytes. Reject so malformed TOML cannot slip past
|
||
// "parse before commit" guarantees.
|
||
let scan = parsed.end;
|
||
while (scan < content.length && (content[scan] === ' ' || content[scan] === '\t')) {
|
||
scan += 1;
|
||
}
|
||
if (scan < content.length && content[scan] !== '\n' && content[scan] !== '\r' && content[scan] !== '#') {
|
||
const lineEnd = content.indexOf('\n', scan);
|
||
const trailing = content.slice(scan, lineEnd === -1 ? content.length : lineEnd);
|
||
throw new Error(
|
||
`trailing bytes after value on line ${idx + 1}: ${JSON.stringify(trailing)}`
|
||
);
|
||
}
|
||
|
||
// Place value into currentTable under dotted key.
|
||
// #2760 CR5 finding 2 — reject duplicate keys per real TOML 1.0. Track
|
||
// the dotted key against the current table instance id; an exact repeat
|
||
// throws.
|
||
let target = currentTable;
|
||
for (let s = 0; s < segments.length - 1; s += 1) {
|
||
const seg = segments[s];
|
||
if (target[seg] === undefined) target[seg] = {};
|
||
else if (typeof target[seg] !== 'object' || Array.isArray(target[seg])) {
|
||
throw new Error(`cannot descend into non-table key ${seg}`);
|
||
}
|
||
target = target[seg];
|
||
}
|
||
const finalKey = segments[segments.length - 1];
|
||
const dottedKey = segments.join('.');
|
||
const keySet = ensureKeySet(currentTableId);
|
||
if (keySet.has(dottedKey) || Object.prototype.hasOwnProperty.call(target, finalKey)) {
|
||
throw new Error(
|
||
`duplicate key ${dottedKey} in ${currentTableId === '__root__' ? 'root table' : currentTableId}`
|
||
);
|
||
}
|
||
keySet.add(dottedKey);
|
||
target[finalKey] = parsed.value;
|
||
}
|
||
|
||
return root;
|
||
}
|
||
|
||
/**
|
||
* Validate that the post-install config.toml matches Codex's expected schema
|
||
* (#2760, fix 3). Returns { ok: true } on success, or { ok: false, reason }
|
||
* with a human-readable explanation of the offending section.
|
||
*
|
||
* Strategy: parse the bytes into a structured object first — malformed TOML
|
||
* fails validation immediately rather than slipping past a header-only scan.
|
||
* Then enforce the schema-shape rules against the parsed structure.
|
||
*
|
||
* Schema rules enforced:
|
||
* - File MUST parse as TOML (no syntax errors).
|
||
* - `agents` MUST be a struct table (`[agents.<name>]`) — never a bare
|
||
* table value or an array of tables.
|
||
* - `hooks.<Event>` MUST be an array of tables when present (Codex ≥0.124
|
||
* rejects bare `[hooks.<Event>]` single-bracket maps).
|
||
*/
|
||
/**
|
||
* True when a bare `[agents]` table body contains ONLY known AgentsToml scalar
|
||
* keys (CODEX_AGENTS_TOML_SCALAR_KEYS) — i.e. it is a valid AgentsToml struct
|
||
* that Codex's `deny_unknown_fields` will accept, not the break-causing form
|
||
* (#2760) that carries an unknown key. Comments and blank lines are ignored; an
|
||
* empty body is trivially valid. Mirrors isLegacyMsdAgentsSection's line scan.
|
||
*/
|
||
function codexBareAgentsHasOnlyKnownScalars(body) {
|
||
const lineRecords = getTomlLineRecords(body);
|
||
for (const record of lineRecords) {
|
||
// Conservative reject of anything not positively a single known-scalar
|
||
// assignment. A multiline-string value cannot be a valid AgentsToml scalar
|
||
// (max_threads/max_depth/job_max_runtime_seconds are integers,
|
||
// interrupt_message is a bool — none are strings), so codex would reject it
|
||
// too; rejecting here is correct, not a false negative.
|
||
if (record.startsInMultilineString) return false;
|
||
if (record.tableHeader) return false;
|
||
const trimmed = record.text.trim();
|
||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||
if (!record.keySegments || record.keySegments.length !== 1 ||
|
||
!CODEX_AGENTS_TOML_SCALAR_KEYS.has(record.keySegments[0])) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function validateCodexConfigSchema(content) {
|
||
let parsed;
|
||
try {
|
||
parsed = parseTomlToObject(content);
|
||
} catch (e) {
|
||
return {
|
||
ok: false,
|
||
reason: `TOML parse failed: ${e.message}`,
|
||
};
|
||
}
|
||
|
||
// Header-shape check: arrays-of-tables are visible in the parsed structure
|
||
// (as Array values) but bare-vs-struct distinction for `[agents]` requires
|
||
// looking at section headers too — `[agents]` with `default = "x"` parses
|
||
// to `{ agents: { default: 'x' } }`, indistinguishable from
|
||
// `[agents.foo]` writing into the same shape. Use header sections to
|
||
// disambiguate.
|
||
const sections = getTomlTableSections(content);
|
||
|
||
for (const section of sections) {
|
||
if (section.array && section.path === 'agents') {
|
||
return {
|
||
ok: false,
|
||
reason: '[[agents]] sequence form is invalid in current Codex schema (expected [agents.<name>] struct form)',
|
||
};
|
||
}
|
||
|
||
if (!section.array && section.path === 'agents') {
|
||
// #2760 rejected ALL bare `[agents]` tables because a bare table holding a
|
||
// non-AgentsToml key (`default = "x"`, a role name, etc.) triggers Codex's
|
||
// "invalid type: ..., expected struct AgentsToml" and breaks every CLI
|
||
// invocation. But a bare `[agents]` whose keys are all valid AgentsToml
|
||
// scalars (max_depth/max_threads/...) IS a valid struct — that is exactly
|
||
// MSD's managed `max_depth` dispatch-tuning block (ADR-1239 upgrade 2 /
|
||
// #2088), and a user's own scalar tuning. Permit known-scalar-only; still
|
||
// reject any bare `[agents]` carrying an unknown key.
|
||
const body = content.slice(section.headerEnd, section.end);
|
||
if (!codexBareAgentsHasOnlyKnownScalars(body)) {
|
||
return {
|
||
ok: false,
|
||
reason: 'bare [agents] table with a non-AgentsToml key is invalid in current Codex schema (expected [agents.<name>] struct form, or only AgentsToml scalars like max_depth/max_threads)',
|
||
};
|
||
}
|
||
}
|
||
|
||
// hooks.state.* is Codex's persistent hook-trust namespace (added in
|
||
// Codex CLI 0.130.0). It uses regular-table shape, NOT array-of-tables.
|
||
// [[hooks.state]] or [[hooks.state.<key>]] (AoT) is invalid; reject it.
|
||
if (section.array && (section.path === 'hooks.state' || section.path.startsWith('hooks.state.'))) {
|
||
return {
|
||
ok: false,
|
||
reason: `[[${section.path}]] is invalid; hooks.state namespace must use regular tables`,
|
||
};
|
||
}
|
||
|
||
// All other hooks.* paths (event handlers like hooks.SessionStart) require
|
||
// AoT shape — bare [hooks.<Event>] (single-bracket) is invalid.
|
||
if (!section.array && section.path.startsWith('hooks.') &&
|
||
section.path !== 'hooks.state' && !section.path.startsWith('hooks.state.')) {
|
||
return {
|
||
ok: false,
|
||
reason: `bare [${section.path}] table is invalid in current Codex schema (expected [[${section.path}]] array-of-tables)`,
|
||
};
|
||
}
|
||
}
|
||
|
||
// Structural confirmation against parsed object: any present hooks.<Event>
|
||
// must be an array, and flat top-level [[hooks]] (parsed as Array on root)
|
||
// is rejected — Codex 0.124.0+ requires [[hooks.<Event>]] namespaced form.
|
||
if (parsed.hooks !== undefined) {
|
||
if (Array.isArray(parsed.hooks)) {
|
||
return {
|
||
ok: false,
|
||
reason: 'flat [[hooks]] array-of-tables is invalid in Codex 0.124.0+ (expected [[hooks.<Event>]] namespaced form)',
|
||
};
|
||
}
|
||
if (typeof parsed.hooks === 'object' && parsed.hooks !== null) {
|
||
for (const [event, value] of Object.entries(parsed.hooks)) {
|
||
// hooks.state is Codex's persistent hook-trust namespace — a regular
|
||
// object (table), not an array of event-handler tables.
|
||
// Reject AoT shape (Array) and scalar forms; only plain objects are valid.
|
||
if (event === 'state') {
|
||
if (Array.isArray(value)) {
|
||
return {
|
||
ok: false,
|
||
reason: `hooks.state must be a regular table/object, got array-of-tables`,
|
||
};
|
||
}
|
||
if (typeof value !== 'object' || value === null) {
|
||
return {
|
||
ok: false,
|
||
reason: `hooks.state must be a regular table/object, got ${typeof value}`,
|
||
};
|
||
}
|
||
continue;
|
||
}
|
||
// Skip the nested .hooks sub-array — it lives under hooks.<Event>[n].hooks
|
||
// and is validated separately below.
|
||
if (!Array.isArray(value)) {
|
||
return {
|
||
ok: false,
|
||
reason: `hooks.${event} must be an array of tables, got ${typeof value}`,
|
||
};
|
||
}
|
||
// Each entry in hooks.<Event> must either be a matcher-only filter (no
|
||
// handler fields) or carry a .hooks sub-array of handler tables.
|
||
// Entries with handler fields (command, type, timeout, statusMessage) at
|
||
// event-entry level but without a .hooks sub-table are the pre-#2773
|
||
// single-block shape that Codex 0.124.0+ rejects. migrateCodexHooksMapFormat
|
||
// converts these before validation runs; their presence here means migration
|
||
// failed to cover this entry — fail loudly rather than pass a broken config.
|
||
const HANDLER_FIELD_NAMES = new Set(['command', 'type', 'timeout', 'statusMessage']);
|
||
for (const entry of value) {
|
||
if (!entry || typeof entry !== 'object') continue;
|
||
if (entry.hooks === undefined) {
|
||
const strayKey = Object.keys(entry).find((k) => HANDLER_FIELD_NAMES.has(k));
|
||
if (strayKey) {
|
||
return {
|
||
ok: false,
|
||
reason: `hooks.${event}[] entry has handler field "${strayKey}" at event-entry level; ` +
|
||
`Codex 0.124.0+ requires handler fields nested under [[hooks.${event}.hooks]]`,
|
||
};
|
||
}
|
||
continue;
|
||
}
|
||
if (!Array.isArray(entry.hooks)) {
|
||
return {
|
||
ok: false,
|
||
reason: `hooks.${event}[].hooks must be an array of handler tables, got ${typeof entry.hooks}`,
|
||
};
|
||
}
|
||
for (const handler of entry.hooks) {
|
||
if (handler && typeof handler === 'object' && handler.type !== undefined) {
|
||
if (handler.type !== 'command') {
|
||
return {
|
||
ok: false,
|
||
reason: `hooks.${event}[].hooks[].type must be "command", got "${handler.type}"`,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return { ok: true };
|
||
}
|
||
|
||
function normalizeCodexHooksLine(line, key) {
|
||
const leadingWhitespace = line.match(/^\s*/)[0];
|
||
const commentStart = findTomlCommentStart(line);
|
||
const comment = commentStart === -1 ? '' : line.slice(commentStart);
|
||
return `${leadingWhitespace}${key} = true${comment ? ` ${comment}` : ''}`;
|
||
}
|
||
|
||
function findTomlAssignmentBlockEnd(content, record) {
|
||
const equalsIndex = findTomlAssignmentEquals(record.text);
|
||
if (equalsIndex === -1) {
|
||
return record.end + record.eol.length;
|
||
}
|
||
|
||
let i = record.start + equalsIndex + 1;
|
||
let arrayDepth = 0;
|
||
let inlineTableDepth = 0;
|
||
|
||
while (i < content.length) {
|
||
if (content.startsWith('\'\'\'', i)) {
|
||
const closeIndex = content.indexOf('\'\'\'', i + 3);
|
||
if (closeIndex === -1) {
|
||
return content.length;
|
||
}
|
||
i = closeIndex + 3;
|
||
continue;
|
||
}
|
||
|
||
if (content.startsWith('"""', i)) {
|
||
const closeIndex = findMultilineBasicStringClose(content, i + 3);
|
||
if (closeIndex === -1) {
|
||
return content.length;
|
||
}
|
||
i = closeIndex + 3;
|
||
continue;
|
||
}
|
||
|
||
const ch = content[i];
|
||
|
||
if (ch === '\'') {
|
||
i += 1;
|
||
while (i < content.length) {
|
||
if (content[i] === '\'') {
|
||
i += 1;
|
||
break;
|
||
}
|
||
i += 1;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (ch === '"') {
|
||
i += 1;
|
||
while (i < content.length) {
|
||
if (content[i] === '\\') {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (content[i] === '"') {
|
||
i += 1;
|
||
break;
|
||
}
|
||
i += 1;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (ch === '[') {
|
||
arrayDepth += 1;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (ch === ']') {
|
||
if (arrayDepth > 0) {
|
||
arrayDepth -= 1;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (ch === '{') {
|
||
inlineTableDepth += 1;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (ch === '}') {
|
||
if (inlineTableDepth > 0) {
|
||
inlineTableDepth -= 1;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (ch === '#') {
|
||
while (i < content.length && content[i] !== '\n') {
|
||
i += 1;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (ch === '\n' && arrayDepth === 0 && inlineTableDepth === 0) {
|
||
return i + 1;
|
||
}
|
||
|
||
i += 1;
|
||
}
|
||
|
||
return content.length;
|
||
}
|
||
|
||
function rewriteTomlKeyLines(content, matches, key) {
|
||
if (matches.length === 0) {
|
||
return content;
|
||
}
|
||
|
||
let rewritten = '';
|
||
let cursor = 0;
|
||
|
||
matches.forEach((match, index) => {
|
||
rewritten += content.slice(cursor, match.start);
|
||
if (index === 0) {
|
||
const blockEnd = findTomlAssignmentBlockEnd(content, match);
|
||
const blockEol = blockEnd > 0 && content[blockEnd - 1] === '\n'
|
||
? (blockEnd > 1 && content[blockEnd - 2] === '\r' ? '\r\n' : '\n')
|
||
: '';
|
||
// Preserve the existing key when one is present on the line
|
||
// (`match.keyRaw`). This respects user ownership: a user-authored
|
||
// `codex_hooks = true` line stays as `codex_hooks = true` even
|
||
// though `hooks` is the canonical key in current Codex (#3566).
|
||
// Codex's own `legacy_key` alias mechanism in codex-rs handles the
|
||
// backward compat at the runtime layer. Migration to canonical is
|
||
// a fresh-insert-only operation in ensureCodexHooksFeature.
|
||
rewritten += normalizeCodexHooksLine(match.text, match.keyRaw || key) + blockEol;
|
||
cursor = blockEnd;
|
||
return;
|
||
}
|
||
cursor = findTomlAssignmentBlockEnd(content, match);
|
||
});
|
||
|
||
rewritten += content.slice(cursor);
|
||
return rewritten;
|
||
}
|
||
|
||
// atomicWriteFileSync and __atomicWrittenTmps are now owned by the
|
||
// runtime-hooks-surface module and imported here so both install.js's
|
||
// direct config.toml writes and the module's Cursor/Codex hooks.json
|
||
// writes share the SAME tracking Set. _cleanTmpFiles() below reads
|
||
// hooksSurface.__atomicWrittenTmps to scope cleanup to installer-owned
|
||
// temps only.
|
||
const atomicWriteFileSync = hooksSurface.atomicWriteFileSync;
|
||
const __atomicWrittenTmps = hooksSurface.__atomicWrittenTmps;
|
||
|
||
/**
|
||
* Merge MSD config block into an existing or new config.toml.
|
||
* Three cases: new file, existing with MSD marker, existing without marker.
|
||
*
|
||
* All writes go through atomicWriteFileSync so a mid-write failure leaves
|
||
* the original config.toml untouched (#2760 fix 4).
|
||
*/
|
||
/**
|
||
* Split TOML content into its leading TOP-LEVEL key lines and everything from
|
||
* the first table header onward (#3610).
|
||
*
|
||
* Top-level keys were file-scoped before a merge. The regenerated MSD block
|
||
* opens with a table header (`[agents]`, #2088/ADR-1239 upgrade 2), so placing
|
||
* that block ABOVE surviving top-level keys re-scopes them into `[agents]` —
|
||
* `validateCodexConfigSchema` then correctly rejects the merged file and the
|
||
* install aborts. Hoisting the keys above the block preserves their scope.
|
||
*
|
||
* Table headers inside multiline strings do not start the "rest" region (the
|
||
* record parser already excludes them via startsInMultilineString).
|
||
*/
|
||
function splitTopLevelKeys(content) {
|
||
for (const record of getTomlLineRecords(content)) {
|
||
if (record.tableHeader && !record.startsInMultilineString) {
|
||
return {
|
||
topLevel: content.slice(0, record.start).trim(),
|
||
rest: content.slice(record.start).trim(),
|
||
};
|
||
}
|
||
}
|
||
return { topLevel: content.trim(), rest: '' };
|
||
}
|
||
|
||
function mergeCodexConfig(configPath, msdBlock) {
|
||
// Case 1: No config.toml — create fresh
|
||
if (!fs.existsSync(configPath)) {
|
||
atomicWriteFileSync(configPath, msdBlock + '\n');
|
||
return;
|
||
}
|
||
|
||
const existing = fs.readFileSync(configPath, 'utf8');
|
||
const eol = detectLineEnding(existing);
|
||
// #2088 review: the bare `[agents]` table is purged below (Case 2/3 via
|
||
// stripLeakedMsdCodexSections) to keep a single managed `[agents]`. Preserve
|
||
// the user's own AgentsToml scalar tuning (max_threads, job_max_runtime_seconds,
|
||
// interrupt_message — everything except MSD-managed max_depth) by re-emitting
|
||
// it inside the managed block, so install never silently drops it.
|
||
const mergedMsdBlock = spliceCodexAgentsScalars(msdBlock, extractCodexUserAgentsScalars(existing));
|
||
const normalizedMsdBlock = mergedMsdBlock.replace(/\r?\n/g, eol);
|
||
const markerIndex = existing.indexOf(MSD_CODEX_MARKER);
|
||
|
||
// Case 2: Has MSD marker — preserve user content on BOTH sides, regenerate the MSD block.
|
||
//
|
||
// #2940: the marker delimits where MSD's OWN block begins, NOT where every post-marker byte
|
||
// is MSD-owned. A fresh install writes the MSD block as the file's entire content, so any
|
||
// settings the user or Codex CLI later adds ([model], [mcp_servers.*], [profiles.*]) land
|
||
// AFTER the block. The previous truncate-to-marker logic discarded that trailing region on
|
||
// every update, destroying user config. The fix routes the trailing region through the
|
||
// existing AST-based `stripLeakedMsdCodexSections`, which removes MSD's own managed/leaked
|
||
// sections (the bare [agents] table MSD regenerates, legacy [agents.msd-*], [[agents]])
|
||
// while preserving genuine user TOML — so #2406's de-dup still holds AND user content survives.
|
||
if (markerIndex !== -1) {
|
||
let before = existing.substring(0, markerIndex).trimEnd();
|
||
if (before) {
|
||
// Strip any MSD-managed sections that leaked above the marker from previous installs
|
||
before = stripLeakedMsdCodexSections(before).trimEnd();
|
||
}
|
||
// Capture and preserve genuine user content AFTER the MSD-managed region. The whole
|
||
// post-marker region is passed through stripLeakedMsdCodexSections: MSD's own previously-
|
||
// emitted [agents] table (regenerated above as normalizedMsdBlock) and any leaked sections
|
||
// are removed, while user tables ([model], [mcp_servers.*], [profiles.*]) are kept. The
|
||
// marker comment line itself (and the optional codex_hooks ownership line right under it)
|
||
// is MSD-owned and is stripped from the trailing region so it is not duplicated alongside
|
||
// the freshly regenerated block.
|
||
const rawAfter = existing.substring(markerIndex);
|
||
const markerStripped = rawAfter
|
||
.replace(MSD_CODEX_MARKER, '')
|
||
.replace(/^\r?\n# MSD codex_hooks ownership: (?:section|root_dotted)\r?\n/, '');
|
||
const afterUser = stripLeakedMsdCodexSections(markerStripped).trim();
|
||
|
||
// #3610: top-level keys that survived BELOW the marker were file-scoped
|
||
// before this merge; the regenerated block opens with the `[agents]` table
|
||
// header, so they must be hoisted to FILE scope or TOML re-scopes them
|
||
// into a table. File scope means BEFORE the first table header of the
|
||
// pre-marker region too — appending them after a pre-marker table (the
|
||
// default real-world layout: user tables precede the marker) would merely
|
||
// capture them into THAT table instead of [agents], and the schema
|
||
// validator is blind to non-agents tables.
|
||
const beforeSplit = before ? splitTopLevelKeys(before) : { topLevel: '', rest: '' };
|
||
const { topLevel: afterTopLevel, rest: afterTables } = splitTopLevelKeys(afterUser);
|
||
|
||
const parts = [];
|
||
const topParts = [];
|
||
if (beforeSplit.topLevel) topParts.push(beforeSplit.topLevel);
|
||
if (afterTopLevel) topParts.push(afterTopLevel);
|
||
if (topParts.length > 0) parts.push(topParts.join(eol + eol));
|
||
if (beforeSplit.rest) parts.push(beforeSplit.rest);
|
||
parts.push(normalizedMsdBlock);
|
||
if (afterTables) parts.push(afterTables);
|
||
atomicWriteFileSync(configPath, parts.join(eol + eol) + eol);
|
||
return;
|
||
}
|
||
|
||
// Case 3: No marker — append MSD block
|
||
let content = stripLeakedMsdCodexSections(existing).trimEnd();
|
||
if (content) {
|
||
content = content + eol + eol + normalizedMsdBlock + eol;
|
||
} else {
|
||
content = normalizedMsdBlock + eol;
|
||
}
|
||
|
||
atomicWriteFileSync(configPath, content);
|
||
}
|
||
|
||
/**
|
||
* Repair config.toml files corrupted by pre-#1346 MSD installs.
|
||
* Non-boolean keys (e.g. model = "gpt-5.4") that ended up under [features]
|
||
* are relocated before the [features] header so Codex can parse them correctly.
|
||
* Returns the content unchanged if no trapped keys are found.
|
||
*/
|
||
function repairTrappedFeaturesKeys(content) {
|
||
const eol = detectLineEnding(content);
|
||
const lineRecords = getTomlLineRecords(content);
|
||
const featuresSection = getTomlTableSections(content)
|
||
.find((section) => !section.array && section.path === 'features');
|
||
|
||
if (!featuresSection) {
|
||
return content;
|
||
}
|
||
|
||
// Find non-boolean key-value lines inside [features] that don't belong there.
|
||
// Boolean keys (codex_hooks, multi_agent, etc.) are legitimate feature flags.
|
||
const trappedLines = lineRecords.filter((record) => {
|
||
if (record.tableHeader || record.startsInMultilineString) return false;
|
||
if (record.tablePath !== 'features') return false;
|
||
if (record.start < featuresSection.headerEnd) return false;
|
||
if (record.end + record.eol.length > featuresSection.end) return false;
|
||
if (!record.keySegments || record.keySegments.length === 0) return false;
|
||
|
||
// Check if the value is a boolean — if so, it belongs under [features]
|
||
const equalsIndex = findTomlAssignmentEquals(record.text);
|
||
if (equalsIndex === -1) return false;
|
||
const commentStart = findTomlCommentStart(record.text);
|
||
const valueText = record.text
|
||
.slice(equalsIndex + 1, commentStart === -1 ? record.text.length : commentStart)
|
||
.trim();
|
||
if (valueText === 'true' || valueText === 'false') return false;
|
||
|
||
// Skip values that start a multiline string — they may legitimately live
|
||
// under [features] and spanning multiple lines makes relocation unsafe.
|
||
if (valueText.startsWith("'''") || valueText.startsWith('"""')) return false;
|
||
|
||
// Non-boolean value — this key is trapped
|
||
return true;
|
||
});
|
||
|
||
if (trappedLines.length === 0) {
|
||
return content;
|
||
}
|
||
|
||
// Build the relocated text block from trapped lines
|
||
const relocatedText = trappedLines.map((r) => r.text).join(eol) + eol;
|
||
|
||
// Remove trapped lines from their current positions (with their EOLs)
|
||
const removalRanges = trappedLines.map((r) => ({
|
||
start: r.start,
|
||
end: r.end + r.eol.length,
|
||
}));
|
||
let cleaned = removeContentRanges(content, removalRanges);
|
||
|
||
// Collapse any runs of 3+ blank lines left behind
|
||
cleaned = collapseTomlBlankLines(cleaned);
|
||
|
||
// Re-locate the [features] header in the cleaned content
|
||
const cleanedRecords = getTomlLineRecords(cleaned);
|
||
const cleanedFeaturesHeader = cleanedRecords.find(
|
||
(r) => r.tableHeader && r.tableHeader.path === 'features' && !r.tableHeader.array
|
||
);
|
||
|
||
if (!cleanedFeaturesHeader) {
|
||
return cleaned;
|
||
}
|
||
|
||
// Insert relocated keys before [features]
|
||
const before = cleaned.slice(0, cleanedFeaturesHeader.start);
|
||
const after = cleaned.slice(cleanedFeaturesHeader.start);
|
||
const needsGap = before.length > 0 && !before.endsWith(eol + eol);
|
||
const trailingGap = after.length > 0 && !relocatedText.endsWith(eol + eol) ? eol : '';
|
||
|
||
return before + (needsGap ? eol : '') + relocatedText + trailingGap + after;
|
||
}
|
||
|
||
function ensureCodexHooksFeature(configContent) {
|
||
const eol = detectLineEnding(configContent);
|
||
const lineRecords = getTomlLineRecords(configContent);
|
||
|
||
const featuresSection = getTomlTableSections(configContent)
|
||
.find((section) => !section.array && section.path === 'features');
|
||
|
||
if (featuresSection) {
|
||
const sectionLines = lineRecords
|
||
.filter((record) =>
|
||
!record.tableHeader &&
|
||
!record.startsInMultilineString &&
|
||
record.tablePath === 'features' &&
|
||
record.start >= featuresSection.headerEnd &&
|
||
record.end + record.eol.length <= featuresSection.end &&
|
||
record.keySegments &&
|
||
record.keySegments.length === 1 &&
|
||
isCodexHooksFeatureKey(record.keySegments[0])
|
||
);
|
||
|
||
if (sectionLines.length > 0) {
|
||
// Rewrite to canonical key — this migrates legacy `codex_hooks` to
|
||
// `hooks` in-place on every reinstall. If the file already has the
|
||
// canonical key the rewrite is a no-op shape-wise (same key, same
|
||
// value). The rewriteTomlKeyLines helper preserves indentation,
|
||
// trailing comments, and ownership-marker positioning, and always
|
||
// emits the caller-supplied canonical key (#3566).
|
||
const rewritten = rewriteTomlKeyLines(configContent, sectionLines, CODEX_HOOKS_FEATURE_KEY);
|
||
return {
|
||
content: repairTrappedFeaturesKeys(rewritten),
|
||
ownership: null,
|
||
};
|
||
}
|
||
|
||
const sectionBody = configContent.slice(featuresSection.headerEnd, featuresSection.end);
|
||
const needsSeparator = sectionBody.length > 0 && !sectionBody.endsWith('\n') && !sectionBody.endsWith('\r\n');
|
||
const insertPrefix = sectionBody.length === 0 && featuresSection.headerEnd === configContent.length ? eol : '';
|
||
const insertText = `${insertPrefix}${needsSeparator ? eol : ''}${CODEX_HOOKS_FEATURE_KEY} = true${eol}`;
|
||
const merged = configContent.slice(0, featuresSection.end) + insertText + configContent.slice(featuresSection.end);
|
||
return {
|
||
content: repairTrappedFeaturesKeys(merged),
|
||
ownership: 'section',
|
||
};
|
||
}
|
||
|
||
const rootFeatureLines = lineRecords
|
||
.filter((record) =>
|
||
!record.tableHeader &&
|
||
!record.startsInMultilineString &&
|
||
record.tablePath === null &&
|
||
record.keySegments &&
|
||
record.keySegments[0] === 'features'
|
||
);
|
||
|
||
const rootCodexHooksLines = rootFeatureLines
|
||
.filter((record) => record.keySegments.length === 2 && isCodexHooksFeatureKey(record.keySegments[1]));
|
||
|
||
if (rootCodexHooksLines.length > 0) {
|
||
return {
|
||
content: rewriteTomlKeyLines(configContent, rootCodexHooksLines, `features.${CODEX_HOOKS_FEATURE_KEY}`),
|
||
ownership: null,
|
||
};
|
||
}
|
||
|
||
const rootFeaturesValueLines = rootFeatureLines
|
||
.filter((record) => record.keySegments.length === 1);
|
||
|
||
if (rootFeaturesValueLines.length > 0) {
|
||
return { content: configContent, ownership: null };
|
||
}
|
||
|
||
if (rootFeatureLines.length > 0) {
|
||
const lastFeatureLine = rootFeatureLines[rootFeatureLines.length - 1];
|
||
const insertAt = findTomlAssignmentBlockEnd(configContent, lastFeatureLine);
|
||
const prefix = insertAt > 0 && configContent[insertAt - 1] === '\n' ? '' : eol;
|
||
return {
|
||
content: configContent.slice(0, insertAt) +
|
||
`${prefix}features.${CODEX_HOOKS_FEATURE_KEY} = true${eol}` +
|
||
configContent.slice(insertAt),
|
||
ownership: 'root_dotted',
|
||
};
|
||
}
|
||
|
||
const featuresBlock = `[features]${eol}${CODEX_HOOKS_FEATURE_KEY} = true${eol}`;
|
||
if (!configContent) {
|
||
return { content: featuresBlock, ownership: 'section' };
|
||
}
|
||
// Insert [features] before the first table header, preserving bare top-level keys.
|
||
// Prepending would trap them under [features] where Codex expects only booleans (#1202).
|
||
const firstTableHeader = lineRecords.find(r => r.tableHeader);
|
||
if (firstTableHeader) {
|
||
const before = configContent.slice(0, firstTableHeader.start);
|
||
const after = configContent.slice(firstTableHeader.start);
|
||
const needsGap = before.length > 0 && !before.endsWith(eol + eol);
|
||
return {
|
||
content: before + (needsGap ? eol : '') + featuresBlock + eol + after,
|
||
ownership: 'section',
|
||
};
|
||
}
|
||
// No table headers — append [features] after top-level keys
|
||
const needsGap = configContent.length > 0 && !configContent.endsWith(eol + eol);
|
||
return { content: configContent + (needsGap ? eol : '') + featuresBlock, ownership: 'section' };
|
||
}
|
||
|
||
function hasEnabledCodexHooksFeature(configContent) {
|
||
const lineRecords = getTomlLineRecords(configContent);
|
||
|
||
return lineRecords.some((record) => {
|
||
if (record.tableHeader || record.startsInMultilineString || !record.keySegments) {
|
||
return false;
|
||
}
|
||
|
||
const isSectionKey = record.tablePath === 'features' &&
|
||
record.keySegments.length === 1 &&
|
||
isCodexHooksFeatureKey(record.keySegments[0]);
|
||
const isRootDottedKey = record.tablePath === null &&
|
||
record.keySegments.length === 2 &&
|
||
record.keySegments[0] === 'features' &&
|
||
isCodexHooksFeatureKey(record.keySegments[1]);
|
||
|
||
if (!isSectionKey && !isRootDottedKey) {
|
||
return false;
|
||
}
|
||
|
||
const equalsIndex = findTomlAssignmentEquals(record.text);
|
||
if (equalsIndex === -1) {
|
||
return false;
|
||
}
|
||
|
||
const commentStart = findTomlCommentStart(record.text);
|
||
const valueText = record.text.slice(equalsIndex + 1, commentStart === -1 ? record.text.length : commentStart).trim();
|
||
return valueText === 'true';
|
||
});
|
||
}
|
||
|
||
// ── Cursor hooks.json reconciler (issue #777) ────────────────────────────────
|
||
//
|
||
// Cursor v2.4+ supports a hooks.json lifecycle hook system. MSD registers two
|
||
// managed command hooks:
|
||
// sessionStart → msd-cursor-session-start.js (context injection)
|
||
// postToolUse → msd-cursor-post-tool.js (STATE.md update monitor)
|
||
//
|
||
// hooks.json schema:
|
||
// { "version": 1, "hooks": { "<event>": [ { "type": "command", "command": "<path>" } ] } }
|
||
//
|
||
// Location:
|
||
// Global: ~/.cursor/hooks.json
|
||
// Local: <project-root>/.cursor/hooks.json
|
||
//
|
||
// MSD entries are identified by a top-level `"msd-managed": true` field on
|
||
// each hook entry. Non-MSD entries are preserved. The reconciler is idempotent
|
||
// (safe to re-run) and preserves user-owned entries in the file.
|
||
//
|
||
// References: https://cursor.com/docs/hooks
|
||
|
||
// #2876: buildCursorHookEntry, isManagedCursorHookEntry, and
|
||
// reconcileCursorHooksJson used to be re-bound here as one-line delegates to
|
||
// the equivalent hooksSurface.* implementations. None had an install.js
|
||
// internal caller or an export consumer (tests import all three directly from
|
||
// msd-core/bin/lib/runtime-hooks-surface.cjs), so the retired bindings were
|
||
// dead code with no reachable body — removed rather than kept as unreachable
|
||
// wrappers.
|
||
|
||
/**
|
||
* #777 — Write MSD-managed Cursor lifecycle hooks into <targetDir>/hooks.json.
|
||
*
|
||
* Both managed hook scripts (msd-cursor-session-start.js, msd-cursor-post-tool.js)
|
||
* are copied from the MSD hooks/ source to <targetDir>/hooks/ first, so the
|
||
* hooks.json entries never reference a script that wasn't installed.
|
||
*
|
||
* @param {string} targetDir - The Cursor config dir (global: ~/.cursor; local: .cursor)
|
||
* @param {string} src - The MSD install source root (for copying hook scripts)
|
||
* @param {{ absoluteRunner?: string|null }} opts
|
||
* @returns {{ hooksJsonPath: string, changed: boolean }}
|
||
*/
|
||
function writeCursorHooksJson(targetDir, src, opts) {
|
||
return hooksSurface.writeCursorHooksJson(targetDir, src, opts);
|
||
}
|
||
|
||
/**
|
||
* Remove all MSD-managed Cursor lifecycle hook entries from hooks.json.
|
||
* User-owned entries are preserved. If the file becomes empty, it is removed.
|
||
*
|
||
* @param {string} targetDir - The Cursor config dir
|
||
* @returns {{ changed: boolean }}
|
||
*/
|
||
function removeCursorHooksJson(targetDir) {
|
||
return hooksSurface.removeCursorHooksJson(targetDir);
|
||
}
|
||
|
||
/**
|
||
* Generate config.toml and per-agent .toml files for Codex.
|
||
* Reads agent .md files from source, extracts metadata, writes .toml configs.
|
||
*/
|
||
|
||
/**
|
||
* #2834: Write ~/.msd/defaults.json for non-Claude runtimes — sets
|
||
* resolve_model_ids="omit" (so resolveModelInternal() returns '' instead of
|
||
* Claude aliases the runtime can't resolve) and runtime=<runtime> (so
|
||
* resolveRuntime() resolves correctly out of the box). MUST be called BEFORE
|
||
* installCodexConfig (or any other step that reads defaults.json at generation
|
||
* time), so a clean first install produces correctly-model-routed agent TOMLs.
|
||
* No-op for Claude runtimes (Claude is the resolveRuntime fallback + has native
|
||
* model aliases). Preserves an explicit `true` opt-in and existing values.
|
||
*/
|
||
function writeNonClaudeDefaults(runtime) {
|
||
if (_hostBehaviors(runtime).nativeModelAliases || process.env.MSD_TEST_MODE) return;
|
||
const msdDir = path.join(os.homedir(), '.msd');
|
||
const defaultsPath = path.join(msdDir, 'defaults.json');
|
||
let releaseLock = null;
|
||
try {
|
||
fs.mkdirSync(msdDir, { recursive: true });
|
||
// defaults.json is machine-global — every runtime and project on the box
|
||
// reads it. Serialize the read-modify-write so two concurrent installs
|
||
// cannot lose each other's key, and apply both mutations in ONE atomic
|
||
// write so a crash cannot leave the file truncated (the read path swallows
|
||
// parse errors and treats a corrupt file as absent, which would silently
|
||
// degrade model resolution everywhere until repaired by hand).
|
||
releaseLock = acquireInstallMigrationLock(msdDir);
|
||
let defaults = {};
|
||
try { defaults = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')); } catch { /* new file */ }
|
||
if (defaults === null || typeof defaults !== 'object' || Array.isArray(defaults)) {
|
||
defaults = {};
|
||
}
|
||
const applied = [];
|
||
// Three-valued domain: false/absent → aliases; true → full IDs; "omit" → ''.
|
||
const existing = defaults.resolve_model_ids;
|
||
const shouldDefaultToOmit = existing !== true && existing !== 'omit';
|
||
if (shouldDefaultToOmit) {
|
||
defaults.resolve_model_ids = 'omit';
|
||
applied.push(`Set resolve_model_ids: "omit" in ~/.msd/defaults.json`);
|
||
}
|
||
// #2395: persist runtime for non-Claude runtimes.
|
||
if (defaults.runtime === undefined || defaults.runtime === null || defaults.runtime === '') {
|
||
defaults.runtime = runtime;
|
||
applied.push(`Set runtime: "${runtime}" in ~/.msd/defaults.json`);
|
||
}
|
||
if (applied.length > 0) {
|
||
atomicWriteFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n', 'utf8');
|
||
for (const message of applied) console.log(` ${green}✓${reset} ${message}`);
|
||
}
|
||
} catch (e) {
|
||
console.log(` ${yellow}⚠${reset} Could not write ~/.msd/defaults.json: ${e.message}`);
|
||
} finally {
|
||
if (releaseLock) {
|
||
try {
|
||
releaseLock();
|
||
} catch (releaseError) {
|
||
// A leaked lock blocks the next install, so surface it rather than
|
||
// swallowing; the stale-lock reaper clears it once this pid exits.
|
||
console.log(` ${yellow}⚠${reset} Could not release the ~/.msd install lock: ${releaseError.message}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-sandbox') {
|
||
// ADR-1239 Phase B write-confinement: every Codex config write stays under targetDir.
|
||
const configPath = assertDestWithinConfigHome(targetDir, 'config.toml');
|
||
const agentsTomlDir = assertDestWithinConfigHome(targetDir, 'agents');
|
||
const resolvedTargetRoot = path.resolve(targetDir);
|
||
// Symlink-escape guard (parity with _copyStaged / copyWithPathReplacement): the
|
||
// lexical gate above does not resolve symlinks, so a pre-existing config.toml or
|
||
// agents/ symlink could redirect writes outside targetDir. Reject those.
|
||
// #2393: honor MSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
||
const symlinkOptIn = isSymlinkedDestOptIn();
|
||
if (
|
||
hasExistingSymlinkBetween(resolvedTargetRoot, configPath, { allowOptInFollow: symlinkOptIn }) ||
|
||
hasExistingSymlinkBetween(resolvedTargetRoot, path.resolve(agentsTomlDir), { allowOptInFollow: symlinkOptIn })
|
||
) {
|
||
throw new Error(
|
||
`installCodexConfig: a Codex config path under "${targetDir}" contains a symlink the install root does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with MSD_ALLOW_SYMLINKED_DEST=1.`,
|
||
);
|
||
}
|
||
fs.mkdirSync(agentsTomlDir, { recursive: true });
|
||
|
||
// #3897 rung 3 (CAUSE B fix) — validateCodexSandboxHolds is deliberately
|
||
// NOT called here. The "no stale holds" invariant is a REPO invariant about
|
||
// the canonical roster in `agents/`, not a property of whatever directory
|
||
// an install happens to read from: a partial or synthetic `agentsSrc` (a
|
||
// test fixture, a `--config-dir` subset) legitimately contains only a few
|
||
// agents, and a held role simply absent from THIS source dir must be
|
||
// inert, not fatal. Throwing here also masked unrelated failures further
|
||
// down this same loop (e.g. the name-injection/path-escape guard on
|
||
// `agentTomlPath` below), since this check ran first and unconditionally.
|
||
// The invariant is still enforced — as a test over the real `agents/`
|
||
// roster (tests/codex-config.test.cjs T24/T25) — just never on this
|
||
// runtime path. See src/codex-agent-toml.cts's `validateCodexSandboxHolds`
|
||
// docblock for the full rationale.
|
||
//
|
||
// #3897 security review F2 (assessed post-F1-fix, verified by execution —
|
||
// not asserted): before F1's fix, the "runtime detector removed" gap here
|
||
// was real — a rename (case a) or a sibling `name:` clobber (case b) could
|
||
// reach this loop and land a held role's `.toml` at `workspace-write` with
|
||
// nothing here to catch it. After F1 (`deriveCodexSandboxMode` now decides
|
||
// over BOTH the filename stem and the resolved frontmatter `name:`, most
|
||
// restrictive wins), both cases were re-run end-to-end through this exact
|
||
// function and the EMITTED ARTIFACT for both is `read-only` — the
|
||
// dangerous condition no longer produces a wrong artifact, it produces the
|
||
// SAFE one. A detector guarding a now-fail-safe condition is not
|
||
// load-bearing, and restoring a throw here would re-break the legitimate
|
||
// partial-source-dir case CAUSE B removed it for (see above). Regression
|
||
// coverage for both cases lives in `tests/codex-config.test.cjs` (F1(a)
|
||
// rename / F1(b) sibling-clobber rows), asserted on the emitted `.toml`'s
|
||
// `sandbox_mode`, not on the derivation's return value.
|
||
|
||
const agentEntries = fs.readdirSync(agentsSrc).filter(f => f.startsWith('msd-') && f.endsWith('.md'));
|
||
const agents = [];
|
||
|
||
// Compute the Codex MSD install path (absolute, so subagents with empty $HOME work — #820)
|
||
const codexMsdPath = `${path.resolve(targetDir, 'msd-core').replace(/\\/g, '/')}/`;
|
||
|
||
for (const file of agentEntries) {
|
||
const agentTomlSourcePath = path.join(agentsSrc, file);
|
||
let content = fs.readFileSync(agentTomlSourcePath, 'utf8');
|
||
// #2995 (epic #1671 Phase 6.4): Codex embeds each agent's prompt into a
|
||
// per-agent `.toml`, reading the source .md independently of the inline
|
||
// agent loop — a separate emission path that must strip msd:section
|
||
// markers too, or a marked agent ships its markers inside the TOML.
|
||
// Found by the exhaustive per-runtime emission sweep in
|
||
// tests/agent-fragments-emission.install.test.cjs, not by call-graph
|
||
// analysis, which is why that guard is behavioral rather than structural.
|
||
content = composeWorkflow(content, { sourcePath: agentTomlSourcePath });
|
||
// Replace full .claude/msd-core prefix so path resolves to the Codex
|
||
// MSD install before generic .claude → .codex conversion rewrites it.
|
||
content = content.replace(/~\/\.claude\/msd-core\//g, codexMsdPath);
|
||
content = content.replace(/\$HOME\/\.claude\/msd-core\//g, codexMsdPath);
|
||
// Route TOML emit through the same full Claude→Codex conversion pipeline
|
||
// used on the `.md` emit path (#2639). Covers: slash-command rewrites,
|
||
// $ARGUMENTS → {{MSD_ARGS}}, /clear removal, anchored and bare .claude/
|
||
// paths, .claudeignore → .codexignore, and standalone "Claude" /
|
||
// CLAUDE.md neutralization via neutralizeAgentReferences(..., 'AGENTS.md').
|
||
content = convertClaudeToCodexMarkdown(content);
|
||
const { frontmatter } = extractFrontmatterAndBody(content);
|
||
// #3897 security review F1 (blocker, post-merge): this loop used to key
|
||
// the sandbox/hold decision off ONLY the filename stem while the emitted
|
||
// `.toml`'s OUTPUT PATH below is keyed off `name` (frontmatter-derived,
|
||
// attacker-editable) — so a renamed source file, or a sibling file whose
|
||
// `name:` collides with a held role, could make the decided identity and
|
||
// the landed artifact disagree, widening a held role's own file to
|
||
// `workspace-write`. `generateCodexAgentToml` (below) now derives
|
||
// `sandbox_mode` over BOTH the filename stem it is given AND the
|
||
// frontmatter `name:` it resolves internally, taking the most
|
||
// restrictive result — this loop no longer needs to choose one identity
|
||
// for that call; see `deriveCodexSandboxMode`'s doc in
|
||
// `codex-agent-toml.cts` for the resolution.
|
||
const fileStem = file.replace(/\.md$/, '');
|
||
const name = extractFrontmatterField(frontmatter, 'name') || fileStem;
|
||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||
|
||
agents.push({ name, description: toSingleLine(description) });
|
||
|
||
// Pass model overrides from both per-project `.planning/config.json` and
|
||
// `~/.msd/defaults.json` (project wins on conflict) so Codex TOML files
|
||
// embed the configured model — Codex cannot receive model inline (#2256).
|
||
// Previously only the global file was read, which silently dropped the
|
||
// per-project override the reporter had set for msd-codebase-mapper.
|
||
// #2517 — also pass the runtime-aware tier resolver so profile tiers can
|
||
// resolve to Codex-native model IDs + reasoning_effort when `runtime: "codex"`
|
||
// is set in defaults.json.
|
||
const modelOverrides = readMsdEffectiveModelOverrides(targetDir);
|
||
// Pass `targetDir` so per-project .planning/config.json wins over global
|
||
// ~/.msd/defaults.json — without this, the PR's headline claim that
|
||
// setting runtime in the project config reaches the Codex emit path is
|
||
// false (review finding #1).
|
||
const runtimeResolver = readMsdRuntimeProfileResolver(targetDir);
|
||
// #443 — pass unified effort config so model_reasoning_effort in the .toml
|
||
// follows the same config-driven precedence as the Claude .md effort key.
|
||
const effortCfg = readMsdEffectiveEffortConfig(targetDir);
|
||
// Pass `fileStem`; `generateCodexAgentToml` itself additionally resolves
|
||
// and folds in the frontmatter `name:` for the sandbox decision (F1
|
||
// above) — this call site does not need to pass `name` explicitly.
|
||
const tomlContent = generateCodexAgentToml(fileStem, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier);
|
||
// Confine the per-agent write to the agents/ dir itself: a crafted agent
|
||
// `name` containing path separators must not escape agents/ (which would let
|
||
// it clobber config.toml or write elsewhere under the configHome).
|
||
const agentTomlPath = assertDestWithinConfigHome(agentsTomlDir, `${name}.toml`);
|
||
if (hasExistingSymlinkBetween(resolvedTargetRoot, agentTomlPath, { allowOptInFollow: symlinkOptIn })) {
|
||
throw new Error(
|
||
`installCodexConfig: agent toml path "${agentTomlPath}" contains a symlink the install root does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with MSD_ALLOW_SYMLINKED_DEST=1.`,
|
||
);
|
||
}
|
||
fs.writeFileSync(agentTomlPath, tomlContent);
|
||
}
|
||
|
||
const msdBlock = generateCodexConfigBlock(agents, targetDir);
|
||
mergeCodexConfig(configPath, msdBlock);
|
||
|
||
return agents.length;
|
||
}
|
||
|
||
/**
|
||
* Runtime-neutral agent name and instruction file replacement.
|
||
* Used by ALL non-Claude runtime converters to avoid Claude-specific
|
||
* references in workflow prompts, agent definitions, and documentation.
|
||
*
|
||
* Replaces:
|
||
* - Standalone "Claude" (agent name) → "the agent"
|
||
* Preserves: "Claude Code" (product), "Claude Opus/Sonnet/Haiku" (models),
|
||
* "claude-" (prefixes), "CLAUDE.md" (handled separately)
|
||
* - "CLAUDE.md" → runtime-appropriate instruction file
|
||
* - "Do NOT load full AGENTS.md" → removed (harmful for AGENTS.md runtimes)
|
||
*
|
||
* @param {string} content - File content to neutralize
|
||
* @param {string} instructionFile - Runtime's instruction file ('AGENTS.md', 'GEMINI.md', etc.)
|
||
* @returns {string} Content with runtime-neutral references
|
||
*/
|
||
function neutralizeAgentReferences(content, instructionFile) {
|
||
let c = content;
|
||
// Replace standalone "Claude" (the agent) but preserve product/model names.
|
||
// Negative lookahead avoids: Claude Code, Claude Opus/Sonnet/Haiku, Claude native, Claude-based
|
||
c = c.replace(/\bClaude(?! Code| Opus| Sonnet| Haiku| native| based|-)\b(?!\.md)/g, 'the agent');
|
||
// Replace CLAUDE.md with runtime-appropriate instruction file
|
||
if (instructionFile) {
|
||
c = c.replace(/CLAUDE\.md/g, instructionFile);
|
||
}
|
||
// Remove instructions that conflict with AGENTS.md-based runtimes
|
||
c = c.replace(/Do NOT load full `AGENTS\.md` files[^\n]*/g, '');
|
||
return c;
|
||
}
|
||
|
||
function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOverride = null } = {}) {
|
||
// Replace tool name references in content (applies to all files)
|
||
let convertedContent = filterRuntimeNotesForTarget(content, 'opencode');
|
||
convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
|
||
convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill');
|
||
convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite');
|
||
// Replace /msd-command colon variant with /msd-command for opencode (flat command structure)
|
||
convertedContent = convertedContent.replace(/\/msd:/g, '/msd-');
|
||
// Replace ~/.claude and $HOME/.claude with OpenCode's config location
|
||
convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/opencode');
|
||
convertedContent = convertedContent.replace(/\$HOME\/\.claude\b/g, '$HOME/.config/opencode');
|
||
// Replace general-purpose subagent type with OpenCode's equivalent "general"
|
||
convertedContent = convertedContent.replace(/subagent_type="general-purpose"/g, 'subagent_type="general"');
|
||
// Runtime-neutral agent name replacement (#766)
|
||
convertedContent = neutralizeAgentReferences(convertedContent, 'AGENTS.md');
|
||
|
||
// Check if content has frontmatter
|
||
if (!convertedContent.startsWith('---')) {
|
||
return convertedContent;
|
||
}
|
||
|
||
// Find the end of frontmatter
|
||
const endIndex = convertedContent.indexOf('---', 3);
|
||
if (endIndex === -1) {
|
||
return convertedContent;
|
||
}
|
||
|
||
const frontmatter = convertedContent.substring(3, endIndex).trim();
|
||
const body = convertedContent.substring(endIndex + 3);
|
||
|
||
// Parse frontmatter line by line (simple YAML parsing)
|
||
const lines = frontmatter.split('\n');
|
||
const newLines = [];
|
||
let inAllowedTools = false;
|
||
let inSkippedArray = false;
|
||
const allowedTools = [];
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
|
||
// For agents: skip commented-out lines (e.g. hooks blocks)
|
||
if (isAgent && trimmed.startsWith('#')) {
|
||
continue;
|
||
}
|
||
|
||
// Detect start of allowed-tools array
|
||
if (trimmed.startsWith('allowed-tools:')) {
|
||
inAllowedTools = true;
|
||
continue;
|
||
}
|
||
|
||
// Detect inline tools: field (comma-separated string)
|
||
if (trimmed.startsWith('tools:')) {
|
||
if (isAgent) {
|
||
// Agents: strip tools entirely (not supported in OpenCode agent frontmatter)
|
||
inSkippedArray = true;
|
||
continue;
|
||
}
|
||
const toolsValue = trimmed.substring(6).trim();
|
||
if (toolsValue) {
|
||
// Parse comma-separated tools
|
||
const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t);
|
||
allowedTools.push(...tools);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// For agents: strip skills:, color:, memory:, maxTurns:, permissionMode:, disallowedTools:
|
||
if (isAgent && /^(skills|color|memory|maxTurns|permissionMode|disallowedTools):/.test(trimmed)) {
|
||
inSkippedArray = true;
|
||
continue;
|
||
}
|
||
|
||
// Skip continuation lines of a stripped array/object field
|
||
if (inSkippedArray) {
|
||
if (trimmed.startsWith('- ') || trimmed.startsWith('#') || /^\s/.test(line)) {
|
||
continue;
|
||
}
|
||
inSkippedArray = false;
|
||
}
|
||
|
||
// For commands: remove name: field (opencode uses filename for command name)
|
||
// For agents: keep name: (required by OpenCode agents)
|
||
if (!isAgent && trimmed.startsWith('name:')) {
|
||
continue;
|
||
}
|
||
|
||
// Strip model: field — OpenCode doesn't support Claude Code model aliases
|
||
// like 'haiku', 'sonnet', 'opus', or 'inherit'. Omitting lets OpenCode use
|
||
// its configured default model. See #1156.
|
||
if (trimmed.startsWith('model:')) {
|
||
continue;
|
||
}
|
||
|
||
// Convert color names to hex for opencode (commands only; agents strip color above)
|
||
if (trimmed.startsWith('color:')) {
|
||
const colorValue = trimmed.substring(6).trim().toLowerCase();
|
||
const hexColor = colorNameToHex[colorValue];
|
||
if (hexColor) {
|
||
newLines.push(`color: "${hexColor}"`);
|
||
} else if (colorValue.startsWith('#')) {
|
||
// Validate hex color format (#RGB or #RRGGBB)
|
||
if (/^#[0-9a-f]{3}$|^#[0-9a-f]{6}$/i.test(colorValue)) {
|
||
// Already hex and valid, keep as is
|
||
newLines.push(line);
|
||
}
|
||
// Skip invalid hex colors
|
||
}
|
||
// Skip unknown color names
|
||
continue;
|
||
}
|
||
|
||
// Collect allowed-tools items
|
||
if (inAllowedTools) {
|
||
if (trimmed.startsWith('- ')) {
|
||
allowedTools.push(trimmed.substring(2).trim());
|
||
continue;
|
||
} else if (trimmed && !trimmed.startsWith('-')) {
|
||
// End of array, new field started
|
||
inAllowedTools = false;
|
||
}
|
||
}
|
||
|
||
// Keep other fields
|
||
if (!inAllowedTools) {
|
||
newLines.push(line);
|
||
}
|
||
}
|
||
|
||
// For agents: add required OpenCode agent fields
|
||
// Note: Do NOT add 'model: inherit' — OpenCode does not recognize the 'inherit'
|
||
// keyword and throws ProviderModelNotFoundError. Omitting model: lets OpenCode
|
||
// use its default model for subagents. See #1156.
|
||
if (isAgent) {
|
||
newLines.push('mode: subagent');
|
||
// Embed model override from ~/.msd/defaults.json so model_overrides is
|
||
// respected on OpenCode (which uses static agent frontmatter, not inline
|
||
// Task() model parameters). See #2256.
|
||
if (modelOverride) {
|
||
newLines.push(`model: ${modelOverride}`);
|
||
}
|
||
}
|
||
|
||
// For commands: add tools object if we had allowed-tools or tools
|
||
if (!isAgent && allowedTools.length > 0) {
|
||
newLines.push('tools:');
|
||
for (const tool of allowedTools) {
|
||
newLines.push(` ${convertToolName(tool)}: true`);
|
||
}
|
||
}
|
||
|
||
// Rebuild frontmatter (body already has tool names converted)
|
||
const newFrontmatter = newLines.join('\n').trim();
|
||
return `---\n${newFrontmatter}\n---${body}`;
|
||
}
|
||
|
||
// convertClaudeCommandToOpencodeFamilySkill, convertClaudeCommandToOpencodeSkill:
|
||
// moved to src/install-engine.cts (ADR-1239 Phase B).
|
||
// #2876 found no install.js internal caller for the latter (tests import
|
||
// it directly from msd-core/bin/lib/install-engine.cjs) and retired the
|
||
// destructured binding above.
|
||
|
||
// applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B).
|
||
// #2876 found no install.js internal caller (never had one either — it was
|
||
// never part of this module's export surface) and retired the destructured
|
||
// binding above.
|
||
//
|
||
// copyFlattenedCommands (OpenCode flattened command/ writer): moved to
|
||
// src/install-engine.cts as installOpencodeFamilyCommands (ADR-1239 / #2087).
|
||
// OpenCode installs now route through installRuntimeArtifacts's
|
||
// combinedFamilyInstall path (installOpencodeFamilyArtifacts) instead of the
|
||
// bespoke inline block that used to call this function.
|
||
|
||
function listCodexSkillNames(skillsDir, prefix = 'msd-') {
|
||
if (!fs.existsSync(skillsDir)) return [];
|
||
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
||
return entries
|
||
.filter(entry => entry.isDirectory() && entry.name.startsWith(prefix))
|
||
.filter(entry => fs.existsSync(path.join(skillsDir, entry.name, 'SKILL.md')))
|
||
.map(entry => entry.name)
|
||
.sort();
|
||
}
|
||
|
||
/**
|
||
* Generic skills install helper used by all copyCommandsAs*Skills shims.
|
||
*
|
||
* Recursively walks srcDir, applies converter to each .md file (mirroring the
|
||
* old per-function recurse() bodies), applies runtime content rewrites
|
||
* (path + branding), and writes each skill as <prefix>-<stem>/SKILL.md under
|
||
* skillsDir. Replaces the ~50-line recursion bodies in the 9 old functions.
|
||
*
|
||
* @param {string} srcDir source commands directory
|
||
* @param {string} skillsDir destination skills directory
|
||
* @param {string} prefix skill name prefix without trailing dash (e.g. 'msd')
|
||
* @param {string} pathPrefix trailing-slash path prefix for content rewrites
|
||
* @param {string} runtime canonical runtime ID for rewrite table
|
||
* @param {Function} converter wrapped converter (content, skillName) → string
|
||
*/
|
||
|
||
|
||
|
||
/**
|
||
* Copy Claude commands as Claude skills — one folder per skill with SKILL.md.
|
||
* Claude Code 2.1.88+ uses skills/xxx/SKILL.md instead of commands/msd/xxx.md.
|
||
* Branding rewrites are applied via
|
||
* applyRuntimeContentRewritesInPlace inside _copyCommandsAsSkillsViaConverter.
|
||
* @param {string} srcDir - Source commands directory
|
||
* @param {string} skillsDir - Target skills directory
|
||
* @param {string} prefix - Skill name prefix (e.g. 'msd')
|
||
* @param {string} pathPrefix - Path prefix for file references
|
||
* @param {string} runtime - Target runtime
|
||
* @param {boolean} isGlobal - Whether this is a global install (unused; kept for compat)
|
||
*/
|
||
|
||
/**
|
||
* Recursively install MSD commands as Antigravity skills.
|
||
* Each command becomes a skill-name/ folder containing SKILL.md.
|
||
* @param {string} srcDir - Source commands directory
|
||
* @param {string} skillsDir - Target skills directory
|
||
* @param {string} prefix - Skill name prefix (e.g. 'msd')
|
||
* @param {boolean} isGlobal - Whether this is a global install
|
||
*/
|
||
|
||
// USER_OWNED_ARTIFACTS, _snapshotDir,
|
||
// installRuntimeArtifacts, installOpencodeFamilySkills, uninstallRuntimeArtifacts:
|
||
// ALL moved to src/install-engine.cts (ADR-1239 Phase B). Imported from
|
||
// installEngine above. _copyStaged, _removeMsdEntries,
|
||
// _runLegacyInstallMigrations, _runLegacyUninstallCleanup, and _restoreDir
|
||
// moved there too but #2876 found no install.js
|
||
// internal caller for any of them and retired their destructured bindings.
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Phase 2 — Layout-driven install/uninstall orchestrators (moved to engine)
|
||
// _applyRuntimeRewrites / _stampNonClaudeRuntimeDefaults remain here for
|
||
// call sites in copyWithPathReplacement (not moved).
|
||
// ---------------------------------------------------------------------------
|
||
const _applyRuntimeRewrites = runtimeArtifactConversion._applyRuntimeRewrites;
|
||
const _stampNonClaudeRuntimeDefaults = runtimeArtifactConversion._stampNonClaudeRuntimeDefaults;
|
||
|
||
/**
|
||
* Data-driven dispatch table for copyWithPathReplacement (ADR-1239 Phase B).
|
||
* Keyed by runtime id. Each entry declares ONLY what that runtime does differently.
|
||
* The DEFAULT (no entry, or entry with no md/js key) = identity transform after
|
||
* the uniform steps — covers claude, zcode, etc.
|
||
*
|
||
* Entry shape:
|
||
* mdSkipGenericRewrite?: boolean — skip the ~/.claude/ rewrite block (antigravity)
|
||
* md?: (content, ctx) => string — per-runtime .md transform
|
||
* mdReattributeAfter?: boolean — re-run processAttribution after md() (antigravity)
|
||
* mdTomlRenameOnCommand?: boolean — when isCommand, rename dest .md → .toml
|
||
* (unused since the gemini runtime was removed, #1928;
|
||
* kept as generic dispatch infra for a future TOML-command runtime)
|
||
* js?: (content, ctx) => string — per-runtime .cjs/.js transform (absent = plain copyFileSync)
|
||
*
|
||
* ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName, runtime }
|
||
*/
|
||
const RUNTIME_CONTENT_DISPATCH = {
|
||
opencode: {
|
||
md: (content) => convertClaudeToOpencodeFrontmatter(content),
|
||
},
|
||
codex: {
|
||
md: (content) => convertClaudeToCodexMarkdown(content),
|
||
},
|
||
antigravity: {
|
||
mdSkipGenericRewrite: true,
|
||
md: (content, ctx) => convertClaudeToAntigravityContent(content, ctx.isGlobal),
|
||
mdReattributeAfter: true,
|
||
js: (content, ctx) => convertClaudeToAntigravityContent(content, ctx.isGlobal),
|
||
},
|
||
cursor: {
|
||
md: (content) => convertClaudeToCursorMarkdown(content),
|
||
js: (content) => {
|
||
content = content.replace(/msd:/gi, 'msd-');
|
||
content = content.replace(/\.claude\/skills\//g, '.cursor/skills/');
|
||
content = content.replace(/CLAUDE\.md/g, '.cursor/rules/');
|
||
content = content.replace(/\bClaude Code\b/g, 'Cursor');
|
||
return content;
|
||
},
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Recursively copy directory, replacing paths in .md files
|
||
* Deletes existing destDir first to remove orphaned files from previous versions
|
||
* @param {string} srcDir - Source directory
|
||
* @param {string} destDir - Destination directory
|
||
* @param {string} pathPrefix - Path prefix for file references
|
||
* @param {string} runtime - Target runtime ('claude', 'opencode', 'codex')
|
||
* @param {boolean} isCommand - Whether the source is a command directory
|
||
* @param {boolean} isGlobal - Whether the install is global
|
||
*/
|
||
function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false, isGlobal = false, confinementRoot) {
|
||
const dirName = getDirName(runtime);
|
||
|
||
// ADR-1239 Phase B write-confinement: refuse to wipe/write a destDir that
|
||
// escapes the caller-declared install root. Runs BEFORE the rmSync below so a
|
||
// crafted destDir can never delete or write outside confinementRoot.
|
||
if (confinementRoot === undefined) {
|
||
throw new Error(
|
||
'copyWithPathReplacement: confinementRoot is required to confine writes to the install root — refusing to write',
|
||
);
|
||
}
|
||
const resolvedConfinementRoot = path.resolve(confinementRoot);
|
||
const resolvedDestDir = assertDestWithinConfigHome(confinementRoot, destDir);
|
||
// #2393: honor MSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
||
if (hasExistingSymlinkBetween(resolvedConfinementRoot, resolvedDestDir, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
||
throw new Error(
|
||
`copyWithPathReplacement: destDir "${destDir}" contains a symlink the install root "${confinementRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with MSD_ALLOW_SYMLINKED_DEST=1.`,
|
||
);
|
||
}
|
||
// Use the validated absolute path for all writes below so the gate validates
|
||
// exactly what is written (a relative destDir would otherwise resolve to cwd).
|
||
destDir = resolvedDestDir;
|
||
|
||
// Clean install: remove existing destination to prevent orphaned files
|
||
if (fs.existsSync(destDir)) {
|
||
fs.rmSync(destDir, { recursive: true });
|
||
}
|
||
fs.mkdirSync(destDir, { recursive: true });
|
||
|
||
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
||
|
||
for (const entry of entries) {
|
||
const srcPath = path.join(srcDir, entry.name);
|
||
const destPath = path.join(destDir, entry.name);
|
||
|
||
// #3333: srcPath was enumerated by readdirSync above, but a filesystem is not
|
||
// transactional — the file it named can vanish between listing and this read
|
||
// (a concurrent process, or another test in this suite writing/cleaning up a
|
||
// fixture inside this same real directory). Treat "gone by the time we get
|
||
// here" as benign and skip it, never a fatal crash of the whole install.
|
||
if (!entry.isDirectory() && !fs.existsSync(srcPath)) {
|
||
continue;
|
||
}
|
||
|
||
if (entry.isDirectory()) {
|
||
copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal, confinementRoot);
|
||
} else if (entry.name.endsWith('.md')) {
|
||
const dispatch = RUNTIME_CONTENT_DISPATCH[runtime] || {};
|
||
const ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName: entry.name, runtime };
|
||
|
||
// Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths
|
||
// Skip generic replacement for Antigravity — its converter handles all paths
|
||
let content = fs.readFileSync(srcPath, 'utf8');
|
||
|
||
// #2930 (epic #1671 Phase 3): strip `<!-- msd:section -->` markers
|
||
// BEFORE any per-runtime rewrite so a `.claude/` -> `.cursor/` regex
|
||
// (or any other converter below) never reaches inside a marker
|
||
// attribute and corrupts it. composeWorkflow is a no-op (byte-identical
|
||
// return) for the 88+ workflows and every non-workflow .md that carries
|
||
// no markers, and for a malformed marker it throws loudly naming
|
||
// srcPath — never emit a half-composed workflow.
|
||
//
|
||
// Scoped to msd-core/workflows/ ONLY (two independent reviewers,
|
||
// chore/2930): copyWithPathReplacement is the emit path for every .md
|
||
// under msd-core/, skills/, and commands/ (see the three call sites),
|
||
// not just workflows. A doc that merely DOCUMENTS the marker syntax
|
||
// with an unfenced example (docs/reference/workflow-fragments.md is
|
||
// the live instance of this class, though not under the install tree
|
||
// today) would otherwise get silently mis-parsed as a real marker and
|
||
// that line lossily dropped — a file class issue #2930 never scoped
|
||
// to. Path is normalized UNCONDITIONALLY (backslash paths arrive on
|
||
// Linux too — CONTEXT.md path-separator rule) and checked as a
|
||
// path-segment match so the recursive descent (srcPath may be several
|
||
// directory levels below msd-core/workflows/) is still caught.
|
||
//
|
||
// #3072: the scoping predicate itself now lives in ONE place —
|
||
// shouldCompose (src/mcp-catalog.cts, imported above) — rather than
|
||
// being re-declared inline here. The MCP served catalog calls the
|
||
// SAME function to decide what it composes vs serves verbatim, so this
|
||
// install path and the catalog can never independently drift on what
|
||
// gets composed (ADR-1671:309, DEFECT.GENERATIVE-FIX; the parity gate
|
||
// is tests/mcp-catalog-parity.test.cjs). shouldCompose normalizes with
|
||
// the identical unconditional `.replace(/\\/g, '/')` internally, so
|
||
// this call is behavior-preserving byte-for-behavior with the regex it
|
||
// replaces.
|
||
if (shouldCompose(srcPath)) {
|
||
content = composeWorkflow(content, { sourcePath: srcPath });
|
||
}
|
||
|
||
content = filterRuntimeNotesForTarget(content, runtime);
|
||
|
||
if (!dispatch.mdSkipGenericRewrite) {
|
||
// #4377: with a project-relative prefix, mask `${VAR:-default}` shell
|
||
// defaults out of the substitutions below and restore them after. The
|
||
// runtime launcher snippet probes msd-tools through a chain of those
|
||
// (`${CLAUDE_CONFIG_DIR:-$HOME/.claude}/msd-core/bin/...`, one per
|
||
// runtime); they are shell word expansions, not markdown @ includes,
|
||
// and a relative value there resolves against the shell's cwd instead
|
||
// of the project. Swapping an include that points at the wrong
|
||
// checkout for a path that points at nothing is not a fix, and the
|
||
// launcher already probes `$(git rev-parse --show-toplevel)/.claude`
|
||
// first, so the multi-worktree case is handled before these defaults
|
||
// are ever reached. The shared helper is the single owner of the
|
||
// balanced masking grammar used by this path and the rewrite engine.
|
||
const rewriteGenericPaths = (body) => {
|
||
content = body;
|
||
const globalClaudeRegex = /~\/\.claude\//g;
|
||
const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
|
||
const localClaudeRegex = /\.\/\.claude\//g;
|
||
content = content.replace(globalClaudeRegex, pathPrefix);
|
||
content = content.replace(globalClaudeHomeRegex, pathPrefix);
|
||
content = content.replace(localClaudeRegex, `./${dirName}/`);
|
||
// #3544 review (Finding 1 fallout): guarded with the SAME
|
||
// negative-lookahead convention already used at ~:2859-2860 below
|
||
// ("preserve .claude-plugin and .claudeignore"). A naive `\b` here
|
||
// is satisfied by ANY non-word character, including '-' — so for a
|
||
// --config-dir whose name EXTENDS '.claude' (e.g. '.claude-work',
|
||
// pathPrefix '$HOME/.claude-work/'), this pass re-matched the
|
||
// '$HOME/.claude' PREFIX of its own slash-form output (lines above)
|
||
// and re-appended the full prefix, corrupting every emitted path to
|
||
// '$HOME/.claude-work-work/...'. Harmless no-op for the literal
|
||
// default '.claude' (self-replace with an identical string), which
|
||
// is why this went undetected until a non-default config-dir name
|
||
// was exercised.
|
||
content = content.replace(/~\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
|
||
content = content.replace(/\$HOME\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
|
||
content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
|
||
// #3544: restore @-file-reference lines to the tilde form Claude Code
|
||
// actually expands — the SAME correction #3133 already applies to
|
||
// skill/command bodies via _applyRuntimeRewrites's 'claude' case (see
|
||
// restoreClaudeGlobalAtRefTilde's doc comment in
|
||
// runtime-artifact-conversion.cts). This is the msd-core/ spec-tree
|
||
// emit path, which never had it: every @~/.claude/msd-core/… include
|
||
// in a global install's workflows/references tree silently resolved
|
||
// to nothing (54 includes across 22 files on a live install).
|
||
if (_hostBehaviors(runtime).ownsClaudePaths) {
|
||
content = runtimeArtifactConversion._restoreClaudeGlobalAtRefTilde(content, pathPrefix);
|
||
}
|
||
return content;
|
||
};
|
||
content = runtimeArtifactConversion._isRelativePathPrefix(pathPrefix)
|
||
? runtimeArtifactConversion._withShellDefaultsPreserved(content, rewriteGenericPaths)
|
||
: rewriteGenericPaths(content);
|
||
}
|
||
content = processAttribution(content, getCommitAttribution(runtime));
|
||
|
||
// #1521: stamp the workflow runtime-resolution block so every non-Claude
|
||
// install resolves its own runtime identity and defaults use_worktrees=false.
|
||
// copyWithPathReplacement is the emit path for msd-core/workflows/*.md;
|
||
// _applyRuntimeRewrites is NOT invoked here, so this is what makes the fix
|
||
// live in real installs (it is a no-op for files without those lines).
|
||
if (!_hostBehaviors(runtime).authorsCanonicalWorkflow) {
|
||
content = _stampNonClaudeRuntimeDefaults(content, runtime);
|
||
}
|
||
|
||
// #3683 — normalize /msd:<cmd> → /msd-<cmd> in any body passing through
|
||
// copyWithPathReplacement for runtimes that register commands under the
|
||
// hyphen form; normalizeAgentBodyForRuntime self-gates on
|
||
// shouldNormalizeHyphenNamespaceInAgentBody(runtime) and is a no-op for
|
||
// colon-canonical / self-converting runtimes.
|
||
content = normalizeAgentBodyForRuntime(content, runtime, readMsdCommandNames());
|
||
|
||
// Apply per-runtime .md converter (if any)
|
||
if (dispatch.md) content = dispatch.md(content, ctx);
|
||
|
||
// Re-run attribution after converter for runtimes that need it (antigravity)
|
||
if (dispatch.mdReattributeAfter) content = processAttribution(content, getCommitAttribution(runtime));
|
||
|
||
// Rename .md → .toml for command files (unused since gemini removal, #1928)
|
||
const finalPath = (dispatch.mdTomlRenameOnCommand && isCommand) ? destPath.replace(/\.md$/, '.toml') : destPath;
|
||
fs.writeFileSync(finalPath, content);
|
||
} else if (entry.name.endsWith('.cjs') || entry.name.endsWith('.js')) {
|
||
const dispatch = RUNTIME_CONTENT_DISPATCH[runtime] || {};
|
||
if (dispatch.js) {
|
||
const ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName: entry.name, runtime };
|
||
let content = fs.readFileSync(srcPath, 'utf8');
|
||
content = dispatch.js(content, ctx);
|
||
fs.writeFileSync(destPath, content);
|
||
} else {
|
||
fs.copyFileSync(srcPath, destPath);
|
||
}
|
||
} else {
|
||
fs.copyFileSync(srcPath, destPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clean up orphaned hook registrations from settings.json
|
||
*/
|
||
function cleanupOrphanedHooks(settings) {
|
||
const orphanedHookPatterns = [
|
||
'msd-notify.sh', // Removed in v1.6.x
|
||
'hooks/statusline.js', // Renamed to msd-statusline.js in v1.9.0
|
||
'msd-intel-index.js', // Removed in v1.9.2
|
||
'msd-intel-session.js', // Removed in v1.9.2
|
||
'msd-intel-prune.js', // Removed in v1.9.2
|
||
];
|
||
|
||
let cleanedHooks = false;
|
||
|
||
// Check all hook event types (Stop, SessionStart, etc.)
|
||
if (settings.hooks) {
|
||
for (const eventType of Object.keys(settings.hooks)) {
|
||
const hookEntries = settings.hooks[eventType];
|
||
if (Array.isArray(hookEntries)) {
|
||
// Filter out entries that contain orphaned hooks
|
||
const filtered = hookEntries.filter(entry => {
|
||
if (entry.hooks && Array.isArray(entry.hooks)) {
|
||
// Check if any hook in this entry matches orphaned patterns
|
||
const hasOrphaned = entry.hooks.some(h =>
|
||
h.command && orphanedHookPatterns.some(pattern => h.command.includes(pattern))
|
||
);
|
||
if (hasOrphaned) {
|
||
cleanedHooks = true;
|
||
return false; // Remove this entry
|
||
}
|
||
}
|
||
return true; // Keep this entry
|
||
});
|
||
settings.hooks[eventType] = filtered;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (cleanedHooks) {
|
||
console.log(` ${green}✓${reset} Removed orphaned hook registrations`);
|
||
}
|
||
|
||
// Fix #330: Update statusLine if it points to old MSD statusline.js path
|
||
// Only match the specific old MSD path pattern (hooks/statusline.js),
|
||
// not third-party statusline scripts that happen to contain 'statusline.js'
|
||
if (settings.statusLine && settings.statusLine.command &&
|
||
/hooks[\/\\]statusline\.js/.test(settings.statusLine.command)) {
|
||
settings.statusLine.command = settings.statusLine.command.replace(
|
||
/hooks([\/\\])statusline\.js/,
|
||
'hooks$1msd-statusline.js'
|
||
);
|
||
console.log(` ${green}✓${reset} Updated statusline path (hooks/statusline.js → hooks/msd-statusline.js)`);
|
||
}
|
||
|
||
return settings;
|
||
}
|
||
|
||
/**
|
||
* Validate hook field requirements to prevent silent settings.json rejection.
|
||
*
|
||
* Claude Code validates the entire settings file with a strict Zod schema.
|
||
* If ANY hook has an invalid schema (e.g., type: "agent" missing "prompt"),
|
||
* the ENTIRE settings.json is silently discarded — disabling all plugins,
|
||
* env vars, and other configuration.
|
||
*
|
||
* This defensive check removes invalid hook entries and cleans up empty
|
||
* event arrays to prevent this. It validates:
|
||
* - agent hooks require a "prompt" field
|
||
* - command hooks require a "command" field
|
||
* - entries must have a valid "hooks" array (non-array/missing is removed)
|
||
*
|
||
* @param {object} settings - The settings object (mutated in place)
|
||
* @returns {object} The same settings object
|
||
*/
|
||
function validateHookFields(settings) {
|
||
if (!settings.hooks || typeof settings.hooks !== 'object') return settings;
|
||
|
||
let fixedHooks = false;
|
||
const emptyKeys = [];
|
||
|
||
for (const [eventType, hookEntries] of Object.entries(settings.hooks)) {
|
||
if (!Array.isArray(hookEntries)) continue;
|
||
|
||
// Pass 1: validate each entry, building a new array without mutation
|
||
const validated = [];
|
||
for (const entry of hookEntries) {
|
||
// Entries without a hooks sub-array are structurally invalid — remove them
|
||
if (!entry.hooks || !Array.isArray(entry.hooks)) {
|
||
fixedHooks = true;
|
||
continue;
|
||
}
|
||
|
||
// Filter invalid hooks within the entry
|
||
const validHooks = entry.hooks.filter(h => {
|
||
if (h.type === 'agent' && !h.prompt) {
|
||
fixedHooks = true;
|
||
return false;
|
||
}
|
||
if (h.type === 'command' && !h.command) {
|
||
fixedHooks = true;
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// Drop entries whose hooks are now empty
|
||
if (validHooks.length === 0) {
|
||
fixedHooks = true;
|
||
continue;
|
||
}
|
||
|
||
// Build a clean copy instead of mutating the original entry
|
||
validated.push({ ...entry, hooks: validHooks });
|
||
}
|
||
|
||
settings.hooks[eventType] = validated;
|
||
|
||
// Collect empty event arrays for removal (avoid delete during iteration)
|
||
if (validated.length === 0) {
|
||
emptyKeys.push(eventType);
|
||
fixedHooks = true;
|
||
}
|
||
}
|
||
|
||
// Pass 2: remove empty event arrays
|
||
for (const key of emptyKeys) {
|
||
delete settings.hooks[key];
|
||
}
|
||
|
||
if (fixedHooks) {
|
||
console.log(` ${green}✓${reset} Fixed invalid hook entries (prevents settings.json schema rejection)`);
|
||
}
|
||
|
||
return settings;
|
||
}
|
||
|
||
/**
|
||
* MSD hook filenames removed during uninstall.
|
||
* Module-level so tests can assert structurally instead of regex-parsing source
|
||
* (retires pending-migration-to-typed-ir on hooks-opt-in.test.cjs, per #455).
|
||
*
|
||
* Derived from _HOOKS_TO_COPY (scripts/build-hooks.js — the SAME single source
|
||
* of truth INSTALLED_HOOK_FILES uses for manifest-tracking above) instead of a
|
||
* separately hand-maintained literal array. The hand-maintained array had
|
||
* silently drifted out of sync with the install-time set — missing
|
||
* msd-check-update-worker.js, msd-ensure-canonical-path.js,
|
||
* managed-hooks-registry.cjs, msd-cursor-pre-tool.js, msd-cursor-stop.js,
|
||
* msd-cursor-subagent-start.js, msd-cursor-subagent-stop.js, and
|
||
* msd-worktree-path-guard.js — so every one of those files (and the hooks/ dir
|
||
* itself, via the non-empty-dir rmdir guard) was left behind on uninstall for
|
||
* every settings-json-hook runtime. `msd-check-update.cmd` is added on top: a
|
||
* Windows-only SessionStart shim generated at install time (not copied from
|
||
* hooks/dist/, so it is not in _HOOKS_TO_COPY).
|
||
*/
|
||
const MSD_UNINSTALL_HOOKS = [..._HOOKS_TO_COPY, 'msd-check-update.cmd'];
|
||
|
||
/**
|
||
* Uninstall MSD from the specified directory for a specific runtime
|
||
* Removes only MSD-specific files/directories, preserves user content
|
||
* @param {boolean} isGlobal - Whether to uninstall from global or local
|
||
* @param {string} runtime - Target runtime ('claude', 'opencode', 'codex', ...)
|
||
*/
|
||
function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
|
||
const { isOpencode, isCodex, isCursor } = runtimeFlags(runtime);
|
||
const dirName = getDirName(runtime);
|
||
|
||
// Get the target directory based on runtime and install type, mirroring the
|
||
// install() path resolution.
|
||
const targetDir = isGlobal
|
||
? getGlobalConfigDir(runtime, explicitConfigDir)
|
||
: path.join(process.cwd(), dirName);
|
||
|
||
const locationLabel = isGlobal
|
||
? targetDir.replace(os.homedir(), '~')
|
||
: targetDir.replace(process.cwd(), '.');
|
||
|
||
// runtimeLabel is now the single-source getRuntimeLabel lookup (ADR-1239
|
||
// Phase B / #1679) — collapses the prior 15-line assignment chain.
|
||
const runtimeLabel = getRuntimeLabel(runtime);
|
||
|
||
console.log(` Uninstalling MSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
|
||
|
||
// Check if target directory exists
|
||
if (!fs.existsSync(targetDir)) {
|
||
console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
|
||
console.log(` Nothing to uninstall.\n`);
|
||
return;
|
||
}
|
||
|
||
let removedCount = 0;
|
||
|
||
// #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user
|
||
// artifact orphaned by a PRIOR uninstall run that died between staging and
|
||
// its own restore/discard, BEFORE this run's own preserve steps (sites 2,
|
||
// 3, 5 below) stage anything new. Uninstall's own msd-core/ removal and
|
||
// legacy-commands cleanup are exactly as crash-exposed as install's —
|
||
// without this, an orphan from a crashed uninstall is recoverable only if
|
||
// the user later re-installs.
|
||
// #2875 defect fix: DEGRADE, never abort uninstall, when the staging root
|
||
// itself cannot be resolved — skip this recovery pass rather than throw
|
||
// out of uninstall() before it does anything.
|
||
{
|
||
const _uninstallEntryStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
|
||
if (_uninstallEntryStagingRoot !== null) {
|
||
recoverOrphanedUserArtifacts(_uninstallEntryStagingRoot, targetDir);
|
||
}
|
||
}
|
||
|
||
// Remove profile marker so a clean reinstall defaults to full surface.
|
||
try {
|
||
fs.unlinkSync(path.join(targetDir, '.msd-profile'));
|
||
removedCount++;
|
||
} catch {}
|
||
|
||
// 1. Remove MSD commands/skills (layout-driven)
|
||
// #2870: scope id resolved ONCE here and reused below (was two independent
|
||
// isGlobal-derived re-derivations). Routed through the Install Scope
|
||
// Module (src/install-scope.cts) when the capability registry is
|
||
// available; degrades to the plain id on failure (unknown/non-installable
|
||
// runtime, broken bundle) so this function's scope-id uses — which never
|
||
// depended on registry availability before this migration — keep working
|
||
// exactly as they did pre-migration.
|
||
const _uninstallScopeId = isGlobal ? 'global' : 'local';
|
||
const _resolvedUninstallScope = _resolveScopeSafe(_uninstallScopeId, runtime);
|
||
const scope = _resolvedUninstallScope ? _resolvedUninstallScope.id : _uninstallScopeId;
|
||
// ADR-1239 / #2086: drive uninstall through the public Host-Integration Interface.
|
||
// Fail-open to the engine directly if the composed-registry adapter can't load.
|
||
const _uninstallAdapter = _runtimeAdapter(runtime);
|
||
if (_uninstallAdapter) {
|
||
_uninstallAdapter.uninstall({ configDir: targetDir, scope });
|
||
} else {
|
||
uninstallRuntimeArtifacts(runtime, targetDir, scope);
|
||
}
|
||
removedCount++;
|
||
|
||
// ADR-1239 split-home migration: the adapter/plan uninstall targets the new
|
||
// `home` location (e.g. Codex → ~/.agents/skills). A user who installed
|
||
// BEFORE the move and never reinstalled still has managed msd-* skill dirs at
|
||
// the old configDir-rooted location (~/.codex/skills) — remove those too so
|
||
// uninstall leaves nothing behind. User-owned content is preserved.
|
||
{
|
||
const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope);
|
||
if (_movedOldSkillsDir) {
|
||
const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'msd-');
|
||
if (migrated > 0) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed ${migrated} legacy skill dir(s) from ${_movedOldSkillsDir}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 1a. Non-layout Codex side-effects: agent .toml files, config.toml sections, hooks.json
|
||
if (_hostBehaviors(runtime).tomlConfigInstall) {
|
||
const codexAgentsDir = path.join(targetDir, 'agents');
|
||
if (fs.existsSync(codexAgentsDir)) {
|
||
const tomlFiles = fs.readdirSync(codexAgentsDir);
|
||
let tomlCount = 0;
|
||
for (const file of tomlFiles) {
|
||
if (file.startsWith('msd-') && file.endsWith('.toml')) {
|
||
fs.unlinkSync(path.join(codexAgentsDir, file));
|
||
tomlCount++;
|
||
}
|
||
}
|
||
if (tomlCount > 0) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed ${tomlCount} agent .toml configs`);
|
||
}
|
||
}
|
||
|
||
// Codex: clean MSD sections from config.toml
|
||
const codexConfigPath = path.join(targetDir, 'config.toml');
|
||
if (fs.existsSync(codexConfigPath)) {
|
||
const content = fs.readFileSync(codexConfigPath, 'utf8');
|
||
const cleaned = stripMsdFromCodexConfig(content);
|
||
if (cleaned === null) {
|
||
fs.unlinkSync(codexConfigPath);
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed config.toml (was MSD-only)`);
|
||
} else if (cleaned !== content) {
|
||
fs.writeFileSync(codexConfigPath, cleaned);
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Cleaned MSD sections from config.toml`);
|
||
}
|
||
}
|
||
|
||
const hooksJsonCleanup = removeCodexHooksJsonSessionStart(targetDir);
|
||
if (hooksJsonCleanup.changed) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`);
|
||
}
|
||
|
||
// #772/#2088: remove every managed Codex extended hook-event registration.
|
||
// Shares CODEX_EXTENDED_HOOK_EVENTS with the install loop — removal set ==
|
||
// registration set, so no managed event is ever orphaned.
|
||
for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) {
|
||
const eventCleanup = removeCodexHooksJsonEvent(targetDir, eventName);
|
||
if (eventCleanup.changed) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed managed Codex ${eventName} hook from hooks.json`);
|
||
}
|
||
}
|
||
// #2586: uninstall's own symmetric half of the orphaned-script cleanup —
|
||
// same MSD-owned + unreferenced gate as the install-time call.
|
||
const uninstallMonitorCleanup = hooksSurface.cleanupOrphanedCodexContextMonitorScript(targetDir);
|
||
for (const deletedPath of uninstallMonitorCleanup.deleted) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed orphaned Codex hook script (${path.basename(deletedPath)})`);
|
||
}
|
||
for (const warning of uninstallMonitorCleanup.warnings) {
|
||
console.warn(` ${yellow}⚠${reset} Could not remove orphaned Codex hook script ${warning.path}: ${warning.reason}`);
|
||
}
|
||
}
|
||
|
||
// 1b-cursor. Descriptor-driven hook-bus cleanup (ADR-1239 / #2089): remove
|
||
// MSD-managed hook entries from hooks.json and clean up the managed hook
|
||
// scripts. Gated by the hostBehaviors.hooksJsonSurface descriptor axis, not a
|
||
// hardcoded `isCursor` branch.
|
||
if (_hostBehaviors(runtime).hooksJsonSurface) {
|
||
const hooksJsonCleanup = removeCursorHooksJson(targetDir);
|
||
if (hooksJsonCleanup.changed) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed MSD-managed Cursor hooks from hooks.json`);
|
||
}
|
||
// Remove all MSD-managed hook scripts (sessionStart, postToolUse, preToolUse,
|
||
// stop, subagentStart, subagentStop — AC4a, #2089).
|
||
const hooksDir = path.join(targetDir, 'hooks');
|
||
for (const script of MSD_CURSOR_HOOK_SCRIPTS) {
|
||
const p = path.join(hooksDir, script);
|
||
try {
|
||
if (fs.existsSync(p)) {
|
||
fs.unlinkSync(p);
|
||
removedCount++;
|
||
}
|
||
} catch { /* best-effort */ }
|
||
}
|
||
// Prune hooks/ if empty.
|
||
try {
|
||
if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) {
|
||
fs.rmdirSync(hooksDir);
|
||
}
|
||
} catch { /* best-effort */ }
|
||
}
|
||
|
||
// 1c. Claude local: remove flat msd-*.md commands from commands/ (current layout,
|
||
// #1367 fix). Also remove legacy commands/msd/ subdirectory from prior installs.
|
||
if (!isGlobal && _hostBehaviors(runtime).localInstallStyle === 'legacy-flat') {
|
||
const commandsDir = path.join(targetDir, 'commands');
|
||
// Remove flat msd-*.md files (current layout after #1367 fix)
|
||
if (fs.existsSync(commandsDir)) {
|
||
let removed = 0;
|
||
for (const f of fs.readdirSync(commandsDir)) {
|
||
if (f.startsWith('msd-') && f.endsWith('.md')) {
|
||
fs.rmSync(path.join(commandsDir, f), { force: true });
|
||
removed++;
|
||
}
|
||
}
|
||
if (removed > 0) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed ${removed} flat msd-*.md commands from commands/`);
|
||
}
|
||
}
|
||
// Remove legacy commands/msd/ subdirectory if it still exists (pre-#1367 layout).
|
||
// Preserve user-owned dev-preferences.md if present (#1423 parity).
|
||
const legacyMsdCommandsDir = path.join(targetDir, 'commands', 'msd');
|
||
if (fs.existsSync(legacyMsdCommandsDir)) {
|
||
// Stage user-owned dev-preferences.md DURABLY before wiping (#2875 /
|
||
// #1874-F19 "site 7" — found by sweeping bin/install.js for the
|
||
// read-then-wipe-then-write PATTERN, not for preserveUserArtifacts'
|
||
// callers; this uninstall-path block open-coded the same round-trip).
|
||
// #2875 defect fix: DEGRADE, never abort uninstall, when the staging
|
||
// root cannot be resolved — skip this legacy-cleanup block entirely
|
||
// (leave the stale dir in place) rather than wipe without a durable
|
||
// backup for dev-preferences.md.
|
||
const _legacyMsdCommandsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
|
||
if (_legacyMsdCommandsStagingRoot !== null) {
|
||
const stagedDevPrefs = stageUserArtifacts(legacyMsdCommandsDir, ['dev-preferences.md'], _legacyMsdCommandsStagingRoot);
|
||
// Preserve the ORIGINAL truthy-content check exactly: an existing but
|
||
// EMPTY dev-preferences.md was (and still is) silently not restored.
|
||
const savedDevPrefs = stagedDevPrefs.names.includes('dev-preferences.md')
|
||
? fs.readFileSync(path.join(stagedDevPrefs.filesDir, 'dev-preferences.md'), 'utf8')
|
||
: null;
|
||
fs.rmSync(legacyMsdCommandsDir, { recursive: true });
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed legacy commands/msd/`);
|
||
if (savedDevPrefs) {
|
||
try {
|
||
restoreStagedUserArtifacts(legacyMsdCommandsDir, stagedDevPrefs);
|
||
discardStagedUserArtifacts(stagedDevPrefs);
|
||
console.log(` ${green}✓${reset} Preserved commands/msd/dev-preferences.md`);
|
||
} catch (err) {
|
||
console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
|
||
}
|
||
} else {
|
||
// #2875 defect fix: an existing-but-EMPTY dev-preferences.md was (and
|
||
// still is) never restored — the original truthy-content check is
|
||
// preserved byte-for-byte above — but the staged batch was never
|
||
// discarded either, leaking a <configDir>/.msd-staging/ record
|
||
// forever and re-materializing the just-deleted file on a future
|
||
// install's orphan-recovery pass. Discard unconditionally when there
|
||
// is nothing to restore.
|
||
discardStagedUserArtifacts(stagedDevPrefs);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Remove msd-core directory
|
||
const msdDir = path.join(targetDir, 'msd-core');
|
||
if (fs.existsSync(msdDir)) {
|
||
// Stage user-generated files DURABLY to disk before wipe (#1423; #2875 /
|
||
// #1874-F19 "site 5" — this block open-coded its own preserve/restore
|
||
// instead of calling preserveUserArtifacts, which is why it was missed
|
||
// by the original symbol-search measurement).
|
||
// #2875 defect fix: this IS the core uninstall step (removing msd-core/)
|
||
// — unlike the optional legacy-cleanup blocks above, uninstall must
|
||
// still be able to proceed and actually remove msd-core/ even when the
|
||
// staging root cannot be resolved. Degrade by skipping ONLY the
|
||
// USER-PROFILE.md preserve/restore wrapper (warn), never the removal
|
||
// itself.
|
||
const _msdDirStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
|
||
if (_msdDirStagingRoot === null) {
|
||
console.warn(` ${yellow}!${reset} Skipping msd-core/USER-PROFILE.md preservation (staging unavailable) — it will be lost if present.`);
|
||
fs.rmSync(msdDir, { recursive: true });
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed msd-core/`);
|
||
} else {
|
||
const stagedProfile = stageUserArtifacts(msdDir, USER_OWNED_ARTIFACTS, _msdDirStagingRoot);
|
||
// Preserve the ORIGINAL truthy-content check exactly: an existing but
|
||
// EMPTY USER-PROFILE.md was (and still is) silently not restored —
|
||
// matching prior behavior byte-for-byte rather than widening scope.
|
||
const preservedProfile = stagedProfile.names.includes('USER-PROFILE.md')
|
||
? fs.readFileSync(path.join(stagedProfile.filesDir, 'USER-PROFILE.md'), 'utf8')
|
||
: null;
|
||
|
||
fs.rmSync(msdDir, { recursive: true });
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed msd-core/`);
|
||
|
||
// Restore user-generated files
|
||
if (preservedProfile) {
|
||
try {
|
||
restoreStagedUserArtifacts(msdDir, stagedProfile);
|
||
discardStagedUserArtifacts(stagedProfile);
|
||
console.log(` ${green}✓${reset} Preserved msd-core/USER-PROFILE.md`);
|
||
} catch (err) {
|
||
console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`);
|
||
}
|
||
} else {
|
||
// #2875 defect fix: same empty-file orphan leak as the legacy
|
||
// commands/msd/ site above — discard the staging batch regardless of
|
||
// whether the staged content was truthy.
|
||
discardStagedUserArtifacts(stagedProfile);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Remove MSD agents (msd-*.md files only)
|
||
const agentsDir = path.join(targetDir, 'agents');
|
||
if (fs.existsSync(agentsDir)) {
|
||
const files = fs.readdirSync(agentsDir);
|
||
let agentCount = 0;
|
||
for (const file of files) {
|
||
if (file.startsWith('msd-') && file.endsWith('.md')) {
|
||
fs.unlinkSync(path.join(agentsDir, file));
|
||
agentCount++;
|
||
}
|
||
}
|
||
if (agentCount > 0) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed ${agentCount} MSD agents`);
|
||
}
|
||
}
|
||
|
||
// 4. Remove MSD hooks
|
||
// #3023: mirror the install site's descriptor-driven bundle dir name.
|
||
const hooksDir = path.join(targetDir, SHARED_HOOKS_DIR);
|
||
if (fs.existsSync(hooksDir)) {
|
||
let hookCount = 0;
|
||
for (const hook of MSD_UNINSTALL_HOOKS) {
|
||
const hookPath = path.join(hooksDir, hook);
|
||
if (fs.existsSync(hookPath)) {
|
||
fs.unlinkSync(hookPath);
|
||
hookCount++;
|
||
}
|
||
}
|
||
if (hookCount > 0) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed ${hookCount} MSD hooks`);
|
||
}
|
||
|
||
// Remove only the MSD-managed files from hooks/lib/ (git-cmd.js + msd-graphify-rebuild.sh).
|
||
// hooks/lib/ lives inside the user's runtime hooks directory (shared space) and
|
||
// may contain user-owned custom helpers. We must not recursively delete the dir.
|
||
const hooksLibDir = path.join(hooksDir, 'lib');
|
||
if (fs.existsSync(hooksLibDir)) {
|
||
let removedLibFiles = 0;
|
||
for (const file of MSD_HOOK_LIB_FILES) {
|
||
const filePath = path.join(hooksLibDir, file);
|
||
try {
|
||
fs.unlinkSync(filePath);
|
||
removedLibFiles++;
|
||
} catch (_) {
|
||
// Ignore missing files (best effort, non-fatal)
|
||
}
|
||
}
|
||
// Only remove the directory itself if it is now empty (preserve any user files)
|
||
try {
|
||
fs.rmdirSync(hooksLibDir);
|
||
} catch (_) {
|
||
// Directory not empty or other error — leave it alone
|
||
}
|
||
if (removedLibFiles > 0) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed ${removedLibFiles} hooks/lib/ helper(s)`);
|
||
}
|
||
}
|
||
|
||
// Retire the CommonJS marker staged into hooks/. hooks/ is shared space and
|
||
// is deliberately never rmdir'd here, so the marker must be removed
|
||
// explicitly or it would be left behind. Removed ONLY when it still carries
|
||
// MSD's exact content — a user-authored package.json is never deleted.
|
||
//
|
||
// #2717 reaches the runtimes that stage .js hooks via dedicated paths
|
||
// (cursor/codex); #2544 reaches the shared-bundle runtimes, whose
|
||
// marker this PR moves out of the config root and into hooks/. Both land in
|
||
// the same directory, so one guarded call covers both.
|
||
try {
|
||
if (hooksSurface.removeCommonJsMarkerIfMsdOwned(hooksDir)) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed MSD hooks/package.json (CommonJS marker)`);
|
||
}
|
||
} catch { /* best-effort */ }
|
||
}
|
||
|
||
// 4z. Remove the native plugin adapter (#1914).
|
||
// Descriptor-driven via hostBehaviors.nativePlugin — covers every runtime
|
||
// that declares the block (OpenCode, ...), not just OpenCode. Only
|
||
// MSD's own plugin file is removed; the plugins/ dir is pruned only if it
|
||
// becomes empty, preserving any user-authored plugins for that host.
|
||
const _np = _hostBehaviors(runtime).nativePlugin;
|
||
if (_np) {
|
||
const pluginsDir = path.join(targetDir, _np.dir);
|
||
const pluginPath = path.join(pluginsDir, _np.file);
|
||
// Tracks whether MSD actually removed anything from pluginsDir. The rmdir
|
||
// below is gated on it: pruning a directory MSD never wrote to is the same
|
||
// "don't touch territory MSD didn't fill" violation this issue is about,
|
||
// just inverted — a user-created but empty plugin/ or extensions/ dir is
|
||
// theirs, and an uninstall that never removed anything has no business
|
||
// deleting it.
|
||
let removedFromPluginsDir = false;
|
||
if (fs.existsSync(pluginPath)) {
|
||
try {
|
||
fs.unlinkSync(pluginPath);
|
||
removedCount++;
|
||
removedFromPluginsDir = true;
|
||
console.log(` ${green}✓${reset} Removed native plugin adapter (${runtime})`);
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
// #2544: the adapter's CommonJS marker sits beside it. Cleaned up OUTSIDE
|
||
// the adapter-exists guard above — a partial install (or a hand-deleted
|
||
// adapter) would otherwise strand MSD's marker forever and keep the dir
|
||
// from ever pruning. Conditioned on the adapter being GONE, though: if the
|
||
// unlink above failed, pulling the marker out from under a still-present
|
||
// CommonJS adapter would leave it unloadable. The exact content match
|
||
// still leaves any user-authored package.json in place.
|
||
if (!fs.existsSync(pluginPath) && removeCommonJsMarker(pluginsDir)) {
|
||
removedCount++;
|
||
removedFromPluginsDir = true;
|
||
console.log(` ${green}✓${reset} Removed MSD package.json from ${_np.dir}/`);
|
||
}
|
||
// Only prune a dir MSD emptied. Pre-fix this rmdir sat inside the
|
||
// adapter-exists guard, so it could never fire on a dir MSD had not
|
||
// written to; hoisting it out to catch the marker-only case must not
|
||
// silently widen it to "any empty plugin dir".
|
||
if (removedFromPluginsDir) {
|
||
try { fs.rmdirSync(pluginsDir); } catch (_) { /* not empty — user plugins present */ }
|
||
}
|
||
}
|
||
|
||
// 4a. Remove scripts/changeset/ and scripts/lib/ (#935)
|
||
// MSD-managed files only: enumerate the exact set the installer writes.
|
||
// Any file NOT in this set is user-owned and must survive uninstall.
|
||
// After removing MSD files, attempt to rmdir — if the directory is still
|
||
// non-empty (user has custom helpers) it stays; otherwise it goes cleanly.
|
||
// MSD_CHANGESET_FILES / MSD_SCRIPTS_LIB_FILES are module-scoped (#3184) so
|
||
// tests can assert their parity against the real directory contents.
|
||
const changesetUninstallDir = path.join(targetDir, 'scripts', 'changeset');
|
||
if (fs.existsSync(changesetUninstallDir)) {
|
||
let removedChangeset = 0;
|
||
for (const file of MSD_CHANGESET_FILES) {
|
||
const fp = path.join(changesetUninstallDir, file);
|
||
try { fs.unlinkSync(fp); removedChangeset++; } catch (_) { /* best-effort */ }
|
||
}
|
||
// Remove directory if empty after our cleanup
|
||
try { fs.rmdirSync(changesetUninstallDir); } catch (_) { /* Not empty — user content present */ }
|
||
if (removedChangeset > 0) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed scripts/changeset/ MSD files`);
|
||
}
|
||
}
|
||
const scriptsLibUninstallDir = path.join(targetDir, 'scripts', 'lib');
|
||
if (fs.existsSync(scriptsLibUninstallDir)) {
|
||
let removedScriptsLib = 0;
|
||
for (const file of MSD_SCRIPTS_LIB_FILES) {
|
||
const fp = path.join(scriptsLibUninstallDir, file);
|
||
try { fs.unlinkSync(fp); removedScriptsLib++; } catch (_) { /* best-effort */ }
|
||
}
|
||
// Remove directory if empty after our cleanup
|
||
try { fs.rmdirSync(scriptsLibUninstallDir); } catch (_) { /* Not empty — user content present */ }
|
||
if (removedScriptsLib > 0) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed scripts/lib/ MSD files`);
|
||
}
|
||
}
|
||
// Remove scripts/fix-slash-commands.cjs (#1223) — must come before the scripts/ rmdir
|
||
const fixSlashUninstallPath = path.join(targetDir, 'scripts', 'fix-slash-commands.cjs');
|
||
try { fs.unlinkSync(fixSlashUninstallPath); } catch (_) { /* best-effort */ }
|
||
|
||
// Remove the capability registry generator scripts (#1920) — before the scripts/ rmdir
|
||
for (const gen of ['gen-capability-registry.cjs', 'gen-loop-host-contract.cjs']) {
|
||
try { fs.unlinkSync(path.join(targetDir, 'scripts', gen)); } catch (_) { /* best-effort */ }
|
||
}
|
||
|
||
// If scripts/ dir is now empty, remove it too
|
||
const scriptsUninstallDir = path.join(targetDir, 'scripts');
|
||
if (fs.existsSync(scriptsUninstallDir)) {
|
||
try { fs.rmdirSync(scriptsUninstallDir); } catch (_) { /* Not empty — leave it */ }
|
||
}
|
||
|
||
// 5. Remove MSD package.json (CommonJS mode marker)
|
||
// Since #2544 the marker is staged into hooks/ (and the nativePlugin dir,
|
||
// handled at 4z above) rather than at targetDir. The targetDir removal is
|
||
// retained to retire the marker written by pre-#2544 installs — same exact
|
||
// content match as before, so a user-authored package.json is still never
|
||
// touched.
|
||
if (removeCommonJsMarker(targetDir)) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed MSD package.json (pre-#2544 config-root marker)`);
|
||
}
|
||
|
||
// 6. Clean up settings.json (remove MSD hooks and statusline)
|
||
const settingsPath = path.join(targetDir, 'settings.json');
|
||
if (fs.existsSync(settingsPath)) {
|
||
let settings = readSettings(settingsPath);
|
||
if (settings === null) {
|
||
console.log(` ${yellow}i${reset} Skipping settings.json cleanup — file could not be parsed`);
|
||
settings = {}; // prevent downstream crashes, but don't write back
|
||
}
|
||
let settingsModified = false;
|
||
|
||
// Remove MSD statusline if it references our hook
|
||
if (settings.statusLine && settings.statusLine.command &&
|
||
settings.statusLine.command.includes('msd-statusline')) {
|
||
delete settings.statusLine;
|
||
settingsModified = true;
|
||
console.log(` ${green}✓${reset} Removed MSD statusline from settings`);
|
||
}
|
||
|
||
// Remove MSD hooks from settings — per-hook granularity to preserve
|
||
// user hooks that share an entry with a MSD hook (#1755 followup).
|
||
// Includes the 3 events added in #788 (SubagentStop, Stop,
|
||
// PreCompact, registered for Claude in #770), the 3 Antigravity-only
|
||
// events added in #776 (BeforeAgent, AfterAgent, BeforeModel), and the
|
||
// Claude-only FileChanged event added in #770 — safe to iterate for all
|
||
// runtimes; installs that don't register these events simply find no
|
||
// entries and skip.
|
||
for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'FileChanged']) {
|
||
if (settings.hooks && settings.hooks[eventName]) {
|
||
const before = JSON.stringify(settings.hooks[eventName]);
|
||
settings.hooks[eventName] = settings.hooks[eventName]
|
||
.map(entry => {
|
||
if (!entry || typeof entry !== 'object' || !Array.isArray(entry.hooks)) return entry;
|
||
// Filter out individual MSD hooks, keep user hooks
|
||
entry.hooks = entry.hooks.filter((h) => {
|
||
if (!h || typeof h.command !== 'string') return true;
|
||
return !isManagedHookCommand(h.command, {
|
||
surface: 'settings-json',
|
||
});
|
||
});
|
||
return entry.hooks.length > 0 ? entry : null;
|
||
})
|
||
.filter(Boolean);
|
||
if (JSON.stringify(settings.hooks[eventName]) !== before) {
|
||
settingsModified = true;
|
||
}
|
||
if (settings.hooks[eventName].length === 0) {
|
||
delete settings.hooks[eventName];
|
||
}
|
||
}
|
||
}
|
||
if (settingsModified) {
|
||
console.log(` ${green}✓${reset} Removed MSD hooks from settings`);
|
||
}
|
||
|
||
// Clean up empty hooks object
|
||
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
||
delete settings.hooks;
|
||
}
|
||
|
||
// #768 — Remove MSD-owned Claude permissions from settings.json.
|
||
// Applies only to Claude uninstalls. Filter only the exact MSD-owned entries
|
||
// to preserve any user-added allow/deny entries.
|
||
// Uses a local flag to avoid the shared `settingsModified` producing a false
|
||
// "Removed MSD permissions" message when only hooks/statusline changed.
|
||
if (_hostBehaviors(runtime).permissionsSchema === 'claude' && settings.permissions) {
|
||
let permissionsModified = false;
|
||
if (Array.isArray(settings.permissions.allow)) {
|
||
const before = settings.permissions.allow.length;
|
||
// #2278 — filter against the union of the current allow-rule forms
|
||
// AND the retired legacy forms, so uninstall still cleans up
|
||
// pre-fix installs that still carry the stale `Write(...)` entries.
|
||
settings.permissions.allow = settings.permissions.allow.filter(
|
||
(e) => !MSD_CLAUDE_ALLOW_PERMISSIONS.includes(e) && !MSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS.includes(e)
|
||
);
|
||
if (settings.permissions.allow.length !== before) {
|
||
permissionsModified = true;
|
||
// #4221: an array this filter emptied was MSD-only \u2014 remove the
|
||
// key rather than leave an empty array behind (Antigravity symmetry).
|
||
if (settings.permissions.allow.length === 0) {
|
||
delete settings.permissions.allow;
|
||
}
|
||
}
|
||
}
|
||
if (Array.isArray(settings.permissions.deny)) {
|
||
const before = settings.permissions.deny.length;
|
||
// #4221: the deny rules are retired, so this is a legacy-only filter.
|
||
settings.permissions.deny = settings.permissions.deny.filter(
|
||
(e) => !MSD_CLAUDE_LEGACY_DENY_PERMISSIONS.includes(e)
|
||
);
|
||
if (settings.permissions.deny.length !== before) {
|
||
permissionsModified = true;
|
||
if (settings.permissions.deny.length === 0) {
|
||
delete settings.permissions.deny;
|
||
}
|
||
}
|
||
}
|
||
if (permissionsModified && Object.keys(settings.permissions).length === 0) {
|
||
delete settings.permissions;
|
||
}
|
||
if (permissionsModified) {
|
||
settingsModified = true;
|
||
console.log(` ${green}✓${reset} Removed MSD permissions from settings.json`);
|
||
}
|
||
}
|
||
|
||
// #2096 Phase B Upgrade 1 — Remove MSD-owned Antigravity permissions.allow
|
||
// rules from settings.json. Symmetric to the Claude branch above: filters
|
||
// only the exact MSD-owned rule strings (regenerated from the current
|
||
// configDir) to preserve any user-added allow entries and all deny/ask.
|
||
if (resolveInstallPlan(runtime).finishPermissionWriter === 'antigravity' && settings.permissions) {
|
||
let antigravityPermissionsModified = false;
|
||
if (Array.isArray(settings.permissions.allow)) {
|
||
const msdRules = new Set(buildAntigravityAllowRules(targetDir));
|
||
const before = settings.permissions.allow.length;
|
||
settings.permissions.allow = settings.permissions.allow.filter((e) => !msdRules.has(e));
|
||
if (settings.permissions.allow.length !== before) {
|
||
antigravityPermissionsModified = true;
|
||
}
|
||
if (settings.permissions.allow.length === 0) {
|
||
delete settings.permissions.allow;
|
||
}
|
||
}
|
||
if (Object.keys(settings.permissions).length === 0) {
|
||
delete settings.permissions;
|
||
}
|
||
if (antigravityPermissionsModified) {
|
||
settingsModified = true;
|
||
console.log(` ${green}✓${reset} Removed MSD permissions from settings.json`);
|
||
}
|
||
}
|
||
|
||
if (settingsModified) {
|
||
writeSettings(settingsPath, settings);
|
||
removedCount++;
|
||
}
|
||
}
|
||
|
||
// 6. For OpenCode, clean up permissions from opencode.json or opencode.jsonc
|
||
if (resolveInstallPlan(runtime).finishPermissionWriter === 'opencode') {
|
||
const configPath = resolveOpencodeConfigPath(targetDir);
|
||
if (fs.existsSync(configPath)) {
|
||
try {
|
||
const config = parseJsonc(fs.readFileSync(configPath, 'utf8'));
|
||
let modified = false;
|
||
|
||
// Remove MSD permission entries
|
||
if (config.permission) {
|
||
for (const permType of ['read', 'external_directory']) {
|
||
if (config.permission[permType]) {
|
||
const keys = Object.keys(config.permission[permType]);
|
||
for (const key of keys) {
|
||
if (key.includes('msd-core')) {
|
||
delete config.permission[permType][key];
|
||
modified = true;
|
||
}
|
||
}
|
||
// Clean up empty objects
|
||
if (Object.keys(config.permission[permType]).length === 0) {
|
||
delete config.permission[permType];
|
||
}
|
||
}
|
||
}
|
||
if (Object.keys(config.permission).length === 0) {
|
||
delete config.permission;
|
||
}
|
||
}
|
||
|
||
if (modified) {
|
||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed MSD permissions from ${path.basename(configPath)}`);
|
||
}
|
||
} catch (e) {
|
||
// Ignore JSON parse errors
|
||
}
|
||
}
|
||
}
|
||
|
||
// 8. For Antigravity, remove the MCP companion entry from mcp_config.json
|
||
// (#2096 Phase B Upgrade 2). Only the MSD-owned mcpServers.msd key is
|
||
// removed — any other user-configured MCP servers are preserved.
|
||
if (resolveInstallPlan(runtime).finishPermissionWriter === 'antigravity') {
|
||
const mcpConfigPath = path.join(targetDir, 'mcp_config.json');
|
||
if (fs.existsSync(mcpConfigPath)) {
|
||
try {
|
||
const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8'));
|
||
if (mcpConfig && typeof mcpConfig === 'object' && mcpConfig.mcpServers && mcpConfig.mcpServers.msd !== undefined) {
|
||
delete mcpConfig.mcpServers.msd;
|
||
if (Object.keys(mcpConfig.mcpServers).length === 0) {
|
||
delete mcpConfig.mcpServers;
|
||
}
|
||
fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed MSD MCP companion server from mcp_config.json`);
|
||
}
|
||
} catch (e) {
|
||
// Ignore JSON parse errors
|
||
}
|
||
}
|
||
}
|
||
|
||
// Remove the file manifest that the installer wrote at install time.
|
||
// Without this step the metadata file persists after uninstall (#1908).
|
||
const manifestPath = path.join(targetDir, MANIFEST_NAME);
|
||
if (fs.existsSync(manifestPath)) {
|
||
fs.rmSync(manifestPath, { force: true });
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed ${MANIFEST_NAME}`);
|
||
}
|
||
|
||
if (removedCount === 0) {
|
||
console.log(` ${yellow}⚠${reset} No MSD files found to remove.`);
|
||
}
|
||
|
||
console.log(`
|
||
${green}Done!${reset} MSD has been uninstalled from ${runtimeLabel}.
|
||
Your other files and settings have been preserved.
|
||
`);
|
||
}
|
||
|
||
/**
|
||
* Parse JSONC (JSON with Comments) by stripping comments and trailing commas.
|
||
* OpenCode supports JSONC format via jsonc-parser, so users may have comments.
|
||
* This is a lightweight inline parser to avoid adding dependencies.
|
||
*/
|
||
function parseJsonc(content) {
|
||
// Strip BOM if present
|
||
if (content.charCodeAt(0) === 0xFEFF) {
|
||
content = content.slice(1);
|
||
}
|
||
|
||
// Remove single-line and block comments while preserving strings
|
||
let result = '';
|
||
let inString = false;
|
||
let i = 0;
|
||
while (i < content.length) {
|
||
const char = content[i];
|
||
const next = content[i + 1];
|
||
|
||
if (inString) {
|
||
result += char;
|
||
// Handle escape sequences
|
||
if (char === '\\' && i + 1 < content.length) {
|
||
result += next;
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (char === '"') {
|
||
inString = false;
|
||
}
|
||
i++;
|
||
} else {
|
||
if (char === '"') {
|
||
inString = true;
|
||
result += char;
|
||
i++;
|
||
} else if (char === '/' && next === '/') {
|
||
// Skip single-line comment until end of line
|
||
while (i < content.length && content[i] !== '\n') {
|
||
i++;
|
||
}
|
||
} else if (char === '/' && next === '*') {
|
||
// Skip block comment
|
||
i += 2;
|
||
while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) {
|
||
i++;
|
||
}
|
||
i += 2; // Skip closing */
|
||
} else {
|
||
result += char;
|
||
i++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Remove trailing commas before } or ]
|
||
result = result.replace(/,(\s*[}\]])/g, '$1');
|
||
|
||
return JSON.parse(result);
|
||
}
|
||
|
||
/**
|
||
* Configure OpenCode permissions to allow reading MSD reference docs
|
||
* This prevents permission prompts when MSD accesses the msd-core directory
|
||
* @param {boolean} isGlobal - Whether this is a global or local install
|
||
* @param {string|null} configDir - Resolved config directory when already known
|
||
*/
|
||
function configureOpencodePermissions(isGlobal = true, configDir = null) {
|
||
// For local installs, use ./.opencode/
|
||
// For global installs, use ~/.config/opencode/
|
||
const opencodeConfigDir = configDir || (isGlobal
|
||
? getGlobalConfigDir('opencode', explicitConfigDir)
|
||
: path.join(process.cwd(), '.opencode'));
|
||
// Ensure config directory exists
|
||
fs.mkdirSync(opencodeConfigDir, { recursive: true });
|
||
|
||
const configPath = resolveOpencodeConfigPath(opencodeConfigDir);
|
||
|
||
// Read existing config or create empty object
|
||
let config = {};
|
||
if (fs.existsSync(configPath)) {
|
||
try {
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
config = parseJsonc(content);
|
||
} catch (e) {
|
||
// Cannot parse - DO NOT overwrite user's config
|
||
const configFile = path.basename(configPath);
|
||
console.log(` ${yellow}⚠${reset} Could not parse ${configFile} - skipping permission config`);
|
||
console.log(` ${dim}Reason: ${e.message}${reset}`);
|
||
console.log(` ${dim}Your config was NOT modified. Fix the syntax manually if needed.${reset}`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// OpenCode also allows a top-level string permission like "allow".
|
||
// In that case, path-specific permission entries are unnecessary.
|
||
if (typeof config.permission === 'string') {
|
||
return;
|
||
}
|
||
|
||
// Ensure permission structure exists
|
||
if (!config.permission || typeof config.permission !== 'object') {
|
||
config.permission = {};
|
||
}
|
||
|
||
// Build the MSD path using the actual config directory
|
||
// Use ~ shorthand if it's in the default location, otherwise use full path
|
||
const defaultConfigDir = path.join(os.homedir(), '.config', 'opencode');
|
||
const msdPath = opencodeConfigDir === defaultConfigDir
|
||
? '~/.config/opencode/msd-core/*'
|
||
: `${opencodeConfigDir.replace(/\\/g, '/')}/msd-core/*`;
|
||
|
||
let modified = false;
|
||
|
||
// Configure read permission
|
||
if (!config.permission.read || typeof config.permission.read !== 'object') {
|
||
config.permission.read = {};
|
||
}
|
||
if (config.permission.read[msdPath] !== 'allow') {
|
||
config.permission.read[msdPath] = 'allow';
|
||
modified = true;
|
||
}
|
||
|
||
// Configure external_directory permission (the safety guard for paths outside)
|
||
if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
|
||
config.permission.external_directory = {};
|
||
}
|
||
if (config.permission.external_directory[msdPath] !== 'allow') {
|
||
config.permission.external_directory[msdPath] = 'allow';
|
||
modified = true;
|
||
}
|
||
|
||
// ADR-1239 Phase D / #1682 — register the companion MCP server (Phase 4) so
|
||
// OpenCode connects to MSD's command (point 1) + state-IO (point 5) surface
|
||
// with NO bespoke plugin. Idempotent + non-clobbering: only added when
|
||
// `mcp.msd` is absent (a user-defined `mcp.msd` is respected — Hyrum's Law).
|
||
// Local-stdio schema per OpenCode config (packages/core/src/config/mcp.ts).
|
||
// `-p @golem15/msd-core` resolves the `msd-mcp-server` bin from this package
|
||
// (bin name != package name) regardless of global-install state.
|
||
if (!config.mcp || typeof config.mcp !== 'object') {
|
||
config.mcp = {};
|
||
}
|
||
if (config.mcp.msd === undefined) {
|
||
config.mcp.msd = {
|
||
type: 'local',
|
||
command: ['npx', '-y', '-p', PACKAGE_NAME, 'msd-mcp-server'],
|
||
enabled: true,
|
||
};
|
||
modified = true;
|
||
}
|
||
|
||
if (!modified) {
|
||
return; // Already configured
|
||
}
|
||
|
||
// Write config back
|
||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
||
console.log(` ${green}✓${reset} Configured read permission for MSD docs`);
|
||
}
|
||
|
||
/**
|
||
* Convert an absolute path to a `~`-relative form when it lives under the
|
||
* user's home directory (generalizes configureOpencodePermissions'
|
||
* single-default-dir shorthand to Antigravity's three probed sibling config
|
||
* dirs — antigravity/antigravity-ide/antigravity-cli under ~/.gemini — none of
|
||
* which is a single fixed "default").
|
||
*/
|
||
function toTildePosixPath(absPath) {
|
||
const posixPath = absPath.replace(/\\/g, '/');
|
||
const posixHome = os.homedir().replace(/\\/g, '/');
|
||
return posixPath === posixHome || posixPath.startsWith(`${posixHome}/`)
|
||
? `~${posixPath.slice(posixHome.length)}`
|
||
: posixPath;
|
||
}
|
||
|
||
/**
|
||
* Antigravity permission rule strings this installer contributes.
|
||
* Schema: antigravity.google/docs/cli/permissions — "action(target)" rule
|
||
* strings in permissions.{allow,deny,ask}, evaluated deny > ask > allow. MSD
|
||
* only ever contributes to `allow` — never deny/ask (those are user-owned risk
|
||
* decisions this installer has no business making).
|
||
*/
|
||
function buildAntigravityAllowRules(configDir) {
|
||
const msdPath = toTildePosixPath(configDir);
|
||
return [
|
||
`read_file(${msdPath}/msd-core/*)`,
|
||
`read_file(${msdPath}/agents/msd-*)`,
|
||
`read_file(${msdPath}/skills/msd-*)`,
|
||
`command(node ${msdPath}/hooks/*)`,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Configure Antigravity permissions to allow reading/executing MSD's installed
|
||
* tree without per-call approval prompts (#2096 Phase B Upgrade 1 — mirrors
|
||
* configureOpencodePermissions).
|
||
*
|
||
* Antigravity's permission schema (antigravity.google/docs/cli/permissions) is
|
||
* `{"permissions":{"allow":[...],"deny":[...],"ask":[...]}}`, living in the
|
||
* SAME settings.json MSD's own hook registration writes for this runtime
|
||
* (installSurface: 'settings-json', writesSharedSettings: true) — unlike
|
||
* OpenCode, which writes a separate native config file. This function
|
||
* re-reads the file (already containing MSD's hooks by the time finishInstall
|
||
* reaches this call) and only appends to permissions.allow.
|
||
*
|
||
* Non-destructive + idempotent: only `permissions.allow` is touched; an
|
||
* existing user permissions block (including any deny/ask entries, or
|
||
* unrelated allow entries) is preserved untouched.
|
||
*
|
||
* @param {boolean} isGlobal - Whether this is a global or local install
|
||
* @param {string|null} configDir - Resolved config directory when already known
|
||
*/
|
||
function configureAntigravityPermissions(isGlobal = true, configDir = null) {
|
||
// For local installs, use ./.agents/ (MSD's antigravity localConfigDir)
|
||
// For global installs, use the resolved ~/.gemini/antigravity{,-ide,-cli}
|
||
const antigravityConfigDir = configDir || (isGlobal
|
||
? getGlobalConfigDir('antigravity', explicitConfigDir)
|
||
: path.join(process.cwd(), '.agents'));
|
||
// Ensure config directory exists
|
||
fs.mkdirSync(antigravityConfigDir, { recursive: true });
|
||
|
||
const configPath = path.join(antigravityConfigDir, 'settings.json');
|
||
|
||
// Read existing settings.json (readSettings tolerates JSONC + missing file;
|
||
// returns null — and warns — only when the file exists but fails to parse).
|
||
const config = readSettings(configPath);
|
||
if (config === null) {
|
||
// Cannot parse — DO NOT overwrite user's config (readSettings already warned).
|
||
return;
|
||
}
|
||
|
||
// Ensure permission structure exists
|
||
if (!config.permissions || typeof config.permissions !== 'object' || Array.isArray(config.permissions)) {
|
||
config.permissions = {};
|
||
}
|
||
if (!Array.isArray(config.permissions.allow)) {
|
||
config.permissions.allow = [];
|
||
}
|
||
|
||
let modified = false;
|
||
for (const rule of buildAntigravityAllowRules(antigravityConfigDir)) {
|
||
if (!config.permissions.allow.includes(rule)) {
|
||
config.permissions.allow.push(rule);
|
||
modified = true;
|
||
}
|
||
}
|
||
|
||
if (!modified) {
|
||
return; // Already configured
|
||
}
|
||
|
||
writeSettings(configPath, config);
|
||
console.log(` ${green}✓${reset} Configured Antigravity permissions for MSD paths`);
|
||
}
|
||
|
||
/**
|
||
* Configure Antigravity's MCP companion server config (#2096 Phase B
|
||
* Upgrade 2).
|
||
*
|
||
* Antigravity CLI manages MCP servers via standalone `mcp_config.json`
|
||
* profiles rather than nesting them in settings.json (antigravity.google/docs/
|
||
* cli/gcli-migration: "Antigravity CLI uses standalone mcp_config.json
|
||
* profiles in ~/.gemini/config/ for global servers and .agents/mcp_config.json
|
||
* for workspace servers"). The raw schema for the Antigravity IDE surface
|
||
* itself is unpublished (docs are JS-rendered), so this follows the CLI's
|
||
* documented standalone-profile convention plus the standard Gemini/MCP
|
||
* `mcpServers` shape.
|
||
*
|
||
* BEST-EFFORT PATH CHOICE: rather than the CLI doc's separate `~/.gemini/config/`
|
||
* directory for global scope, this writes `<configDir>/mcp_config.json` — the
|
||
* SAME resolved configDir as settings.json (configureAntigravityPermissions) —
|
||
* because (1) MSD's own antigravity configDir resolution already varies
|
||
* per-user across three sibling dirs (antigravity/antigravity-ide/
|
||
* antigravity-cli — see resolveAntigravityGlobalDir), so a hardcoded separate
|
||
* shared path would not track that resolution, and (2) it matches the doc's
|
||
* OWN workspace-scope convention exactly (`.agents/mcp_config.json`, which IS
|
||
* MSD's local configDir for antigravity), keeping global/local symmetric and
|
||
* consistent with the configDir-relative convention every other MSD
|
||
* permission writer (opencode) already uses.
|
||
*
|
||
* Non-destructive + idempotent: only adds mcpServers.msd when entirely absent;
|
||
* any other user-configured mcpServers entries (or a user's OWN "msd" override)
|
||
* are preserved untouched (Hyrum's Law — mirrors OpenCode's config.mcp.msd guard).
|
||
*
|
||
* @param {boolean} isGlobal - Whether this is a global or local install
|
||
* @param {string|null} configDir - Resolved config directory when already known
|
||
*/
|
||
function configureAntigravityMcpConfig(isGlobal = true, configDir = null) {
|
||
const antigravityConfigDir = configDir || (isGlobal
|
||
? getGlobalConfigDir('antigravity', explicitConfigDir)
|
||
: path.join(process.cwd(), '.agents'));
|
||
fs.mkdirSync(antigravityConfigDir, { recursive: true });
|
||
|
||
const configPath = path.join(antigravityConfigDir, 'mcp_config.json');
|
||
|
||
let config = {};
|
||
if (fs.existsSync(configPath)) {
|
||
try {
|
||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||
config = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
|
||
} catch (e) {
|
||
// Cannot parse - DO NOT overwrite user's config
|
||
console.log(` ${yellow}⚠${reset} Could not parse mcp_config.json - skipping MCP companion config`);
|
||
console.log(` ${dim}Reason: ${e.message}${reset}`);
|
||
console.log(` ${dim}Your config was NOT modified. Fix the syntax manually if needed.${reset}`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!config.mcpServers || typeof config.mcpServers !== 'object' || Array.isArray(config.mcpServers)) {
|
||
config.mcpServers = {};
|
||
}
|
||
|
||
if (config.mcpServers.msd !== undefined) {
|
||
return; // Already configured (or a user-owned override) — never clobber.
|
||
}
|
||
|
||
config.mcpServers.msd = {
|
||
command: 'npx',
|
||
args: ['-y', '-p', PACKAGE_NAME, 'msd-mcp-server'],
|
||
};
|
||
|
||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
||
console.log(` ${green}✓${reset} Configured Antigravity MCP companion server (msd)`);
|
||
}
|
||
|
||
/**
|
||
* Verify a directory exists and contains files
|
||
*/
|
||
function verifyInstalled(dirPath, description) {
|
||
if (!fs.existsSync(dirPath)) {
|
||
console.error(` ${yellow}✗${reset} Failed to install ${description}: directory not created`);
|
||
return false;
|
||
}
|
||
try {
|
||
const entries = fs.readdirSync(dirPath);
|
||
if (entries.length === 0) {
|
||
console.error(` ${yellow}✗${reset} Failed to install ${description}: directory is empty`);
|
||
return false;
|
||
}
|
||
} catch (e) {
|
||
console.error(` ${yellow}✗${reset} Failed to install ${description}: ${e.message}`);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Verify a file exists
|
||
*/
|
||
function verifyFileInstalled(filePath, description) {
|
||
if (!fs.existsSync(filePath)) {
|
||
console.error(` ${yellow}✗${reset} Failed to install ${description}: file not created`);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Install to the specified directory for a specific runtime
|
||
* @param {boolean} isGlobal - Whether to install globally or locally
|
||
* @param {string} runtime - Target runtime ('claude', 'opencode', 'codex')
|
||
*/
|
||
|
||
// ──────────────────────────────────────────────────────
|
||
// Local Patch Persistence
|
||
// ──────────────────────────────────────────────────────
|
||
|
||
const PATCHES_DIR_NAME = 'msd-local-patches';
|
||
const MANIFEST_NAME = 'msd-file-manifest.json';
|
||
|
||
/**
|
||
* Compute SHA256 hash of file contents
|
||
*/
|
||
function fileHash(filePath) {
|
||
const content = fs.readFileSync(filePath);
|
||
return crypto.createHash('sha256').update(content).digest('hex');
|
||
}
|
||
|
||
/**
|
||
* Recursively collect all files in dir with their hashes
|
||
*/
|
||
function generateManifest(dir, baseDir) {
|
||
if (!baseDir) baseDir = dir;
|
||
const manifest = {};
|
||
if (!fs.existsSync(dir)) return manifest;
|
||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(dir, entry.name);
|
||
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
|
||
if (entry.isDirectory()) {
|
||
Object.assign(manifest, generateManifest(fullPath, baseDir));
|
||
} else {
|
||
manifest[relPath] = fileHash(fullPath);
|
||
}
|
||
}
|
||
return manifest;
|
||
}
|
||
|
||
function normalizeInstallRelativePath(relPath) {
|
||
if (typeof relPath !== 'string' || relPath.trim() === '' || relPath.includes('\0')) {
|
||
return null;
|
||
}
|
||
if (path.isAbsolute(relPath) || path.win32.isAbsolute(relPath)) {
|
||
return null;
|
||
}
|
||
const normalized = relPath.replace(/\\/g, '/');
|
||
const segments = normalized.split('/');
|
||
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
||
return null;
|
||
}
|
||
return segments.join('/');
|
||
}
|
||
|
||
function resolveInstallRelativePath(baseDir, relPath) {
|
||
const normalized = normalizeInstallRelativePath(relPath);
|
||
if (!normalized) return null;
|
||
const root = path.resolve(baseDir);
|
||
const fullPath = path.resolve(root, normalized);
|
||
if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
|
||
return null;
|
||
}
|
||
if (hasExistingSymlinkBetween(root, fullPath, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
||
return null;
|
||
}
|
||
return { relPath: normalized, fullPath };
|
||
}
|
||
|
||
// hasExistingSymlinkBetween: moved to src/install-engine.cts (ADR-1239 Phase B).
|
||
// Imported from installEngine above.
|
||
|
||
/**
|
||
* Write file manifest after installation for future modification detection
|
||
*/
|
||
function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
|
||
const { isOpencode, isCodex, isCursor } = runtimeFlags(runtime);
|
||
const msdDir = path.join(configDir, 'msd-core');
|
||
// #1367: Claude local now writes flat msd-*.md files at commands/ (not commands/msd/).
|
||
// Claude local uses flatCommandsDir instead for manifest recording.
|
||
const flatCommandsDir = path.join(configDir, 'commands');
|
||
const opencodeCommandDir = path.join(configDir, _hostBehaviors(runtime).flatCommandDir || 'command');
|
||
// ADR-1239 upgrade 3 (#2088): honor a skills-kind `home` override (e.g. Codex
|
||
// skills -> $HOME/.agents/skills instead of configDir/skills) via the same
|
||
// descriptor-driven helper used by the snapshot/rollback/verification paths,
|
||
// so the manifest records what's actually on disk. _resolveSkillsRootDir already
|
||
// resolves destSubpath — do not re-append anything.
|
||
// #2872 (ADR-2866 Phase 3): the scope used to pick the skills root and the
|
||
// scope RECORDED in the manifest are one value, resolved once. Two reads of
|
||
// `options.scope` could drift; one cannot.
|
||
const resolvedScope = options.scope === 'local' ? 'local' : 'global';
|
||
const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, resolvedScope);
|
||
// #3738: resolve the ACTUAL agents-install dir honoring an agents-kind `home`
|
||
// override (antigravity global → $HOME/.gemini/config/agents), mirroring
|
||
// _resolveSkillsRootDir for skills. Hardcoding configDir/agents left the
|
||
// manifest blind to the whole agents surface the moment the override landed —
|
||
// no drift detection, no patch backup. Falls back to <configDir>/agents.
|
||
const agentsDir = _kindDestDirSafe(runtime, configDir, resolvedScope, 'agents')
|
||
|| path.join(configDir, 'agents');
|
||
const manifest = {
|
||
// Schema version of this DOCUMENT (#2872) — distinct from `version`
|
||
// below, which is the MSD package version. Absent ⇒ a pre-#2872 (v1)
|
||
// manifest, which readInstallManifest still reads without error and
|
||
// without requiring a reinstall. Read from the Installer Migration
|
||
// Module rather than repeated as a second literal: the writer here and
|
||
// the reader's normalizeManifestVersion are two surfaces over one
|
||
// constant, and this repo's "generative fix divergence" class is exactly
|
||
// two such literals drifting apart.
|
||
manifestVersion: MANIFEST_SCHEMA_VERSION,
|
||
version: pkg.version,
|
||
timestamp: new Date().toISOString(),
|
||
mode: options.mode === 'minimal' ? 'minimal' : 'full',
|
||
// Recorded so an Installed Surface Resolver can answer "which surfaces
|
||
// are installed, at which scopes, for which runtimes" without re-deriving
|
||
// it from the directory it happened to be found in (#2872).
|
||
runtime,
|
||
scope: resolvedScope,
|
||
// #4377: a surface re-apply is a separate process and cannot rely on the
|
||
// installer's environment. Persist only a safe project-relative prefix.
|
||
relativeIncludePrefix: resolvedScope === 'local' && hasRelativeIncludes
|
||
? runtimeArtifactConversion._projectRelativePrefixFromProjectRoot(process.cwd(), configDir)
|
||
: undefined,
|
||
files: {},
|
||
};
|
||
|
||
const msdHashes = generateManifest(msdDir);
|
||
for (const [rel, hash] of Object.entries(msdHashes)) {
|
||
// Skip user-owned artifacts (e.g. USER-PROFILE.md). They are staged
|
||
// durably and restored across reinstalls (user-artifact-staging.cts,
|
||
// #2875) and must NOT be hashed into the manifest — otherwise
|
||
// saveLocalPatches() would flag every refresh as a "local patch"
|
||
// (bug #2771). Single source of truth: USER_OWNED_ARTIFACTS at top of file.
|
||
if (USER_OWNED_ARTIFACTS.includes(rel)) continue;
|
||
manifest.files['msd-core/' + rel] = hash;
|
||
}
|
||
// Record commands surface for runtimes that emit it:
|
||
// Claude local (#1367 fix): flat msd-<cmd>.md at commands/ level
|
||
// Manifest must reflect everything on disk so saveLocalPatches() can detect
|
||
// user edits and per-runtime minimal-mode assertions can read manifest.files.
|
||
// Claude local (#1367): flat msd-*.md files at commands/ level.
|
||
// Only claude local writes msd-*.md here; global installs don't emit commands,
|
||
// so this branch is a no-op for global (no matching files to find).
|
||
if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && fs.existsSync(flatCommandsDir)) {
|
||
for (const file of fs.readdirSync(flatCommandsDir)) {
|
||
if (file.startsWith('msd-') && file.endsWith('.md')) {
|
||
manifest.files['commands/' + file] = fileHash(path.join(flatCommandsDir, file));
|
||
}
|
||
}
|
||
}
|
||
if (_hostBehaviors(runtime).flatCommandDir && fs.existsSync(opencodeCommandDir)) {
|
||
// #2329: derive the manifest key prefix from the SAME descriptor value used
|
||
// to compute opencodeCommandDir above, instead of a separately-hardcoded
|
||
// literal — a divergence here would silently break the manifest even after
|
||
// the destSubpath descriptor is corrected (Generative Fix Divergence guard).
|
||
const flatCommandDirPrefix = _hostBehaviors(runtime).flatCommandDir || 'command';
|
||
for (const file of fs.readdirSync(opencodeCommandDir)) {
|
||
if (file.startsWith('msd-') && file.endsWith('.md')) {
|
||
manifest.files[flatCommandDirPrefix + '/' + file] = fileHash(path.join(opencodeCommandDir, file));
|
||
}
|
||
}
|
||
}
|
||
if (fs.existsSync(codexSkillsDir)) {
|
||
// All runtimes use the canonical 'msd-' prefix.
|
||
const skillListPrefix = 'msd-';
|
||
for (const skillName of listCodexSkillNames(codexSkillsDir, skillListPrefix)) {
|
||
const skillRoot = path.join(codexSkillsDir, skillName);
|
||
const skillHashes = generateManifest(skillRoot);
|
||
for (const [rel, hash] of Object.entries(skillHashes)) {
|
||
manifest.files[`skills/${skillName}/${rel}`] = hash;
|
||
}
|
||
}
|
||
}
|
||
if (fs.existsSync(agentsDir)) {
|
||
for (const file of fs.readdirSync(agentsDir)) {
|
||
if (file.startsWith('msd-') && (file.endsWith('.md') || file.endsWith('.toml'))) {
|
||
manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file));
|
||
}
|
||
}
|
||
}
|
||
// Track hook files so saveLocalPatches() can detect user modifications
|
||
// Hooks are only installed for runtimes that use settings.json (not Codex).
|
||
// Descriptor-driven (ADR-1239 / #2089): exclusions via
|
||
// hostBehaviors.skipSharedHooksInstall.
|
||
if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true) {
|
||
// #3023: manifest keys must track the bundle wherever the descriptor put it,
|
||
// or uninstall/saveLocalPatches silently orphan the tree.
|
||
const hooksDir = path.join(configDir, SHARED_HOOKS_DIR);
|
||
if (fs.existsSync(hooksDir)) {
|
||
// Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
|
||
// scripts/build-hooks.js) rather than a prefix/extension regex, so the
|
||
// manifest set is structurally identical to the build set. The old regex
|
||
// `file.startsWith('msd-') && (file.endsWith('.js') || file.endsWith('.sh'))`
|
||
// missed managed-hooks-registry.cjs (wrong prefix, .cjs extension), causing
|
||
// detect-custom-files to flag it as a perpetual false-positive custom file
|
||
// on every /msd-update. See #941.
|
||
for (const hook of INSTALLED_HOOK_FILES) {
|
||
const hookPath = path.join(hooksDir, hook);
|
||
if (fs.existsSync(hookPath)) {
|
||
manifest.files[SHARED_HOOKS_DIR + '/' + hook] = fileHash(hookPath);
|
||
}
|
||
}
|
||
// Track hooks/lib/ helpers so saveLocalPatches() can back up user edits
|
||
// to git-cmd.js (validate-commit classifier) and msd-graphify-rebuild.sh.
|
||
const hooksLibDir = path.join(hooksDir, 'lib');
|
||
if (fs.existsSync(hooksLibDir)) {
|
||
for (const file of fs.readdirSync(hooksLibDir)) {
|
||
if (MSD_HOOK_LIB_FILES.includes(file)) {
|
||
manifest.files[SHARED_HOOKS_DIR + '/lib/' + file] = fileHash(path.join(hooksLibDir, file));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Track scripts/changeset/ and scripts/lib/ so saveLocalPatches() can detect drift
|
||
const changesetInstallDir = path.join(configDir, 'scripts', 'changeset');
|
||
if (fs.existsSync(changesetInstallDir)) {
|
||
for (const file of fs.readdirSync(changesetInstallDir)) {
|
||
if (file.endsWith('.cjs')) {
|
||
manifest.files['scripts/changeset/' + file] = fileHash(path.join(changesetInstallDir, file));
|
||
}
|
||
}
|
||
}
|
||
const scriptsLibInstallDir = path.join(configDir, 'scripts', 'lib');
|
||
if (fs.existsSync(scriptsLibInstallDir)) {
|
||
for (const file of fs.readdirSync(scriptsLibInstallDir)) {
|
||
if (file.endsWith('.cjs')) {
|
||
manifest.files['scripts/lib/' + file] = fileHash(path.join(scriptsLibInstallDir, file));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Track scripts/fix-slash-commands.cjs (top-level scripts/ file, not covered by changeset/lib loops)
|
||
const fixSlashInstallPath = path.join(configDir, 'scripts', 'fix-slash-commands.cjs');
|
||
if (fs.existsSync(fixSlashInstallPath)) {
|
||
manifest.files['scripts/fix-slash-commands.cjs'] = fileHash(fixSlashInstallPath);
|
||
}
|
||
|
||
// Track the capability registry generator scripts (#1920) — top-level scripts/ files
|
||
// not covered by the changeset/lib loops.
|
||
for (const gen of ['gen-capability-registry.cjs', 'gen-loop-host-contract.cjs']) {
|
||
const genInstallPath = path.join(configDir, 'scripts', gen);
|
||
if (fs.existsSync(genInstallPath)) {
|
||
manifest.files['scripts/' + gen] = fileHash(genInstallPath);
|
||
}
|
||
}
|
||
|
||
// Track the OpenCode native plugin adapter (#1914) so update/drift detection
|
||
// and uninstall can account for it.
|
||
const _npM = _hostBehaviors(runtime).nativePlugin;
|
||
if (_npM) {
|
||
const pluginInstallPath = path.join(configDir, _npM.dir, _npM.file);
|
||
if (fs.existsSync(pluginInstallPath)) {
|
||
manifest.files[`${_npM.dir}/${_npM.file}`] = fileHash(pluginInstallPath);
|
||
}
|
||
}
|
||
|
||
fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
|
||
return manifest;
|
||
}
|
||
|
||
/**
|
||
* Populate msd-pristine/ with the transformed pristine versions of every
|
||
* `modified` file, derived from the current package's source tree by
|
||
* running the install transform pipeline (`copyWithPathReplacement`)
|
||
* into a tmp directory, then copying out only the relevant paths.
|
||
*
|
||
* Pristine semantically represents "what the install would write to
|
||
* configDir/<relPath> if the user had not modified it." This is what the
|
||
* /msd-reapply-patches Step 5 verifier (#2972) uses as the diff base
|
||
* for "user-added lines" — lines in the user's backup that are NOT in
|
||
* the pristine baseline. Without this dir, the verifier degrades to its
|
||
* over-broad fallback ("every significant backup line"), exactly the
|
||
* silent-success-on-lost-content failure mode #2969 was designed to
|
||
* prevent (#2998).
|
||
*
|
||
* Implementation note: we run the FULL transform pipeline against a tmp
|
||
* staging dir (one-time, only when modified.length > 0), then copy out
|
||
* just the modified paths. This re-uses the existing transform code
|
||
* exactly — pristine is byte-identical to what `copyWithPathReplacement`
|
||
* would have written under normal install. Cost: one extra full transform
|
||
* pass per install where local patches were detected; acceptable.
|
||
*/
|
||
function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathPrefix, isGlobal }) {
|
||
if (!modified || modified.length === 0) return 0;
|
||
// Modified paths come from manifest.files which can live under several
|
||
// install roots: msd-core/, commands/msd/, command/, skills/, agents/,
|
||
// hooks/, plus runtime-specific root files (#3004 CR). Stage every
|
||
// top-level dir that actually contains a modified path; root-level files
|
||
// are copied directly without the transform pipeline (they don't need
|
||
// path replacement).
|
||
const stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-pristine-stage-'));
|
||
let written = 0;
|
||
try {
|
||
const topLevels = new Set();
|
||
const safeModified = [];
|
||
for (const relPath of modified) {
|
||
const norm = normalizeInstallRelativePath(relPath);
|
||
if (!norm) continue;
|
||
safeModified.push(norm);
|
||
const slash = norm.indexOf('/');
|
||
topLevels.add(slash === -1 ? '' : norm.slice(0, slash));
|
||
}
|
||
|
||
for (const top of topLevels) {
|
||
if (top === '') {
|
||
// Root-level files — copy directly from package source. The transform
|
||
// pipeline is directory-oriented; root files don't need path-prefix
|
||
// substitution (they're not markdown content with embedded paths).
|
||
for (const relPath of safeModified) {
|
||
const norm = normalizeInstallRelativePath(relPath);
|
||
if (!norm) continue;
|
||
if (norm.includes('/')) continue;
|
||
const srcRef = resolveInstallRelativePath(packageSrc, norm);
|
||
const stagedRef = resolveInstallRelativePath(stageRoot, norm);
|
||
if (!srcRef || !stagedRef || !fs.existsSync(srcRef.fullPath)) continue;
|
||
const stagedFile = stagedRef.fullPath;
|
||
fs.mkdirSync(path.dirname(stagedFile), { recursive: true });
|
||
fs.copyFileSync(srcRef.fullPath, stagedFile);
|
||
}
|
||
continue;
|
||
}
|
||
const srcDir = path.join(packageSrc, top);
|
||
const stageDir = path.join(stageRoot, top);
|
||
if (!fs.existsSync(srcDir)) continue;
|
||
copyWithPathReplacement(srcDir, stageDir, pathPrefix, runtime, false, isGlobal, stageRoot);
|
||
}
|
||
|
||
for (const relPath of safeModified) {
|
||
// Only populate pristine for paths we successfully staged. If a path's
|
||
// source dir does not exist (obsolete manifest entry), skip silently
|
||
// rather than corrupting pristine with stale data.
|
||
const stagedRef = resolveInstallRelativePath(stageRoot, relPath);
|
||
const outRef = resolveInstallRelativePath(pristineDir, relPath);
|
||
if (!stagedRef || !outRef || !fs.existsSync(stagedRef.fullPath)) continue;
|
||
fs.mkdirSync(path.dirname(outRef.fullPath), { recursive: true });
|
||
fs.copyFileSync(stagedRef.fullPath, outRef.fullPath);
|
||
written++;
|
||
}
|
||
} finally {
|
||
try { fs.rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
||
}
|
||
return written;
|
||
}
|
||
|
||
/**
|
||
* #4145: recover a pristine baseline from a hash-matching orphan stored at an
|
||
* unexpected path under msd-pristine/ (e.g. without the msd-core/ prefix an
|
||
* earlier release's writer dropped).
|
||
*
|
||
* The preserve-check's strict join (pristineDir + manifest-keyed relPath)
|
||
* misses such snapshots, so they were pushed into regeneration from the
|
||
* incoming release — and when the file changed upstream, the candidate's hash
|
||
* could never satisfy the recorded outgoing hash, leaving the correct
|
||
* baseline permanently unconsumed and unpruned (the self-perpetuating state
|
||
* #4145 reports). Hash equality with pristine_hashes is the same authority
|
||
* the #3657 drift guard trusts, so an exact match cannot be the wrong
|
||
* baseline no matter where under msd-pristine/ it lives.
|
||
*
|
||
* Recovery = relocation: copy the orphan to the canonical manifest-keyed path
|
||
* (hash-verified after the copy) and remove the orphan only once the
|
||
* canonical copy is verified in place. Returns true when the canonical path
|
||
* ended up holding recorded-hash bytes. Never deletes anything it cannot
|
||
* vouch for by hash, and never consumes a path that is the canonical path of
|
||
* ANY manifest file (see canonicalSkip below) — only genuine orphans, which
|
||
* no strict-join reader ever consults, are eligible for removal.
|
||
*/
|
||
function recoverOrphanedPristine(pristineDir, relPath, recordedHash, canonicalSkip) {
|
||
if (!recordedHash) return false;
|
||
let orphanRel;
|
||
try {
|
||
// canonicalSkip = the normalized manifest keys: a file already sitting at
|
||
// any canonical path can never be (re-)adopted through the scan. Without
|
||
// this, two modified files sharing byte-identical outgoing content would
|
||
// repeatedly "rescue" each other's canonical away (relocate + delete at
|
||
// its home path) in alternating updates — bytes identical, state unstable.
|
||
// It also keeps drift (#3657) / stale (#3407) territory with the caller.
|
||
orphanRel = msdFindPristineByHash(pristineDir, recordedHash, canonicalSkip);
|
||
} catch {
|
||
return false;
|
||
}
|
||
if (!orphanRel) return false;
|
||
const outRef = resolveInstallRelativePath(pristineDir, relPath);
|
||
if (!outRef) return false;
|
||
try {
|
||
fs.mkdirSync(path.dirname(outRef.fullPath), { recursive: true });
|
||
fs.copyFileSync(path.join(pristineDir, orphanRel), outRef.fullPath);
|
||
// Verify the relocated copy before removing the orphan — only a
|
||
// hash-matching canonical counts as recovered.
|
||
if (fileHash(outRef.fullPath) !== recordedHash) {
|
||
try { fs.rmSync(outRef.fullPath, { force: true }); } catch { /* best-effort */ }
|
||
return false;
|
||
}
|
||
// Orphan removal is best-effort: the canonical copy is already verified,
|
||
// so a failed unlink leaves a harmless duplicate, never data loss.
|
||
try { fs.rmSync(path.join(pristineDir, orphanRel), { force: true }); } catch { /* best-effort */ }
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* #4135: honest N-of-M accounting for msd-pristine/ baselines after an
|
||
* update. The #3407 promotion rule keeps only hash-validated candidates
|
||
* (byte-identical across the version span), so a multi-version update
|
||
* legitimately ends with near-zero baselines — the collapse itself is NOT a
|
||
* bug to hide; hiding it is. This renders the covered-of-total line the
|
||
* update output prints either way, so "1 of 13" can never present like a
|
||
* fully-covered run. Pure function (typed return) so tests lock the exact
|
||
* contract without matching console prose.
|
||
*/
|
||
function describeBaselineCoverage(totalModified, covered) {
|
||
const total = Math.max(0, totalModified);
|
||
const have = Math.min(Math.max(0, covered), total);
|
||
const uncovered = total - have;
|
||
return {
|
||
complete: uncovered === 0,
|
||
uncovered,
|
||
text: uncovered === 0
|
||
? `msd-pristine/ baselines cover ${have} of ${total} modified file(s)`
|
||
: `msd-pristine/ baselines cover ${have} of ${total} modified file(s) — ${uncovered} will be reported no_baseline by the reapply verifier`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Detect user-modified MSD files by comparing against install manifest.
|
||
* Backs up modified files to msd-local-patches/ for reapply after update.
|
||
* Also saves pristine copies (from manifest) to msd-pristine/ to enable
|
||
* three-way merge during reapply-patches (pristine vs user vs new).
|
||
*
|
||
* The optional `pristineCtx` parameter (set by the install entry point)
|
||
* carries the source package root, runtime, pathPrefix, and isGlobal
|
||
* needed to populate msd-pristine/. If omitted (legacy callers), pristine
|
||
* stays empty — the verifier falls back to its over-broad heuristic, same
|
||
* behavior as before #2998.
|
||
*/
|
||
function saveLocalPatches(configDir, pristineCtx) {
|
||
const manifestPath = path.join(configDir, MANIFEST_NAME);
|
||
if (!fs.existsSync(manifestPath)) return [];
|
||
|
||
let manifest;
|
||
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return []; }
|
||
|
||
// Normalize legacy manifests written before #2771 fix: strip user-owned artifacts
|
||
// that were incorrectly recorded so refreshes don't surface false patches warnings.
|
||
if (manifest.files) {
|
||
for (const artifact of USER_OWNED_ARTIFACTS) {
|
||
delete manifest.files[`msd-core/${artifact}`];
|
||
}
|
||
}
|
||
|
||
const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
|
||
const pristineDir = path.join(configDir, 'msd-pristine');
|
||
const modified = [];
|
||
const pristineHashes = {};
|
||
|
||
// #4086: skills/ manifest keys may live OUTSIDE configDir at the runtime's
|
||
// ACTUAL skills root — codex global installs to $HOME/.agents/skills (the
|
||
// ADR-1239 skills-kind `home` override), which writeManifest() already
|
||
// hashes from via _resolveSkillsRootDir (#2088/#3738). Resolving every key
|
||
// against configDir alone made every skills/ key miss here, so user
|
||
// modifications to Codex skills were never hash-compared, never backed up,
|
||
// and were silently overwritten by the next update. configDir stays FIRST
|
||
// (Postel: runtimes whose skills genuinely live under configDir resolve
|
||
// byte-identically to before); the skills root is a fallback, only for keys
|
||
// under the SAME descriptor-driven manifest prefix writeManifest uses, and
|
||
// only when that root resolves outside configDir. Containment + symlink
|
||
// guards apply to the alternate root too (resolveInstallRelativePath).
|
||
const patchRuntime = (pristineCtx && pristineCtx.runtime) || manifest.runtime || null;
|
||
const patchScope = manifest.scope === 'local' ? 'local' : 'global';
|
||
let skillsRedirect = null;
|
||
if (patchRuntime) {
|
||
const skillsRoot = _resolveSkillsRootDir(patchRuntime, configDir, patchScope);
|
||
const resolvedConfig = path.resolve(configDir);
|
||
if (
|
||
skillsRoot &&
|
||
skillsRoot !== resolvedConfig &&
|
||
!skillsRoot.startsWith(resolvedConfig + path.sep)
|
||
) {
|
||
skillsRedirect = { root: skillsRoot, prefix: 'skills/' };
|
||
}
|
||
}
|
||
|
||
for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
|
||
const safeRef = resolveInstallRelativePath(configDir, relPath);
|
||
if (!safeRef) continue;
|
||
const { relPath: safeRelPath, fullPath } = safeRef;
|
||
let installedPath = fullPath;
|
||
if (!fs.existsSync(installedPath) && skillsRedirect && safeRelPath.startsWith(skillsRedirect.prefix)) {
|
||
const altRef = resolveInstallRelativePath(
|
||
skillsRedirect.root,
|
||
safeRelPath.slice(skillsRedirect.prefix.length)
|
||
);
|
||
if (altRef && fs.existsSync(altRef.fullPath)) {
|
||
installedPath = altRef.fullPath;
|
||
}
|
||
}
|
||
if (!fs.existsSync(installedPath)) continue;
|
||
const currentHash = fileHash(installedPath);
|
||
if (currentHash !== originalHash) {
|
||
// Back up the user's modified version
|
||
const backupRef = resolveInstallRelativePath(patchesDir, safeRelPath);
|
||
if (!backupRef) continue;
|
||
const backupPath = backupRef.fullPath;
|
||
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
||
fs.copyFileSync(installedPath, backupPath);
|
||
modified.push(safeRelPath);
|
||
pristineHashes[safeRelPath] = originalHash;
|
||
}
|
||
}
|
||
|
||
// Save pristine copies of modified files from the CURRENT install (before wipe).
|
||
// Pristine semantically represents "what the install would write to configDir
|
||
// if the user had not modified it" — used by /msd-reapply-patches Step 5
|
||
// (#2972) as the diff baseline for the user-added-lines computation. Without
|
||
// this dir the verifier degrades to its over-broad fallback heuristic (#2998).
|
||
if (modified.length > 0) {
|
||
const meta = {
|
||
backed_up_at: new Date().toISOString(),
|
||
from_version: manifest.version,
|
||
from_manifest_timestamp: manifest.timestamp,
|
||
files: modified,
|
||
pristine_hashes: {}
|
||
};
|
||
// Record the original (pristine) hash for each modified file
|
||
// This lets the reapply workflow verify reconstructed pristine files
|
||
for (const relPath of modified) {
|
||
meta.pristine_hashes[relPath] = pristineHashes[relPath];
|
||
}
|
||
fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
|
||
console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified MSD file(s) — backed up to ' + PATCHES_DIR_NAME + '/');
|
||
for (const f of modified) {
|
||
console.log(' ' + dim + f + reset);
|
||
}
|
||
|
||
// #2998 / #3407: maintain msd-pristine/ as the diff baseline for the
|
||
// reapply-patches verifier (#2972).
|
||
//
|
||
// #3407 root-cause fix: the prior approach (#3004 CR) wiped msd-pristine/
|
||
// and re-populated it from pristineCtx.packageSrc (the NEW release source).
|
||
// For files that changed between the old and new release this wrote NEW-
|
||
// release bytes as the pristine baseline while backup-meta.json recorded
|
||
// OLD-release hashes — a hash mismatch that caused the #3657 verifier guard
|
||
// (OK_PRISTINE_DRIFT_DETECTED) to skip the baseline and fall back to over-
|
||
// broad mode on every upgrade.
|
||
//
|
||
// Correct approach: `msd-pristine/` is populated lazily by saveLocalPatches'
|
||
// regenerate branch (not by a separate install-time step); the fix works by
|
||
// induction across upgrades — each clean upgrade persists hash-validated
|
||
// entries for the next run. During this call we must PRESERVE entries whose
|
||
// hash matches originalHash, not overwrite them with new-release bytes.
|
||
//
|
||
// Per-file decision:
|
||
// - sha256(msd-pristine/X) === originalHash → correct; keep it
|
||
// - msd-pristine/X exists but hash mismatch → stale from a previous
|
||
// buggy run (#3407); remove so verifier falls back cleanly
|
||
// - msd-pristine/X absent → attempt hash-validated
|
||
// regeneration: generate candidate from new-release source; if
|
||
// sha256(candidate) === originalHash the file is identical between
|
||
// old and new releases so candidate bytes ARE the old-release pristine
|
||
// and can be used; discard otherwise (over-broad fallback)
|
||
if (pristineCtx) {
|
||
let preserved = 0;
|
||
// Track which relPaths had stale pristine entries (hash mismatch) that we
|
||
// removed. After the regeneration pass we compute `removed` = stale entries
|
||
// that could NOT be recovered (over-broad fallback applies to those only).
|
||
const stalePaths = new Set();
|
||
// Track which relPaths were successfully regenerated (from either missing or stale).
|
||
const regeneratedPaths = new Set();
|
||
// #4145: track which relPaths were recovered by relocating a hash-matching
|
||
// orphan (stored at an unexpected path, e.g. without the msd-core/ prefix).
|
||
const rescuedPaths = new Set();
|
||
// #4145: the set of paths that are SOME file's canonical pristine path
|
||
// (every normalized manifest key). The orphan scan must never consume
|
||
// these — see recoverOrphanedPristine.
|
||
const canonicalSkip = new Set(
|
||
Object.keys(manifest.files || {}).map((k) => normalizeInstallRelativePath(k)).filter(Boolean),
|
||
);
|
||
const missingPaths = [];
|
||
for (const relPath of modified) {
|
||
const outRef = resolveInstallRelativePath(pristineDir, relPath);
|
||
if (!outRef) continue;
|
||
const { fullPath: pristinePath } = outRef;
|
||
if (fs.existsSync(pristinePath)) {
|
||
try {
|
||
const onDiskHash = fileHash(pristinePath);
|
||
if (onDiskHash === pristineHashes[relPath]) {
|
||
preserved++;
|
||
continue; // correct old-release bytes already in place — keep them
|
||
}
|
||
} catch { /* read error — treat as mismatch */ }
|
||
// Hash mismatch or read error: stale pristine from a previous buggy
|
||
// run (#3407). Remove so verifier falls back to over-broad mode.
|
||
try { fs.rmSync(pristinePath, { force: true, recursive: true }); } catch { /* best-effort */ }
|
||
// Only count as removed if the file is actually gone post-removal.
|
||
if (!fs.existsSync(pristinePath)) {
|
||
stalePaths.add(relPath);
|
||
}
|
||
}
|
||
// #4145: canonical absent (or just removed as stale) — before falling
|
||
// into regeneration, try to recover the baseline from a hash-matching
|
||
// orphan elsewhere under msd-pristine/ and relocate it to the canonical
|
||
// path. This is the self-heal for snapshots an earlier release stored
|
||
// without the msd-core/ prefix: without it the state repeats forever
|
||
// (regeneration candidates from the incoming release can never satisfy
|
||
// the recorded outgoing hash when upstream changed the file).
|
||
if (recoverOrphanedPristine(pristineDir, relPath, pristineHashes[relPath], canonicalSkip)) {
|
||
rescuedPaths.add(relPath);
|
||
continue;
|
||
}
|
||
// File absent from msd-pristine/ (or just removed above as stale):
|
||
// attempt hash-validated regeneration from new-release source.
|
||
missingPaths.push(relPath);
|
||
}
|
||
// Regenerate missing entries into a temp dir, then validate each hash
|
||
// before promoting. Only files whose new-release generated bytes hash to
|
||
// originalHash are safe to use — they were unchanged between releases.
|
||
if (missingPaths.length > 0) {
|
||
let tempPristineDir = null;
|
||
try {
|
||
tempPristineDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-pristine-regen-'));
|
||
populatePristineDir({
|
||
packageSrc: pristineCtx.packageSrc,
|
||
pristineDir: tempPristineDir,
|
||
modified: missingPaths,
|
||
runtime: pristineCtx.runtime,
|
||
pathPrefix: pristineCtx.pathPrefix,
|
||
isGlobal: pristineCtx.isGlobal,
|
||
});
|
||
for (const relPath of missingPaths) {
|
||
const tempRef = resolveInstallRelativePath(tempPristineDir, relPath);
|
||
const outRef = resolveInstallRelativePath(pristineDir, relPath);
|
||
if (!tempRef || !outRef || !fs.existsSync(tempRef.fullPath)) continue;
|
||
try {
|
||
const candidateHash = fileHash(tempRef.fullPath);
|
||
if (candidateHash !== pristineHashes[relPath]) continue; // new-release differs — discard
|
||
fs.mkdirSync(path.dirname(outRef.fullPath), { recursive: true });
|
||
fs.copyFileSync(tempRef.fullPath, outRef.fullPath);
|
||
regeneratedPaths.add(relPath);
|
||
} catch { /* hash or copy error — skip; over-broad fallback applies */ }
|
||
}
|
||
} catch (err) {
|
||
// Match the pre-fix behavior: log a warning and continue (verifier falls back to over-broad mode for missing files).
|
||
console.warn(`msd-pristine regen skipped: ${err.message}`);
|
||
} finally {
|
||
if (tempPristineDir) {
|
||
try { fs.rmSync(tempPristineDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||
}
|
||
}
|
||
}
|
||
// `regenerated` = total files successfully regenerated (from missing OR stale).
|
||
const regenerated = regeneratedPaths.size;
|
||
// `rescued` = files recovered by relocating a hash-matching orphan to its
|
||
// canonical path (#4145) — distinct from preservation (canonical already
|
||
// correct) and regeneration (bytes re-derived from new-release source).
|
||
const rescued = rescuedPaths.size;
|
||
// `removed` = stale entries that were deleted and NOT subsequently regenerated.
|
||
// Entries that were stale-deleted but then successfully regenerated are counted
|
||
// only in `regenerated`; stale-deleted-then-orphan-rescued entries are counted
|
||
// only in `rescued` — the counts are non-overlapping.
|
||
const removed = [...stalePaths].filter(p => !regeneratedPaths.has(p) && !rescuedPaths.has(p)).length;
|
||
if (preserved > 0) {
|
||
console.log(' ' + green + '✓' + reset + ' Preserved ' + cyan + 'msd-pristine/' + reset + ' (' + preserved + ' file(s)) for three-way merge');
|
||
}
|
||
if (rescued > 0) {
|
||
console.log(' ' + green + '✓' + reset + ' Recovered ' + cyan + 'msd-pristine/' + reset + ' (' + rescued + ' file(s)) by recorded hash from a legacy-path snapshot and relocated them (#4145)');
|
||
}
|
||
if (regenerated > 0) {
|
||
console.log(' ' + green + '✓' + reset + ' Regenerated ' + cyan + 'msd-pristine/' + reset + ' (' + regenerated + ' file(s)) via hash-validated new-release source');
|
||
}
|
||
if (removed > 0) {
|
||
console.log(' ' + yellow + 'i' + reset + ' Removed ' + removed + ' stale msd-pristine/ snapshot(s); regenerated ' + regenerated + ' of those — falls back to over-broad verify heuristic for the rest');
|
||
}
|
||
// #4135: the honest N-of-M coverage line. Preserved/rescued/regenerated
|
||
// are disjoint buckets (see their accounting comments above), so their
|
||
// sum is exactly the files that ended this update with a hash-valid
|
||
// baseline. A partial result renders as an info line, not an error:
|
||
// the collapse is legitimate (#3407), hiding it was the bug.
|
||
const coverage = describeBaselineCoverage(modified.length, preserved + rescued + regenerated);
|
||
if (coverage.complete) {
|
||
console.log(' ' + green + '✓' + reset + ' ' + coverage.text);
|
||
} else {
|
||
console.log(' ' + yellow + 'i' + reset + ' ' + coverage.text);
|
||
}
|
||
}
|
||
}
|
||
return modified;
|
||
}
|
||
|
||
/**
|
||
* After install, report backed-up patches for user to reapply.
|
||
*/
|
||
function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) {
|
||
const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
|
||
const metaPath = path.join(patchesDir, 'backup-meta.json');
|
||
if (!fs.existsSync(metaPath)) return [];
|
||
|
||
let meta;
|
||
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
|
||
|
||
if (meta.files && meta.files.length > 0) {
|
||
const reapplyCommand = _hostBehaviors(runtime).reapplyCommand || '/msd-update --reapply';
|
||
console.log('');
|
||
console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
|
||
for (const f of meta.files) {
|
||
console.log(' ' + cyan + f + reset);
|
||
}
|
||
console.log('');
|
||
console.log(' Your modifications are saved in ' + cyan + PATCHES_DIR_NAME + '/' + reset);
|
||
console.log(' Run ' + cyan + reapplyCommand + reset + ' to merge them into the new version.');
|
||
console.log(' Or manually compare and merge the files.');
|
||
console.log('');
|
||
}
|
||
return meta.files || [];
|
||
}
|
||
|
||
function reportInstallerMigrationResult(result) {
|
||
const summary = summarizeInstallerMigrationResult(result);
|
||
if (!summary.hasReportableActions) return;
|
||
|
||
console.log(` ${green}✓${reset} Installer migrations`);
|
||
for (const row of summary.rows) {
|
||
const reason = row.reason ? ` — ${row.reason}` : '';
|
||
console.log(` ${row.label} ${dim}${row.relPath}${reset}${reason}`);
|
||
}
|
||
}
|
||
|
||
function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||
const { isOpencode, isCodex, isCursor } = runtimeFlags(runtime);
|
||
const plan = resolveInstallPlan(runtime);
|
||
const dirName = getDirName(runtime);
|
||
const src = path.join(__dirname, '..');
|
||
|
||
// #3241 — the Codex resolver-model-omitted notice dedupes "at most once", but
|
||
// scoped per install() call rather than per process — each install() run gets
|
||
// its own fresh window so a second install (e.g. a second runtime, or a test
|
||
// re-running install()) can warn again if the same condition recurs.
|
||
_codexResolverModelOmittedWarned = false;
|
||
|
||
// #2870: scope id resolved ONCE here and reused at every use below (was 10
|
||
// independent isGlobal-derived re-derivations). Routed through the Install Scope Module (src/install-scope.cts)
|
||
// when the capability registry is available; degrades to the plain id on
|
||
// failure (unknown/non-installable runtime, broken bundle) so this
|
||
// function's plain scope-id uses — which never depended on registry
|
||
// availability before this migration — keep working exactly as they did
|
||
// pre-migration. `_installScope` (the full resolved value, not just the
|
||
// id) additionally backs the settingsFileByScope routing below.
|
||
const _installScopeId = isGlobal ? 'global' : 'local';
|
||
const _installScope = _resolveScopeSafe(_installScopeId, runtime);
|
||
|
||
// Reusable helper to copy hooks/lib/ (git-cmd.js + msd-graphify-rebuild.sh).
|
||
// Defined early so it is visible to both the main and Codex code paths.
|
||
// `allowlist` (when non-empty) restricts copying to the named top-level entries,
|
||
// keeping install scope aligned with MSD_HOOK_LIB_FILES (which uninstall/manifest manage).
|
||
const copyLibDir = (sDir, dDir, allowlist = []) => {
|
||
const allowed = allowlist.length > 0 ? new Set(allowlist) : null;
|
||
for (const entry of fs.readdirSync(sDir)) {
|
||
if (allowed && !allowed.has(entry)) continue;
|
||
const s = path.join(sDir, entry);
|
||
const d = path.join(dDir, entry);
|
||
let st;
|
||
try { st = fs.lstatSync(s); } catch (_) { continue; }
|
||
if (st.isSymbolicLink()) continue; // defense-in-depth
|
||
if (st.isDirectory()) {
|
||
fs.mkdirSync(d, { recursive: true });
|
||
copyLibDir(s, d);
|
||
} else if (entry.endsWith('.sh')) {
|
||
let content = fs.readFileSync(s, 'utf8');
|
||
content = content.replace(/\{\{MSD_VERSION\}\}/g, pkg.version);
|
||
fs.writeFileSync(d, content);
|
||
try { fs.chmodSync(d, 0o755); } catch (_) { /* Windows */ }
|
||
} else {
|
||
fs.copyFileSync(s, d);
|
||
if (entry.endsWith('.js')) {
|
||
try { fs.chmodSync(d, 0o755); } catch (_) { /* Windows */ }
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Get the target directory based on runtime and install type.
|
||
// #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/
|
||
// directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES
|
||
// but NOT auto-removed here; legacy .agent/ msd artifacts are recognized but not
|
||
// auto-removed on reinstall (dual-read fallback per issue #791 spec).
|
||
const targetDir = isGlobal
|
||
? getGlobalConfigDir(runtime, explicitConfigDir)
|
||
: path.join(process.cwd(), dirName);
|
||
|
||
// #3664: a --config-dir destination holding foreign agent files gets an
|
||
// explicit install-time warning — never a silent Claude-shaped emit.
|
||
if (isGlobal) {
|
||
warnIfForeignAgentDest(runtime, targetDir, _installScopeId, Boolean(explicitConfigDir));
|
||
}
|
||
|
||
// #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user
|
||
// artifact orphaned by a PRIOR install run that died between staging and
|
||
// its own restore/discard, BEFORE this run's own preserve step stages
|
||
// anything new. This is the production entry point every install() call
|
||
// reaches — the only place this phase's durability fix is complete rather
|
||
// than merely callable (40-design.md "The inertness trap this design must
|
||
// avoid" / #1879-F15). Runs for every runtime, ahead of both the
|
||
// layout-driven path's _runLegacyInstallMigrations (site 1, inside
|
||
// installRuntimeArtifacts) and this function's own mainline msd-core copy
|
||
// (site 4, below).
|
||
// #2875 defect fix: DEGRADE, never abort install, when the staging root
|
||
// itself cannot be resolved — skip this recovery pass rather than throw
|
||
// out of install() before it does anything.
|
||
{
|
||
const _installEntryStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
|
||
if (_installEntryStagingRoot !== null) {
|
||
recoverOrphanedUserArtifacts(_installEntryStagingRoot, targetDir);
|
||
}
|
||
}
|
||
|
||
const locationLabel = isGlobal
|
||
? targetDir.replace(os.homedir(), '~')
|
||
: targetDir.replace(process.cwd(), '.');
|
||
|
||
// Path prefix for file references in markdown content (e.g. msd-tools.cjs).
|
||
// Replaces $HOME/.claude/ or ~/.claude/ so the result is <pathPrefix>msd-core/bin/...
|
||
// For global installs: use $HOME/ so paths expand correctly inside double-quoted
|
||
// shell commands (~ does NOT expand inside double quotes, causing MODULE_NOT_FOUND).
|
||
// For local installs: use resolved absolute path (may be outside $HOME).
|
||
// Exception: OpenCode does not expand $HOME in @file references on any platform —
|
||
// `@$HOME/...` is treated as a literal path relative to the config dir, producing
|
||
// `command/$HOME/...` (file not found). Use the absolute path for OpenCode so
|
||
// @-references resolve correctly (#2376 Windows, #2831 macOS/Linux).
|
||
// msd update marker re-application (ADR-0010 Deviation 2):
|
||
// Resolve which profile to use for this runtime's install:
|
||
// 1. --minimal / --core-only → back-compat alias for the core profile
|
||
// 2. Explicit --profile=<name> → use it (overrides any marker)
|
||
// 3. Marker exists in targetDir → honor it (prevents silent expansion on update)
|
||
// 4. Else → 'full' (back-compat for fresh non-interactive installs)
|
||
//
|
||
// Multi-runtime disagreement: if installing across runtimes and their markers
|
||
// differ, the caller may use mostRestrictiveProfile() across the per-runtime
|
||
// results — here we resolve each runtime independently.
|
||
//
|
||
// ADR-857 phase 4c: ALL profiles (including core/minimal) use stageSkillsForProfile
|
||
// with the registry-aware _resolvedProfile so future tier:core capabilities are
|
||
// staged on core installs. The 'minimal' back-compat distinction is now ONLY the
|
||
// empty manifest (core profile has no transitive deps); the registry IS consulted.
|
||
// MINIMAL is intentionally the same skill set as the 'core' profile
|
||
// (MINIMAL_ALLOWLIST_SET === Set(PROFILES.core)) — it is NOT a separately curated
|
||
// subset. Any future tier:core capability therefore DOES belong in a minimal/core
|
||
// install. Using stageSkillsForProfile(_resolvedProfile) honors the registry while
|
||
// keeping the effective skill set identical to the prior stageSkillsForMode path
|
||
// until a tier:core capability is registered.
|
||
const _activeProfileName = hasMinimal
|
||
? 'core' // --minimal is a back-compat alias for the core profile; marker records 'core'
|
||
: resolveEffectiveProfile({
|
||
requestedProfileName: _requestedProfileName,
|
||
targetDir,
|
||
});
|
||
const _isCoreProfileAlias = _activeProfileName === 'core';
|
||
const _effectiveInstallMode = _isCoreProfileAlias ? 'minimal' : 'full';
|
||
// Load the manifest and compute resolved profile for named profiles.
|
||
// For --minimal/core: use an empty manifest (core profile has no transitive
|
||
// deps) to produce a resolvedProfile with the core skill set. For core/
|
||
// standard profiles, resolveProfile's `registry` arg IS consulted (via
|
||
// _capabilitySkillsForMode) so tier:core/tier:standard capability skills are
|
||
// unioned in when registered. #2322 correction: for the DEFAULT `full`
|
||
// profile, resolveProfile short-circuits to the `{skills:'*'}` sentinel
|
||
// BEFORE ever reading `registry` (there is nothing to union — '*' already
|
||
// means "everything"), so the registry consultation that matters for `full`
|
||
// happens LATER, at staging time (stageSkillsForRuntimeAsSkills's '*'
|
||
// fill-in, resolveRuntimeArtifactLayout's `capabilityRegistry` param below) —
|
||
// not here. `_installedCapabilityRegistry` (not the frozen `_capabilityRegistry`)
|
||
// is passed so an INSTALLED third-party capability (not just a first-party
|
||
// one) is honored on every profile, `full` included (#2322 blocker 2).
|
||
const _commandsDir = path.join(src, 'commands', 'msd');
|
||
const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir);
|
||
let _resolvedProfile = resolveProfile({
|
||
modes: [_activeProfileName],
|
||
manifest: _skillsManifest,
|
||
registry: _installedCapabilityRegistry,
|
||
});
|
||
// Unified staging function: all profiles use stageSkillsForProfile with the
|
||
// registry-aware _resolvedProfile (ADR-857 phase 4c cutover).
|
||
function _stageSkills(commandsMsdDir) {
|
||
return stageSkillsForProfile(commandsMsdDir, _resolvedProfile);
|
||
}
|
||
function _stageAgents(agentsDir) {
|
||
if (_isCoreProfileAlias) return agentsDir;
|
||
return stageAgentsForProfile(agentsDir, _resolvedProfile);
|
||
}
|
||
const persistActiveProfileMarker = () => {
|
||
try {
|
||
writeActiveProfile(targetDir, _activeProfileName);
|
||
} catch {
|
||
// Non-fatal: marker persistence failure doesn't break the install.
|
||
}
|
||
};
|
||
|
||
const resolvedTarget = path.resolve(targetDir).replace(/\\/g, '/');
|
||
const homeDir = os.homedir().replace(/\\/g, '/');
|
||
const isWindowsHost = process.platform === 'win32';
|
||
const pathPrefix = computePathPrefix({
|
||
isGlobal,
|
||
isOpencode: _hostBehaviors(runtime).skipHomePrefixSubstitution === true,
|
||
isWindowsHost,
|
||
resolvedTarget,
|
||
homeDir,
|
||
// #4377: the runtime's own localConfigDir. This is the prefix that reaches
|
||
// copyWithPathReplacement, i.e. the one actually written into every
|
||
// emitted command/skill/workflow body — the rewrite-engine seams below
|
||
// handle re-applied surfaces, not the first install.
|
||
localDirName: getDirName(runtime),
|
||
});
|
||
|
||
// runtimeLabel is now the single-source getRuntimeLabel lookup (ADR-1239
|
||
// Phase B / #1679) — collapses the prior 16-line assignment chain.
|
||
const runtimeLabel = getRuntimeLabel(runtime);
|
||
|
||
console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
|
||
|
||
// Track installation failures
|
||
const failures = [];
|
||
const configuredEntrypoints = [];
|
||
let installerMigrationResult = null;
|
||
const rollbackInstallerMigrations = () => {
|
||
if (!installerMigrationResult || typeof installerMigrationResult.rollback !== 'function') return;
|
||
const rollback = installerMigrationResult.rollback;
|
||
installerMigrationResult = null;
|
||
rollback();
|
||
};
|
||
|
||
// Save any locally modified MSD files before they get wiped.
|
||
// The pristine context lets saveLocalPatches populate msd-pristine/ via
|
||
// the install transform pipeline, giving the reapply-patches Step 5
|
||
// verifier a real diff baseline (#2998).
|
||
saveLocalPatches(targetDir, {
|
||
packageSrc: src,
|
||
runtime,
|
||
pathPrefix,
|
||
isGlobal,
|
||
});
|
||
|
||
// Run manifest-backed cleanup migrations before package materialization.
|
||
installerMigrationResult = runInstallerMigrations({ configDir: targetDir });
|
||
|
||
// #3245 — Codex idempotent rollback. Capture pre-install state of ALL
|
||
// directories and files MSD will mutate so that any post-install validation
|
||
// failure (config.toml schema check, write failure, etc.) can revert the
|
||
// entire install atomically — not just config.toml.
|
||
//
|
||
// Captured BEFORE the first Codex-specific write (skills/) so the snapshots
|
||
// reflect the true pre-MSD state. Non-Codex runtimes skip this block.
|
||
//
|
||
// Snapshot contents:
|
||
// codexPreInstallSkillNames — Set of msd-* skill dir names that existed
|
||
// codexPreInstallSkillContents — Map<skillName, Map<relPath, Buffer>> of
|
||
// the full file tree of each pre-existing msd-* skill dir, so that
|
||
// overwritten dirs can be fully restored on rollback (not just removed).
|
||
// codexPreInstallAgentFiles — Set of msd-*.{md,toml} filenames in agents/
|
||
// codexPreInstallAgentContents — Map<filename, Buffer> of pre-existing agent
|
||
// file bytes, enabling full content restore (not just deletion) on rollback.
|
||
// codexPreInstallVersionBytes — Buffer (or null) of msd-core/VERSION
|
||
//
|
||
// These are referenced by restoreCodexSnapshot(), defined below inside the
|
||
// config block. Defining the variables here (outer scope) makes them
|
||
// accessible by closure.
|
||
const codexPreInstallSkillNames = new Set();
|
||
// Map<skillDirName, Map<relPath, Buffer>> — full content snapshot of each
|
||
// pre-existing msd-* skill directory. Best-effort: read errors are silently
|
||
// skipped so a partial snapshot is still better than none.
|
||
const codexPreInstallSkillContents = new Map();
|
||
const codexPreInstallAgentFiles = new Set();
|
||
// Map<filename, Buffer> — content snapshot of each pre-existing msd-* agent file.
|
||
const codexPreInstallAgentContents = new Map();
|
||
let codexPreInstallVersionBytes = null;
|
||
// #4544 — manifest-driven snapshot state (captured in the block below):
|
||
// codexPreInstallManagedFiles — Map<normalizedRelPath, Buffer|null>; one
|
||
// entry per path the PRIOR install's msd-file-manifest.json recorded.
|
||
// null means the path did not exist pre-install, so rollback re-deletes
|
||
// whatever this install put there instead of resurrecting it.
|
||
// codexPreInstallManifestBytes — Buffer (or null) of the prior manifest file
|
||
// itself, which a reinstall rewrites.
|
||
// codexPreInstallHooksTree — Map<relPath, Buffer>, a full recursive
|
||
// snapshot of <targetDir>/hooks/. The Codex manifest deliberately omits
|
||
// hooks/ (the !isCodex gate on shared-hooks tracking), and hooks/ is
|
||
// shared space, so the restore is wholesale: user files that predate
|
||
// the install are in the snapshot and come back; anything the failed
|
||
// install staged does not.
|
||
const codexPreInstallManagedFiles = new Map();
|
||
let codexPreInstallManifestBytes = null;
|
||
const codexPreInstallHooksTree = new Map();
|
||
// #4544 (review) — capture-state flags the restore must consult:
|
||
// codexManagedSnapshotCaptured — the capture gate ran at all. When
|
||
// false (non-Codex runtimes, minimal mode) NO pre-install state was
|
||
// recorded, and the only safe restore is no restore: an empty
|
||
// snapshot must never be read as "hooks/ was absent".
|
||
// codexPreInstallHooksDirPreExisted — hooks/ existed as a DIRECTORY
|
||
// pre-install. A pre-existing hooks FILE is left alone on rollback
|
||
// rather than deleted.
|
||
// codexPreInstallHooksCaptureIncomplete — some part of the hooks/ tree
|
||
// could not be read (permissions, special files). The restore
|
||
// downgrades to per-file so an uncapturable user file is never
|
||
// destroyed by a wholesale delete whose snapshot lacked it.
|
||
let codexManagedSnapshotCaptured = false;
|
||
// null = the gate never ran; true/false = the gate ran and hooks/ (did|did
|
||
// not) exist as a directory pre-install. Two states are load-bearing: a
|
||
// clean first install records false, so its rollback removes the staged
|
||
// hooks/ tree entirely; a non-Codex runtime records null, so rollback does
|
||
// nothing.
|
||
let codexPreInstallHooksDirPreExisted = null;
|
||
let codexPreInstallHooksCaptureIncomplete = false;
|
||
// #4249 CR: not gated on install mode. restoreCodexSnapshot is reachable for
|
||
// a core/--minimal install too (#2695), and its pass-2 sweeps remove every
|
||
// msd-* skill dir / agent file the snapshot does not claim — so an empty
|
||
// minimal-mode snapshot deleted the whole surface with nothing to restore.
|
||
if (_hostBehaviors(runtime).tomlConfigInstall) {
|
||
codexManagedSnapshotCaptured = true;
|
||
const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
|
||
if (fs.existsSync(_preSkillsDir)) {
|
||
for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
|
||
if (entry.isDirectory() && entry.name.startsWith('msd-')) {
|
||
codexPreInstallSkillNames.add(entry.name);
|
||
// Recursively snapshot all files in this skill dir.
|
||
const skillDir = path.join(_preSkillsDir, entry.name);
|
||
const fileMap = new Map();
|
||
const _snapshotDir = (dir, relBase) => {
|
||
let children;
|
||
try { children = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
||
for (const child of children) {
|
||
const relPath = relBase ? `${relBase}/${child.name}` : child.name;
|
||
const fullPath = path.join(dir, child.name);
|
||
if (child.isDirectory()) {
|
||
_snapshotDir(fullPath, relPath);
|
||
} else {
|
||
try { fileMap.set(relPath, fs.readFileSync(fullPath)); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
};
|
||
_snapshotDir(skillDir, '');
|
||
codexPreInstallSkillContents.set(entry.name, fileMap);
|
||
}
|
||
}
|
||
}
|
||
const _preAgentsDir = path.join(targetDir, 'agents');
|
||
if (fs.existsSync(_preAgentsDir)) {
|
||
for (const file of fs.readdirSync(_preAgentsDir)) {
|
||
if (file.startsWith('msd-') && (file.endsWith('.md') || file.endsWith('.toml'))) {
|
||
codexPreInstallAgentFiles.add(file);
|
||
try {
|
||
codexPreInstallAgentContents.set(file, fs.readFileSync(path.join(_preAgentsDir, file)));
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
}
|
||
const _preVersionPath = path.join(targetDir, 'msd-core', 'VERSION');
|
||
if (fs.existsSync(_preVersionPath)) {
|
||
try { codexPreInstallVersionBytes = fs.readFileSync(_preVersionPath); } catch (_) { /* best-effort */ }
|
||
}
|
||
// #4544 — capture the manifest-driven surfaces, same best-effort
|
||
// conventions as the skills/ snapshot above. readInstallManifest is the
|
||
// same hardened reader installer-migrations uses (array/ garbage shapes
|
||
// degrade to an empty file set — rollback then simply covers less, never
|
||
// crashes), and resolveInstallRelativePath keeps a hostile manifest key
|
||
// from turning into a write outside the install root.
|
||
const _priorManifest = readInstallManifest(targetDir);
|
||
for (const rel of Object.keys(_priorManifest.files)) {
|
||
const resolved = resolveInstallRelativePath(targetDir, rel);
|
||
if (!resolved) continue;
|
||
try {
|
||
codexPreInstallManagedFiles.set(resolved.relPath, fs.readFileSync(resolved.fullPath));
|
||
} catch (_) {
|
||
// Listed but absent/unreadable pre-install: snapshot absence, so
|
||
// rollback re-deletes instead of resurrecting.
|
||
codexPreInstallManagedFiles.set(resolved.relPath, null);
|
||
}
|
||
}
|
||
const _preManifestPath = path.join(targetDir, MANIFEST_NAME);
|
||
if (fs.existsSync(_preManifestPath)) {
|
||
try { codexPreInstallManifestBytes = fs.readFileSync(_preManifestPath); } catch (_) { /* best-effort */ }
|
||
}
|
||
// #4544 (review) — a clean FIRST install has no prior manifest, so nothing
|
||
// above records the payload this install is about to write, and a failed
|
||
// clean install would roll back to a half-written tree. Enumerate the SAME
|
||
// source directories the installer copies (a directory walk tracks the
|
||
// source tree automatically — no second file list to keep in parity) and
|
||
// record every path as absent-pre-install. On a reinstall most of these
|
||
// already carry entries from the prior manifest; any that do not (files
|
||
// new in this version) snapshot their pre-install bytes or absence exactly
|
||
// like the rest, which also closes the new-version-file residual.
|
||
const _recordWritePlanTree = (srcDir, relPrefix) => {
|
||
let children;
|
||
try { children = fs.readdirSync(srcDir, { withFileTypes: true }); } catch (_) { return; }
|
||
for (const child of children) {
|
||
const rel = relPrefix ? `${relPrefix}/${child.name}` : child.name;
|
||
if (child.isDirectory()) {
|
||
_recordWritePlanTree(path.join(srcDir, child.name), rel);
|
||
} else if (child.isFile()) {
|
||
if (codexPreInstallManagedFiles.has(rel)) continue;
|
||
// USER_OWNED_ARTIFACTS are manifest-relative to msd-core/ (#2771):
|
||
// they are durably staged across reinstalls and must never enter a
|
||
// rollback delete-set.
|
||
const manifestRel = rel.startsWith('msd-core/') ? rel.slice('msd-core/'.length) : rel;
|
||
if (USER_OWNED_ARTIFACTS.includes(manifestRel)) continue;
|
||
const resolved = resolveInstallRelativePath(targetDir, rel);
|
||
if (!resolved) continue;
|
||
try {
|
||
codexPreInstallManagedFiles.set(rel, fs.existsSync(resolved.fullPath) ? fs.readFileSync(resolved.fullPath) : null);
|
||
} catch (_) {
|
||
codexPreInstallManagedFiles.set(rel, null);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
_recordWritePlanTree(path.join(src, 'msd-core'), 'msd-core');
|
||
_recordWritePlanTree(path.join(src, 'scripts', 'changeset'), 'scripts/changeset');
|
||
_recordWritePlanTree(path.join(src, 'scripts', 'lib'), 'scripts/lib');
|
||
// msd-core/CHANGELOG.md is sourced from the repo root (not src/msd-core)
|
||
// and msd-core/.msd-runtime is generated at install time — neither appears
|
||
// in the directory walks, so record them explicitly.
|
||
for (const standalone of ['msd-core/CHANGELOG.md', 'msd-core/.msd-runtime', 'scripts/fix-slash-commands.cjs', 'scripts/gen-capability-registry.cjs', 'scripts/gen-loop-host-contract.cjs']) {
|
||
if (codexPreInstallManagedFiles.has(standalone)) continue;
|
||
const resolved = resolveInstallRelativePath(targetDir, standalone);
|
||
if (!resolved) continue;
|
||
try {
|
||
codexPreInstallManagedFiles.set(standalone, fs.existsSync(resolved.fullPath) ? fs.readFileSync(resolved.fullPath) : null);
|
||
} catch (_) {
|
||
codexPreInstallManagedFiles.set(standalone, null);
|
||
}
|
||
}
|
||
// hooks/ — full recursive snapshot, but never blind: lstat every entry so
|
||
// a symlink under hooks/ is neither followed (a link to a FIFO would hang
|
||
// the installer, /dev/zero would exhaust memory, and a link to private
|
||
// data would copy that data into the snapshot — hooks/ is user-writable
|
||
// shared space and, for local installs, repo-controllable) nor restored
|
||
// as a link. Anything unreadable or special marks the capture INCOMPLETE
|
||
// so the restore downgrades to per-file instead of wholesale-deleting a
|
||
// tree it never fully saw. A pre-existing hooks FILE (not directory) is
|
||
// recorded as such and left alone on rollback.
|
||
const _preHooksPath = path.join(targetDir, 'hooks');
|
||
let _preHooksStat = null;
|
||
try { _preHooksStat = fs.lstatSync(_preHooksPath); } catch (_) { /* absent */ }
|
||
codexPreInstallHooksDirPreExisted = Boolean(_preHooksStat && _preHooksStat.isDirectory());
|
||
if (codexPreInstallHooksDirPreExisted) {
|
||
const _snapshotHooksDir = (dir, relBase) => {
|
||
let children;
|
||
try { children = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) {
|
||
codexPreInstallHooksCaptureIncomplete = true;
|
||
return;
|
||
}
|
||
for (const child of children) {
|
||
const relPath = relBase ? `${relBase}/${child.name}` : child.name;
|
||
const fullPath = path.join(dir, child.name);
|
||
let st = null;
|
||
try { st = fs.lstatSync(fullPath); } catch (_) {
|
||
codexPreInstallHooksCaptureIncomplete = true;
|
||
continue;
|
||
}
|
||
if (st.isDirectory()) {
|
||
_snapshotHooksDir(fullPath, relPath);
|
||
} else if (st.isFile()) {
|
||
try { codexPreInstallHooksTree.set(relPath, fs.readFileSync(fullPath)); } catch (_) {
|
||
codexPreInstallHooksCaptureIncomplete = true;
|
||
}
|
||
} else {
|
||
codexPreInstallHooksCaptureIncomplete = true;
|
||
}
|
||
}
|
||
};
|
||
_snapshotHooksDir(_preHooksPath, '');
|
||
}
|
||
}
|
||
|
||
// #4544 — shared restore for the manifest-driven surfaces. Called by BOTH
|
||
// rollback paths: _codexPreConfigRollback (CHANGELOG.md, scripts/, the
|
||
// initial manifest write AND — via installer migrations' stale-hook removal
|
||
// — hooks/ itself are all mutated BEFORE config.toml is touched, so the
|
||
// early path must cover them) and the full restoreCodexSnapshot() below.
|
||
// Best-effort throughout, matching the #3245 convention: restore failures
|
||
// never mask the original install error.
|
||
const restoreCodexManagedSnapshot = () => {
|
||
// #4544 (review) — if the capture never ran (non-Codex runtimes, minimal
|
||
// mode), no pre-install state was recorded. The only safe action is NONE:
|
||
// an empty snapshot must never be read as "hooks/ was absent", or a
|
||
// minimal-mode rollback would delete the user's entire hooks/ tree.
|
||
if (!codexManagedSnapshotCaptured) return;
|
||
// hooks/ — the pre-install tree is restored wholesale: a user file that
|
||
// predated the install is IN the snapshot and comes back; anything the
|
||
// failed install staged is not, and goes away with the tree. When the
|
||
// capture was INCOMPLETE, wholesale deletion would permanently destroy a
|
||
// file whose bytes were never captured, so the restore downgrades to
|
||
// per-file: put back what was captured and remove only the names MSD
|
||
// itself stages (the hoisted CODEX_HOOKS_TO_COPY set plus the CommonJS
|
||
// marker). hooks/lib/ is left untouched in that mode — its contents are
|
||
// transitive and cannot be enumerated safely without the capture.
|
||
if (codexPreInstallHooksDirPreExisted !== null) {
|
||
const _hooksRestoreDir = path.join(targetDir, 'hooks');
|
||
if (!codexPreInstallHooksDirPreExisted) {
|
||
// Clean first install: nothing pre-existed under hooks/, so nothing
|
||
// the failed install staged may survive either.
|
||
try { fs.rmSync(_hooksRestoreDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
|
||
} else if (!codexPreInstallHooksCaptureIncomplete) {
|
||
try { fs.rmSync(_hooksRestoreDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
|
||
for (const [relPath, buf] of codexPreInstallHooksTree) {
|
||
const destFile = path.join(_hooksRestoreDir, relPath);
|
||
try {
|
||
fs.mkdirSync(path.dirname(destFile), { recursive: true });
|
||
fs.writeFileSync(destFile, buf);
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
} else {
|
||
// MSD-owned names are removed FIRST: several of them are also
|
||
// legitimate pre-install files the snapshot just restored, and a
|
||
// removal pass after the restore would delete the restored bytes.
|
||
for (const hookName of CODEX_HOOKS_TO_COPY) {
|
||
try { fs.rmSync(path.join(_hooksRestoreDir, hookName), { force: true }); } catch (_) { /* best-effort */ }
|
||
}
|
||
try { fs.rmSync(path.join(_hooksRestoreDir, 'package.json'), { force: true }); } catch (_) { /* best-effort */ }
|
||
for (const [relPath, buf] of codexPreInstallHooksTree) {
|
||
const destFile = path.join(_hooksRestoreDir, relPath);
|
||
try {
|
||
fs.mkdirSync(path.dirname(destFile), { recursive: true });
|
||
fs.writeFileSync(destFile, buf);
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
}
|
||
// Every MSD-owned path the prior manifest recorded (plus the clean-install
|
||
// write plan): restore bytes, or re-delete a path that was absent
|
||
// pre-install.
|
||
for (const [relPath, buf] of codexPreInstallManagedFiles) {
|
||
const resolved = resolveInstallRelativePath(targetDir, relPath);
|
||
if (!resolved) continue;
|
||
try {
|
||
if (buf !== null) {
|
||
fs.mkdirSync(path.dirname(resolved.fullPath), { recursive: true });
|
||
fs.writeFileSync(resolved.fullPath, buf);
|
||
} else if (fs.existsSync(resolved.fullPath)) {
|
||
fs.rmSync(resolved.fullPath, { force: true });
|
||
}
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
// The prior manifest file itself: reinstall rewrites it; rollback returns
|
||
// the previous install's manifest (or removes it on a clean first install).
|
||
const _manifestRestorePath = path.join(targetDir, MANIFEST_NAME);
|
||
if (codexPreInstallManifestBytes !== null) {
|
||
try { fs.writeFileSync(_manifestRestorePath, codexPreInstallManifestBytes); } catch (_) { /* best-effort */ }
|
||
} else if (fs.existsSync(_manifestRestorePath)) {
|
||
try { fs.unlinkSync(_manifestRestorePath); } catch (_) { /* best-effort */ }
|
||
}
|
||
};
|
||
|
||
// #3245 CR finding 2 — Rollback coverage extends to ALL post-snapshot operations,
|
||
// not just the Codex config/hook error paths. Any throw between snapshot capture and
|
||
// the Codex config block (skills copy, agents copy, VERSION write, manifest write, etc.)
|
||
// must also trigger rollback so the caller is never left in a partially-installed state.
|
||
//
|
||
// _codexPreConfigRollback covers the surfaces that can be mutated before
|
||
// config.toml is touched: skills/, agents/, msd-core/VERSION, the manifest-
|
||
// driven surfaces (#4544 — CHANGELOG.md, scripts/, .msd-runtime and the
|
||
// manifest itself are all rewritten in this window), and orphaned
|
||
// atomic-write temp files. It is safe to call before any writes have happened.
|
||
// The full restoreCodexSnapshot() (defined inside the config block) additionally
|
||
// handles config.toml and the staged hooks/ tree, which are not yet touched
|
||
// at this point in the pipeline.
|
||
const _codexPreConfigRollback = !_hostBehaviors(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => {
|
||
rollbackInstallerMigrations();
|
||
// skills/msd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
|
||
const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
|
||
for (const skillName of codexPreInstallSkillNames) {
|
||
const skillDirPath = path.join(_earlySkillsDir, skillName);
|
||
const fileMap = codexPreInstallSkillContents.get(skillName);
|
||
try {
|
||
fs.rmSync(skillDirPath, { recursive: true, force: true });
|
||
fs.mkdirSync(skillDirPath, { recursive: true });
|
||
if (fileMap) {
|
||
for (const [relPath, buf] of fileMap) {
|
||
const destFile = path.join(skillDirPath, relPath);
|
||
try {
|
||
fs.mkdirSync(path.dirname(destFile), { recursive: true });
|
||
fs.writeFileSync(destFile, buf);
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
// skills/msd-* — pass 2: remove any newly-created dirs not in the snapshot.
|
||
if (fs.existsSync(_earlySkillsDir)) {
|
||
try {
|
||
for (const entry of fs.readdirSync(_earlySkillsDir, { withFileTypes: true })) {
|
||
if (entry.isDirectory() && entry.name.startsWith('msd-') && !codexPreInstallSkillNames.has(entry.name)) {
|
||
try { fs.rmSync(path.join(_earlySkillsDir, entry.name), { recursive: true, force: true }); }
|
||
catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
// agents/msd-* — pass 1: restore snapshot entries.
|
||
const _earlyAgentsDir = path.join(targetDir, 'agents');
|
||
for (const file of codexPreInstallAgentFiles) {
|
||
const buf = codexPreInstallAgentContents.get(file);
|
||
if (buf !== undefined) {
|
||
try {
|
||
fs.mkdirSync(_earlyAgentsDir, { recursive: true });
|
||
fs.writeFileSync(path.join(_earlyAgentsDir, file), buf);
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
// agents/msd-* — pass 2: remove any newly-created files not in the snapshot.
|
||
if (fs.existsSync(_earlyAgentsDir)) {
|
||
try {
|
||
for (const file of fs.readdirSync(_earlyAgentsDir)) {
|
||
if (file.startsWith('msd-') && (file.endsWith('.md') || file.endsWith('.toml')) && !codexPreInstallAgentFiles.has(file)) {
|
||
try { fs.unlinkSync(path.join(_earlyAgentsDir, file)); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
// msd-core/VERSION
|
||
const _earlyVersionPath = path.join(targetDir, 'msd-core', 'VERSION');
|
||
if (codexPreInstallVersionBytes !== null) {
|
||
try { fs.writeFileSync(_earlyVersionPath, codexPreInstallVersionBytes); } catch (_) { /* best-effort */ }
|
||
} else if (fs.existsSync(_earlyVersionPath)) {
|
||
try { fs.unlinkSync(_earlyVersionPath); } catch (_) { /* best-effort */ }
|
||
}
|
||
// #4544 — manifest-driven surfaces (CHANGELOG.md, scripts/, the initial
|
||
// manifest write, and — via installer migrations' stale-hook removal —
|
||
// hooks/ itself are all mutated in this window). The shared restore is
|
||
// also idempotent against an untouched tree.
|
||
restoreCodexManagedSnapshot();
|
||
// Orphaned atomic-write temp files.
|
||
const _earlyTmpPattern = /\.tmp-\d+-\d+$/;
|
||
function _earlyCleanTmpFiles(dir) {
|
||
if (!fs.existsSync(dir)) return;
|
||
let entries;
|
||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
||
for (const entry of entries) {
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
_earlyCleanTmpFiles(full);
|
||
} else if (_earlyTmpPattern.test(entry.name) && __atomicWrittenTmps.has(full)) {
|
||
try { fs.unlinkSync(full); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
}
|
||
_earlyCleanTmpFiles(targetDir);
|
||
};
|
||
|
||
// Run manifest-backed cleanup migrations after rollback snapshots exist and
|
||
// before package materialization. Codex rollback paths invoke the migration
|
||
// rollback handle if a later install step fails.
|
||
//
|
||
// Runtime scope comes from docs/installer-migrations.md#runtime-configuration-contract-registry:
|
||
// every supported runtime uses this same planner/apply/report path, while
|
||
// individual migration records decide whether a runtime-specific config
|
||
// rewrite is allowed by that runtime's documented ownership boundary.
|
||
// #3245 CR finding 2 — wrap the pre-config install operations in a try/catch so
|
||
// that ANY throw between snapshot capture and the Codex config block triggers rollback.
|
||
// Non-Codex paths are unaffected (_codexPreConfigRollback is null for them).
|
||
//
|
||
// agentsSrc is declared here (let, not const) because installCodexConfig() inside the
|
||
// Codex config block below also references it, and that block is outside the try scope.
|
||
let agentsSrc = path.join(src, 'agents');
|
||
// Capture upgrade signal BEFORE files are written (#683). Must be declared at function
|
||
// scope (outside the try block below) so it is accessible in the settings section later.
|
||
// Absent VERSION = fresh install; present VERSION = upgrade/re-install.
|
||
const priorInstallExisted = fs.existsSync(path.join(targetDir, 'msd-core', 'VERSION'));
|
||
try {
|
||
installerMigrationResult = runInstallerMigrations({
|
||
configDir: targetDir,
|
||
runtime,
|
||
scope: _installScopeId,
|
||
migrations: options.installerMigrations,
|
||
baselineScan: true,
|
||
});
|
||
// #3541: non-interactive runs (typical /msd-update via Claude Code) have
|
||
// no stdin TTY and therefore no way to answer prompt-user migration
|
||
// actions. Resolve safe categories by classification (stale SDK build
|
||
// artifacts → remove; user-facing skills → keep; bundled MSD hooks →
|
||
// remove [#3610]) and log every resolution; anything that cannot be
|
||
// safely defaulted falls through to assertInstallerMigrationsUnblocked,
|
||
// which now emits a grouped error with the documented resolution path.
|
||
//
|
||
// #3610: the classifier-based resolution must run regardless of TTY.
|
||
// For unambiguous categories (e.g. `hooks/msd-*` bundled hooks left
|
||
// behind by a previous version), there is no actual "user choice" to
|
||
// make — the file is a known MSD-managed artifact and the installer is
|
||
// about to write the fresh bundled version. Gating the resolver on
|
||
// `!isTTY` made `npx @golem15/msd-core@latest --codex` hard-abort with
|
||
// 12 blocked bundled hooks. The env-override branch (operator-supplied
|
||
// MSD_INSTALLER_MIGRATION_RESOLVE) still applies only in non-TTY mode.
|
||
const _migrationIsTty = process.stdin && process.stdin.isTTY === true;
|
||
if (Array.isArray(installerMigrationResult.blocked) &&
|
||
installerMigrationResult.blocked.length > 0 &&
|
||
installerMigrationResult.plan &&
|
||
Array.isArray(installerMigrationResult.plan.actions)) {
|
||
const { resolutions } = resolveInstallerMigrationPromptsForNonTty(
|
||
installerMigrationResult,
|
||
{ isTty: false }
|
||
);
|
||
for (const entry of resolutions) {
|
||
console.log(
|
||
` ↪ installer-migration auto-resolved: ${entry.relPath} → ${entry.choice} ` +
|
||
`(category=${entry.category}, source=${entry.source})`
|
||
);
|
||
}
|
||
// If we resolved anything, the original run returned early without
|
||
// applying the (now-unblocked) plan — apply it here.
|
||
if (resolutions.length > 0 && installerMigrationResult.plan.blocked.length === 0) {
|
||
const applyResult = applyInstallerMigrationPlan({
|
||
configDir: targetDir,
|
||
plan: installerMigrationResult.plan,
|
||
});
|
||
installerMigrationResult = {
|
||
...installerMigrationResult,
|
||
...applyResult,
|
||
blocked: [],
|
||
};
|
||
}
|
||
}
|
||
reportInstallerMigrationResult(installerMigrationResult);
|
||
assertInstallerMigrationsUnblocked(installerMigrationResult);
|
||
|
||
// Artifact install dispatcher — routes to layout-driven path for all
|
||
// skills-based runtimes (both full and minimal/core profiles); keeps
|
||
// back-compat paths for commands-based runtimes (OpenCode/
|
||
// Claude-local).
|
||
//
|
||
// installRuntimeArtifacts handles legacy migration + skill/agent staging
|
||
// via layout kinds for all profile modes. _resolvedProfile already reflects
|
||
// the user's --profile=core / --minimal choice.
|
||
//
|
||
// Non-layout side-effects preserved inline:
|
||
// Claude local: copyWithPathReplacement + stale-skills cleanup
|
||
|
||
// Layout-driven path for all skills-based runtimes (full and minimal modes).
|
||
// applyRuntimeContentRewritesInPlace (called inside installRuntimeArtifacts)
|
||
// handles per-runtime path + branding rewrites.
|
||
// Descriptor-driven (ADR-1016 / ADR-1239): a runtime takes the layout-driven
|
||
// installRuntimeArtifacts path when its scoped artifactLayout is non-empty
|
||
// (it declares any skills/commands/agents kind for this scope).
|
||
// This replaces the prior hardcoded `isCodex || ...` roster so a
|
||
// newly-added runtime with an artifact layout installs without a per-runtime
|
||
// branch — the add-a-host tax ADR-1239 Phase B retires. OpenCode now
|
||
// routes through this SAME path too: its hostBehaviors.combinedFamilyInstall
|
||
// flag makes installRuntimeArtifacts (in src/install-engine.cts) delegate to
|
||
// installOpencodeFamilyArtifacts for the combined commands+skills+native-plugin
|
||
// install (ADR-1239 / #2087), replacing the bespoke inline block this comment
|
||
// used to describe. Claude-local remains the one special-cased path
|
||
// (copyWithPathReplacement + stale-skills cleanup).
|
||
const _isSkillsRuntime = (() => {
|
||
if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && !isGlobal) return false; // legacy flat local path (descriptor-driven; #2086)
|
||
const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime];
|
||
const layout = cap && cap.runtime && cap.runtime.artifactLayout;
|
||
if (!layout) return false;
|
||
const scopeLayout = isGlobal ? layout.global : layout.local;
|
||
return Array.isArray(scopeLayout) && scopeLayout.length > 0;
|
||
})();
|
||
|
||
// Install the distribution-owned msd-core tree before layout materialization.
|
||
// installRuntimeArtifacts then provisions the durable Runtime Surface corpus
|
||
// exactly once into this final tree instead of having that corpus overwritten
|
||
// by a later whole-tree copy and needing a second provisioning pass.
|
||
const skillSrc = path.join(src, 'msd-core');
|
||
const skillDest = path.join(targetDir, 'msd-core');
|
||
const _msdArtifactsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
|
||
if (_msdArtifactsStagingRoot === null) {
|
||
console.warn(` ${yellow}!${reset} Skipping msd-core/${USER_OWNED_ARTIFACTS.join(', msd-core/')} preservation (staging unavailable) — it will be lost if present.`);
|
||
copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
|
||
} else {
|
||
const stagedMsdArtifacts = stageUserArtifacts(skillDest, USER_OWNED_ARTIFACTS, _msdArtifactsStagingRoot);
|
||
copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
|
||
restoreStagedUserArtifacts(skillDest, stagedMsdArtifacts);
|
||
discardStagedUserArtifacts(stagedMsdArtifacts);
|
||
}
|
||
if (verifyInstalled(skillDest, 'msd-core')) {
|
||
console.log(` ${green}✓${reset} Installed workflow assets`);
|
||
} else {
|
||
failures.push('msd-core');
|
||
}
|
||
|
||
// #2624: write the .msd-source marker. Extracted from its former late position so it can be
|
||
// called BEFORE staging reads the marker (see the call site below). Scoped to the Claude-global
|
||
// layout (issue #1477) — the only install path that ships the skills layout without a
|
||
// commands/msd source tree, so findInstallSourceRoot's walk-up has nothing to find and
|
||
// /msd-surface (list/status) throws without it. Points at the package's own commands/msd
|
||
// source. Guarded on source presence so a half-published package never writes a dangling
|
||
// marker. Write failure is non-fatal (install proceeds; warn so /msd-surface breakage is
|
||
// diagnosable) — the same contract the late write had.
|
||
function _writeMsdSourceMarker(runtime, targetDir, src, isGlobal) {
|
||
if (_hostBehaviors(runtime).sourceMarkerFile && isGlobal) {
|
||
const msdSourceCommands = path.join(src, 'commands', 'msd');
|
||
if (fs.existsSync(msdSourceCommands)) {
|
||
try {
|
||
// ADR-1239 Phase B write-confinement: the descriptor-sourced marker filename
|
||
// must resolve under targetDir (parity with the other descriptor-driven writes).
|
||
const _markerPath = assertDestWithinConfigHome(targetDir, _hostBehaviors(runtime).sourceMarkerFile);
|
||
if (hasExistingSymlinkBetween(path.resolve(targetDir), _markerPath, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
||
throw new Error(`compatibility marker "${_markerPath}" contains an untrusted symlink`);
|
||
}
|
||
fs.writeFileSync(_markerPath, msdSourceCommands + '\n', 'utf8');
|
||
} catch (err) {
|
||
// Non-fatal: install proceeds. But on the Claude-global layout walk-up
|
||
// also fails (no commands/msd source tree), so a silent write failure
|
||
// still leaves /msd-surface broken at runtime — warn so it's diagnosable.
|
||
console.warn(` ${yellow}!${reset} Could not write .msd-source marker (${err.message}); /msd-surface list/status may fail`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// #2624: write the .msd-source marker BEFORE any staging reads it. The marker write
|
||
// formerly lived AFTER staging; on an upgrade the marker still held the PREVIOUS
|
||
// install's source path (e.g. an npx per-version cache dir that still exists on disk),
|
||
// so findInstallSourceRoot(configDir) — called inside installRuntimeArtifacts below —
|
||
// returned the stale path and every converted skill was generated from the OLD version's
|
||
// commands/msd, silently installing prior-version content with a self-consistent manifest
|
||
// hash. Writing first closes the read-before-write hole for every findInstallSourceRoot
|
||
// consumer (skills, commands, /msd-surface, capability-state). Placed here (before the
|
||
// _isSkillsRuntime branch) so it runs for every Claude-global install, matching the
|
||
// original write's sourceMarkerFile && isGlobal guard exactly.
|
||
_writeMsdSourceMarker(runtime, targetDir, src, isGlobal);
|
||
|
||
if (_isSkillsRuntime) {
|
||
// Layout-driven install for skills-based runtimes (full and minimal modes)
|
||
const scope = _installScopeId;
|
||
// Preserve an existing Runtime Surface selection during upgrades. The
|
||
// installer refreshes the source corpus first, then emits exactly the
|
||
// already-committed selection instead of widening it to the base profile.
|
||
const _committedSurface = readSurface(targetDir);
|
||
if (_committedSurface) {
|
||
_resolvedProfile = resolveSurface(
|
||
targetDir,
|
||
loadSkillsManifest(_commandsDir),
|
||
undefined,
|
||
_installedCapabilityRegistry,
|
||
_committedSurface,
|
||
);
|
||
}
|
||
// ADR-1239 upgrade 3 / #2088: a kind may declare an alternate install `home`
|
||
// (e.g. Codex skills -> $HOME/.agents/skills) instead of the runtime's normal
|
||
// configDir. Resolve the ACTUAL on-disk skills root here, descriptor-driven
|
||
// (no isCodex check), so downstream sidecar-cleanup and post-install
|
||
// verification look in the right place regardless of which runtime declares
|
||
// an alternate home for its skills kind.
|
||
const _skillsRootDir = _resolveSkillsRootDir(runtime, targetDir, scope);
|
||
// ADR-1239 / #2086: drive install through the public Host-Integration Interface
|
||
// (imperative adapter). The adapter delegates to the SAME installRuntimeArtifacts
|
||
// engine call -> byte-identical output (gated by golden-install-parity). Fail-open
|
||
// to the engine directly if the composed-registry adapter can't load.
|
||
const _adapter = _runtimeAdapter(runtime);
|
||
if (_adapter) {
|
||
_adapter.install({
|
||
configDir: targetDir,
|
||
scope,
|
||
resolvedProfile: _resolvedProfile,
|
||
resolveAttribution: getCommitAttribution,
|
||
});
|
||
} else {
|
||
// #2322: fallback path (adapter unavailable) — thread the composed
|
||
// registry too, so this path stages third-party capability skills
|
||
// identically to the primary adapter path above.
|
||
installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution, _installedCapabilityRegistry);
|
||
}
|
||
|
||
// #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
|
||
// msd-* skill dirs. Prior installs wrote these files so Codex would show a
|
||
// display name and description in the /skills TUI popup. Recent Codex builds
|
||
// index BOTH SKILL.md and the sidecar, causing each MSD skill to appear twice
|
||
// in autocomplete. Cleaning them up fixes the duplication; SKILL.md alone is
|
||
// sufficient for Codex discovery. User-owned dirs are never touched.
|
||
if (_hostBehaviors(runtime).cleanupSkillSidecars) {
|
||
cleanupCodexSkillMetadataSidecars(_skillsRootDir);
|
||
}
|
||
|
||
// ADR-1239 split-home migration: when a runtime's skills kind moved to an
|
||
// alternate `home` (e.g. Codex → ~/.agents/skills), pre-move installs left
|
||
// managed msd-* skill dirs at the old configDir-rooted location
|
||
// (~/.codex/skills). Reinstalling here writes the new location but would
|
||
// otherwise orphan the old one — clean up the stale msd-* dirs.
|
||
{
|
||
const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope);
|
||
if (_movedOldSkillsDir) {
|
||
const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'msd-');
|
||
if (migrated > 0) {
|
||
console.log(` ${green}✓${reset} Migrated ${migrated} skill dir(s) off the legacy ${_movedOldSkillsDir} location`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Verify installed artifacts and report
|
||
{
|
||
const skillsDir = _skillsRootDir;
|
||
if (fs.existsSync(skillsDir)) {
|
||
const count = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||
.filter(e => e.isDirectory() && e.name.startsWith('msd-')).length;
|
||
if (count > 0) {
|
||
console.log(` ${green}✓${reset} Installed ${count} skills to skills/`);
|
||
} else {
|
||
failures.push('skills/msd-*');
|
||
}
|
||
} else {
|
||
failures.push('skills/msd-*');
|
||
}
|
||
}
|
||
} else {
|
||
// Claude Code local: flat msd-<cmd>.md layout — Claude Code registers
|
||
// commands from .claude/commands/ using the filename stem as the command
|
||
// name, so msd-<cmd>.md produces the /msd-<cmd> hyphen form used everywhere
|
||
// in the framework. The old commands/msd/<cmd>.md subdirectory layout caused
|
||
// Claude Code to namespace commands as /msd:<cmd> (colon form). (#1367)
|
||
const commandsDir = path.join(targetDir, 'commands');
|
||
fs.mkdirSync(commandsDir, { recursive: true });
|
||
const msdSrc = _stageSkills(_commandsDir);
|
||
const cmdNames = readMsdCommandNames();
|
||
|
||
// Remove stale msd-*.md files before writing new ones (clean install)
|
||
if (fs.existsSync(commandsDir)) {
|
||
for (const f of fs.readdirSync(commandsDir)) {
|
||
if (f.startsWith('msd-') && f.endsWith('.md')) {
|
||
fs.unlinkSync(path.join(commandsDir, f));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Write each command as msd-<stem>.md (flat, hyphen-prefixed)
|
||
let cmdCount = 0;
|
||
if (fs.existsSync(msdSrc)) {
|
||
for (const entry of fs.readdirSync(msdSrc, { withFileTypes: true })) {
|
||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||
const stem = entry.name.slice(0, -3);
|
||
let content = fs.readFileSync(path.join(msdSrc, entry.name), 'utf8');
|
||
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal, getCommitAttribution(runtime));
|
||
content = normalizeAgentBodyForRuntime(content, runtime, cmdNames);
|
||
fs.writeFileSync(path.join(commandsDir, `msd-${stem}.md`), content);
|
||
cmdCount++;
|
||
}
|
||
}
|
||
|
||
if (cmdCount > 0) {
|
||
console.log(` ${green}✓${reset} Installed ${cmdCount} commands to commands/ (msd-<cmd>.md flat form)`);
|
||
} else {
|
||
failures.push('commands/msd-*');
|
||
}
|
||
|
||
// Legacy cleanup: remove old commands/msd/ subdirectory from prior installs
|
||
// that used the namespaced layout (wrote bare-name files under commands/msd/).
|
||
const legacyMsdDir = path.join(commandsDir, 'msd');
|
||
if (fs.existsSync(legacyMsdDir)) {
|
||
// Stage user-owned dev-preferences.md DURABLY before wiping (#2875 /
|
||
// #1874-F19 "site 6" — this Claude commands-install path open-coded
|
||
// its own preserve/restore instead of calling preserveUserArtifacts,
|
||
// found by sweeping for the read-then-wipe-then-write PATTERN rather
|
||
// than for that helper's callers).
|
||
// #2875 defect fix: DEGRADE, never abort install, when the staging
|
||
// root cannot be resolved — skip this legacy-migration block entirely
|
||
// (leave the stale dir in place) rather than wipe without a durable
|
||
// backup.
|
||
const _legacyMsdStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir);
|
||
if (_legacyMsdStagingRoot !== null) {
|
||
const stagedDevPrefs = stageUserArtifacts(legacyMsdDir, ['dev-preferences.md'], _legacyMsdStagingRoot);
|
||
// Preserve the ORIGINAL truthy-content check exactly: an existing but
|
||
// EMPTY dev-preferences.md was (and still is) silently not migrated —
|
||
// matching prior behavior byte-for-byte rather than widening scope.
|
||
const preservedDevPrefs = stagedDevPrefs.names.includes('dev-preferences.md')
|
||
? fs.readFileSync(path.join(stagedDevPrefs.filesDir, 'dev-preferences.md'), 'utf8')
|
||
: null;
|
||
fs.rmSync(legacyMsdDir, { recursive: true });
|
||
console.log(` ${green}✓${reset} Removed legacy commands/msd/ (migrated to flat msd-<cmd>.md layout)`);
|
||
if (preservedDevPrefs) {
|
||
// Migrate dev-preferences to the new flat form — a RENAME on
|
||
// restore (staged as 'dev-preferences.md', restored as
|
||
// 'msd-dev-preferences.md'), not a round-trip.
|
||
restoreStagedUserArtifacts(commandsDir, stagedDevPrefs, { rename: { 'dev-preferences.md': 'msd-dev-preferences.md' } });
|
||
console.log(` ${green}✓${reset} Migrated dev-preferences.md to commands/msd-dev-preferences.md`);
|
||
}
|
||
discardStagedUserArtifacts(stagedDevPrefs);
|
||
}
|
||
}
|
||
|
||
// Clean up any stale skills/ from a previous local install
|
||
const staleSkillsDir = path.join(targetDir, 'skills');
|
||
if (fs.existsSync(staleSkillsDir)) {
|
||
const staleMsd = fs.readdirSync(staleSkillsDir, { withFileTypes: true })
|
||
.filter(e => e.isDirectory() && e.name.startsWith('msd-'));
|
||
for (const e of staleMsd) {
|
||
fs.rmSync(path.join(staleSkillsDir, e.name), { recursive: true });
|
||
}
|
||
if (staleMsd.length > 0) {
|
||
console.log(` ${green}✓${reset} Removed ${staleMsd.length} stale MSD skill(s) from skills/`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// #2624: the .msd-source marker is now written by _writeMsdSourceMarker()
|
||
// BEFORE staging reads it (see the early call above the _isSkillsRuntime
|
||
// block). The former write lived here — AFTER staging — which on an upgrade
|
||
// let staging read a stale prior-version marker and silently install
|
||
// old-version skill content. Moved up; this site intentionally left empty.
|
||
|
||
// Copy shared manifests into the msd-core payload
|
||
// at the co-located path that CJS modules resolve first:
|
||
// msd-core/bin/shared/*.json
|
||
//
|
||
// This source now lives under msd-core/bin/shared in-repo.
|
||
const sharedPayloadFiles = [
|
||
'model-catalog.json',
|
||
'config-defaults.manifest.json',
|
||
'config-schema.manifest.json',
|
||
'runtime-aliases.manifest.json',
|
||
];
|
||
for (const fileName of sharedPayloadFiles) {
|
||
const sharedSrc = path.join(src, 'msd-core', 'bin', 'shared', fileName);
|
||
const sharedDest = path.join(skillDest, 'bin', 'shared', fileName);
|
||
const displayPath = `msd-core/bin/shared/${fileName}`;
|
||
if (fs.existsSync(sharedSrc)) {
|
||
fs.mkdirSync(path.dirname(sharedDest), { recursive: true });
|
||
fs.copyFileSync(sharedSrc, sharedDest);
|
||
if (verifyFileInstalled(sharedDest, displayPath)) {
|
||
console.log(` ${green}✓${reset} Installed ${displayPath}`);
|
||
} else {
|
||
failures.push(displayPath);
|
||
}
|
||
} else {
|
||
failures.push(`msd-core/bin/shared/${fileName} (source missing)`);
|
||
}
|
||
}
|
||
|
||
// Agents directory materialization.
|
||
// #2875 Part 2 (the agents-bypass closure): EVERY runtime is now
|
||
// descriptor-driven for agents — installRuntimeArtifacts (called earlier in
|
||
// this function, the `_isSkillsRuntime` branch above) already wrote
|
||
// agents/ for any runtime whose capability.json declares an `agents` kind,
|
||
// via convertedAgentsKind/agentsKind (generic layout loop) or
|
||
// installAgentsKindStandalone (OpenCode's combinedFamilyInstall
|
||
// branch, called from within installOpencodeFamilyArtifacts) — both reuse
|
||
// the SAME stageAgentsForRuntimeWithConverter pipeline (path-rewrite →
|
||
// attribution → converter → frontmatter extensions → normalize) the
|
||
// inline loop this replaces used to hand-roll, and both prune stale msd-*
|
||
// entries via their own _removeMsdEntries pass BEFORE copying (broader
|
||
// than this loop's old extension-gated stale check — see
|
||
// runtime-artifact-layout.cts's convertedAgentsKind doc comment).
|
||
// Minimal-mode agent filtering is handled the SAME way it already was for
|
||
// the ten runtimes cut over before this change: via resolvedProfile.agents
|
||
// at staging time, not a separate branch here.
|
||
//
|
||
// `!_isSkillsRuntime` runtimes (claude-local's legacy-flat local path)
|
||
// never reach that loop at all — installAgentsKindStandalone is called
|
||
// here explicitly to cover them (install-engine.cts's own doc comment
|
||
// explains why; a regression here was caught by the install-tree golden
|
||
// fixture, tests/fixtures/install-tree/claude-local.json).
|
||
if (_isSkillsRuntime) {
|
||
console.log(` ${dim}↳${reset} Agents installed via descriptor-driven layout (${runtime})`);
|
||
} else {
|
||
const _standaloneProjectDir = isGlobal ? process.cwd() : targetDir;
|
||
const _standaloneAgentsResult = installAgentsKindStandalone(runtime, targetDir, _installScopeId, _resolvedProfile, pathPrefix, getCommitAttribution, _installedCapabilityRegistry, _standaloneProjectDir);
|
||
if (_standaloneAgentsResult) {
|
||
// #2875 defect fix: installAgentsKindStandalone now returns `null`
|
||
// (rather than a truthy result pointing at an empty destDir) whenever a
|
||
// restricted (non-'*') resolvedProfile — --minimal being the common
|
||
// case — legitimately stages ZERO agents, matching the pre-#2875-Part-2
|
||
// inline loop's behavior of never creating agentsDest under --minimal
|
||
// at all (see the deleted `isMinimalMode` branch). `destDir` is
|
||
// therefore guaranteed non-empty whenever we reach this branch, so a
|
||
// real staging failure still fails loudly via verifyInstalled below.
|
||
if (verifyInstalled(_standaloneAgentsResult.destDir, 'agents')) {
|
||
console.log(` ${green}✓${reset} Installed agents`);
|
||
} else {
|
||
failures.push('agents');
|
||
}
|
||
} else if (_resolvedProfile.skills !== '*') {
|
||
console.log(` ${dim}↳${reset} Skipping agents (${_resolvedProfile.name} profile excludes all agents — run \`msd update\` with a broader profile to add them)`);
|
||
} else {
|
||
console.log(` ${dim}↳${reset} No agents kind declared for ${runtime} at this scope`);
|
||
}
|
||
}
|
||
|
||
// Codex registers agents in `config.toml` via `[agents.msd-*]` sections —
|
||
// NOT agents-directory materialization (design doc "Deliberately not in
|
||
// scope"), so this stays independent of the agents/ write above. Without
|
||
// stripping these on a full → minimal reinstall, the runtime would keep
|
||
// advertising the old full agent surface even though the descriptor-driven
|
||
// write above already skipped writing the .md files for a minimal-tier
|
||
// resolvedProfile. Reuse the same helper that powers `--uninstall`.
|
||
if (isMinimalMode(_effectiveInstallMode) && _hostBehaviors(runtime).tomlConfigInstall) {
|
||
const codexConfigPath = path.join(targetDir, 'config.toml');
|
||
if (fs.existsSync(codexConfigPath)) {
|
||
const existing = fs.readFileSync(codexConfigPath, 'utf8');
|
||
const cleaned = stripMsdFromCodexConfig(existing);
|
||
if (cleaned === null) {
|
||
fs.unlinkSync(codexConfigPath);
|
||
} else if (cleaned !== existing) {
|
||
fs.writeFileSync(codexConfigPath, cleaned);
|
||
}
|
||
}
|
||
}
|
||
|
||
// agentsSrc is declared as `let` before the enclosing try block (not const)
|
||
// so it is accessible by installCodexConfig() in the Codex config section
|
||
// below — that function reads RAW source agents/*.md (not the
|
||
// descriptor-staged output above) to build Codex's per-agent config.toml
|
||
// sidecar files, a separate writer this migration deliberately does not
|
||
// touch (design doc: "Codex's config.toml [agents.msd-*] strip... is not
|
||
// agents-directory materialization").
|
||
agentsSrc = _stageAgents(path.join(src, 'agents'));
|
||
|
||
// Copy CHANGELOG.md
|
||
const changelogSrc = path.join(src, 'CHANGELOG.md');
|
||
const changelogDest = path.join(targetDir, 'msd-core', 'CHANGELOG.md');
|
||
if (fs.existsSync(changelogSrc)) {
|
||
fs.copyFileSync(changelogSrc, changelogDest);
|
||
if (verifyFileInstalled(changelogDest, 'CHANGELOG.md')) {
|
||
console.log(` ${green}✓${reset} Installed CHANGELOG.md`);
|
||
} else {
|
||
failures.push('CHANGELOG.md');
|
||
}
|
||
}
|
||
|
||
// Write VERSION file
|
||
const versionDest = path.join(targetDir, 'msd-core', 'VERSION');
|
||
fs.writeFileSync(versionDest, pkg.version);
|
||
if (verifyFileInstalled(versionDest, 'VERSION')) {
|
||
console.log(` ${green}✓${reset} Wrote VERSION (${pkg.version})`);
|
||
} else {
|
||
failures.push('VERSION');
|
||
}
|
||
|
||
// #2297: write a per-install runtime marker co-located with VERSION at
|
||
// <install>/msd-core/.msd-runtime. It gives resolveModelInternal a reliable
|
||
// "which runtime owns THIS install" signal in a no-project session (config.runtime
|
||
// is null and MSD_RUNTIME is not exported), so the shared ~/.msd/defaults.json
|
||
// resolve_model_ids:"omit" policy (written below for non-alias runtimes only)
|
||
// applies ONLY when a non-alias runtime is actually resolving — a Claude session
|
||
// reads its own marker and keeps its tier aliases instead of inheriting another
|
||
// runtime's install-order-dependent "omit". See src/model-resolver.cts.
|
||
const runtimeMarkerDest = path.join(targetDir, 'msd-core', '.msd-runtime');
|
||
fs.writeFileSync(runtimeMarkerDest, `${runtime}\n`);
|
||
if (verifyFileInstalled(runtimeMarkerDest, '.msd-runtime')) {
|
||
console.log(` ${green}✓${reset} Wrote runtime marker (.msd-runtime: ${runtime})`);
|
||
} else {
|
||
failures.push('.msd-runtime');
|
||
}
|
||
|
||
// Reusable: copy hooks/dist/ + hooks/lib/ into destRootDir, writing the
|
||
// CommonJS package.json marker alongside them. Used below for the generic
|
||
// configDir install path (guarded by hostBehaviors.skipSharedHooksInstall).
|
||
// Returns false when hooks/dist/ exists but failed to verify post-copy (a
|
||
// genuine failure the caller should surface); true otherwise (including
|
||
// when hooks/dist/ is absent from the package — nothing to verify).
|
||
function installSharedHooksBundle(destRootDir) {
|
||
// destRootDir already exists for the generic call site (targetDir — created
|
||
// earlier in install() by the skills/agents writes above). mkdirSync
|
||
// recursive is a safe no-op when the dir is already present.
|
||
fs.mkdirSync(destRootDir, { recursive: true });
|
||
|
||
// #3023: the bundle's directory NAME is descriptor-driven — a host that
|
||
// reserves `hooks/` must be able to opt out. Resolved once here so the
|
||
// stage / lib / marker sites can never disagree about where the bundle is.
|
||
|
||
// #2544: the CommonJS marker is NOT written here (destRootDir is the
|
||
// runtime's shared config root — user-writable territory on OpenCode,
|
||
// where it is the documented place to declare local-plugin npm
|
||
// dependencies). It is written into hooks/ below, the directory MSD
|
||
// creates and fills with its own .js scripts, once that directory exists.
|
||
|
||
let hooksOk = true;
|
||
// #2544: true once MSD has actually written into destRootDir/hooks/, which
|
||
// is what licenses the CommonJS marker below.
|
||
let stagedHooks = false;
|
||
|
||
// Copy hooks from dist/ (bundled with dependencies)
|
||
// Template paths for the target runtime (replaces '.claude' with correct config dir)
|
||
const hooksSrc = path.join(src, 'hooks', 'dist');
|
||
if (fs.existsSync(hooksSrc)) {
|
||
const hooksDest = path.join(destRootDir, SHARED_HOOKS_DIR);
|
||
fs.mkdirSync(hooksDest, { recursive: true });
|
||
const hookEntries = fs.readdirSync(hooksSrc);
|
||
if (hookEntries.some((e) => fs.statSync(path.join(hooksSrc, e)).isFile())) stagedHooks = true;
|
||
const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
|
||
for (const entry of hookEntries) {
|
||
const srcFile = path.join(hooksSrc, entry);
|
||
if (fs.statSync(srcFile).isFile()) {
|
||
const destFile = path.join(hooksDest, entry);
|
||
if (entry.endsWith('.js') || entry.endsWith('.cjs')) {
|
||
let content = fs.readFileSync(srcFile, 'utf8');
|
||
content = content.replace(/'\.claude'/g, configDirReplacement);
|
||
content = content.replace(/\/\.claude\//g, `/${getDirName(runtime)}/`);
|
||
content = content.replace(/\.claude\//g, `${getDirName(runtime)}/`);
|
||
// #376: rewrite msd: → msd- for hyphen-namespace runtimes
|
||
if (shouldNormalizeHyphenNamespaceInAgentBody(runtime)) {
|
||
content = content.replace(/msd:/gi, 'msd-');
|
||
}
|
||
content = content.replace(/\{\{MSD_VERSION\}\}/g, pkg.version);
|
||
fs.writeFileSync(destFile, content);
|
||
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
||
} else {
|
||
// non-.js: .sh hooks need {{MSD_VERSION}} stamped; others are copied as-is
|
||
if (entry.endsWith('.sh')) {
|
||
let content = fs.readFileSync(srcFile, 'utf8');
|
||
content = content.replace(/\{\{MSD_VERSION\}\}/g, pkg.version);
|
||
fs.writeFileSync(destFile, content);
|
||
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows doesn't support chmod */ }
|
||
} else {
|
||
fs.copyFileSync(srcFile, destFile);
|
||
}
|
||
}
|
||
} else if (fs.statSync(srcFile).isDirectory()) {
|
||
// #3579: recurse one level into hook subdirs (lib/ etc.). The
|
||
// graphify auto-update hook's rebuild helper lives at
|
||
// hooks/dist/lib/msd-graphify-rebuild.sh and must land at the
|
||
// mirrored target path so the hook's REBUILD_SCRIPT lookup resolves.
|
||
const subDest = path.join(hooksDest, entry);
|
||
fs.mkdirSync(subDest, { recursive: true });
|
||
const subEntries = fs.readdirSync(srcFile);
|
||
for (const subEntry of subEntries) {
|
||
const subSrcFile = path.join(srcFile, subEntry);
|
||
if (!fs.statSync(subSrcFile).isFile()) continue;
|
||
const subDestFile = path.join(subDest, subEntry);
|
||
if (subEntry.endsWith('.sh')) {
|
||
let content = fs.readFileSync(subSrcFile, 'utf8');
|
||
content = content.replace(/\{\{MSD_VERSION\}\}/g, pkg.version);
|
||
fs.writeFileSync(subDestFile, content);
|
||
try { fs.chmodSync(subDestFile, 0o755); } catch (e) { /* Windows */ }
|
||
} else {
|
||
fs.copyFileSync(subSrcFile, subDestFile);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (verifyInstalled(hooksDest, 'hooks')) {
|
||
console.log(` ${green}✓${reset} Installed ${SHARED_HOOKS_DIR} (bundled)`);
|
||
// Warn if expected community .sh hooks are missing (non-fatal)
|
||
const expectedShHooks = ['msd-session-state.sh', 'msd-validate-commit.sh', 'msd-phase-boundary.sh', 'msd-graphify-update.sh'];
|
||
for (const sh of expectedShHooks) {
|
||
if (!fs.existsSync(path.join(hooksDest, sh))) {
|
||
console.warn(` ${yellow}⚠${reset} Missing expected hook: ${sh}`);
|
||
}
|
||
}
|
||
} else {
|
||
hooksOk = false;
|
||
}
|
||
}
|
||
|
||
// Gate hooks/lib/ install on the same set of runtimes that receive hooks/.
|
||
// Codex/Cursor do not use the shared hooks/lib/ helpers (Cursor uses
|
||
// standalone .js hook scripts registered via hooks.json — gated
|
||
// descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089;
|
||
// Codex uses hooks.json directly); ZCode also skips hooks entirely
|
||
// (hooksSurface:'none' with no plugin surface — #1821). None of the
|
||
// excluded runtimes must receive the hooks/lib/ helpers — otherwise the
|
||
// Codex comment downstream ("we deliberately do *not* copy hooks/lib/ for
|
||
// Codex") is contradicted in practice. (Gating lives at the call sites
|
||
// below; this helper itself only checks source presence.)
|
||
const hooksLibSrc = path.join(src, 'hooks', 'lib');
|
||
if (fs.existsSync(hooksLibSrc)) {
|
||
const hooksLibDest = path.join(destRootDir, SHARED_HOOKS_DIR, 'lib');
|
||
fs.mkdirSync(hooksLibDest, { recursive: true });
|
||
copyLibDir(hooksLibSrc, hooksLibDest, MSD_HOOK_LIB_FILES);
|
||
if (MSD_HOOK_LIB_FILES.some((f) => fs.existsSync(path.join(hooksLibDest, f)))) stagedHooks = true;
|
||
console.log(` ${green}✓${reset} Installed ${SHARED_HOOKS_DIR}/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
|
||
}
|
||
|
||
// #2544: pin the staged hook scripts to CommonJS from inside hooks/ — the
|
||
// directory MSD just created and filled — instead of from destRootDir.
|
||
// Scoping the marker to MSD's own directory keeps `require` working in
|
||
// hooks/*.js and hooks/lib/*.js under any ambient "type": "module", while
|
||
// leaving the shared config root untouched.
|
||
//
|
||
// Gated on `stagedHooks`, NOT on the directory merely existing: hooks/ is
|
||
// shared space, so an existence check would drop a MSD marker into a
|
||
// hooks/ directory the user created and MSD never wrote to — the same
|
||
// write-into-someone-else's-territory this issue is about. And never
|
||
// written over a package.json MSD does not own.
|
||
//
|
||
// ALSO gated on `hooksOk`: `stagedHooks` is computed from the SOURCE
|
||
// listing before the copy loop, so it stays true when the copies land but
|
||
// `verifyInstalled` then fails. Marking a hooks/ MSD did not successfully
|
||
// populate as CommonJS claims an ownership the install did not earn — the
|
||
// two flags answer different questions ("did we intend to fill it" vs "is
|
||
// it actually filled"), and the marker needs both.
|
||
const hooksMarkerDir = path.join(destRootDir, SHARED_HOOKS_DIR);
|
||
if (stagedHooks && hooksOk) {
|
||
switch (ensureCommonJsMarker(hooksMarkerDir)) {
|
||
case 'written':
|
||
console.log(` ${green}✓${reset} Wrote ${SHARED_HOOKS_DIR}/package.json (CommonJS mode)`);
|
||
break;
|
||
case 'preserved-foreign':
|
||
// #4759: the foreign file usually DOES declare "type": "commonjs" —
|
||
// any hand-written or formatter-touched package.json does — and Node
|
||
// then loads the staged .js hooks as CommonJS, so the old
|
||
// unconditional "may not resolve" claim was usually false. The
|
||
// sibling plugin path (src/install-engine.cts) words this same
|
||
// outcome conditionally; match it and keep will-not-load conditional
|
||
// on "type": "module", the only case where it is true.
|
||
console.warn(` ${yellow}⚠${reset} Left existing ${SHARED_HOOKS_DIR}/package.json untouched (not MSD's marker). If it declares "type": "module", the staged hooks will not load.`);
|
||
break;
|
||
case 'failed':
|
||
// Best-effort: a read-only or full config dir must not abort the
|
||
// install with a raw stack trace. The hooks themselves are staged.
|
||
console.warn(` ${yellow}⚠${reset} Could not write ${SHARED_HOOKS_DIR}/package.json (CommonJS mode) — install continued; MSD hooks may not resolve as CommonJS`);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
return hooksOk;
|
||
}
|
||
|
||
// #1821: ZCode declares hooksSurface:'none' AND has no plugin surface,
|
||
// so the staged hook scripts are dead weight for it — excluded here.
|
||
// OpenCode also declares hooksSurface:'none' but is deliberately NOT excluded:
|
||
// its native plugin adapter (#1914, installed above under plugins/msd-core.js)
|
||
// spawns the staged hooks/*.js scripts via OpenCode's event bus and needs both
|
||
// them and the CommonJS package.json marker written below.
|
||
// #2089: Cursor's exclusion is now descriptor-driven via
|
||
// hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor).
|
||
// #2101: ZCode's exclusion is likewise descriptor-driven (zcode declares
|
||
// skipSharedHooksInstall:true) — the redundant `&& !isZcode` was removed.
|
||
if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true) {
|
||
if (!installSharedHooksBundle(targetDir)) {
|
||
failures.push('hooks');
|
||
}
|
||
}
|
||
|
||
// Install scripts/changeset/ and scripts/lib/ into <configDir>/scripts/
|
||
// so that `node "$MSD_DIR/scripts/changeset/cli.cjs"` resolves at runtime.
|
||
//
|
||
// The changeset CLI (scripts/changeset/cli.cjs) is invoked by the update
|
||
// workflow (msd-core/workflows/update.md) to extract changelog ranges for
|
||
// the /msd-update preview step. It was previously only present in the npm
|
||
// tarball root but never copied to the runtime config dir, causing the
|
||
// preview to always silently fail (#935).
|
||
//
|
||
// cli.cjs requires:
|
||
// - sibling files in scripts/changeset/ (parse/render/serialize/github-release-notes)
|
||
// - ../lib/cli-exit.cjs → scripts/lib/cli-exit.cjs
|
||
// - ../../msd-core/bin/lib/semver-compare.cjs (already installed under msd-core/)
|
||
// - ../../msd-core/bin/lib/package-identity.cjs (already installed under msd-core/)
|
||
//
|
||
// All runtimes that use the update workflow need this, so we copy unconditionally
|
||
// (same scope as msd-core/ itself — every runtime that installs workflows gets it).
|
||
const changesetSrc = path.join(src, 'scripts', 'changeset');
|
||
const scriptsLibSrc = path.join(src, 'scripts', 'lib');
|
||
if (!fs.existsSync(changesetSrc)) {
|
||
// The changeset CLI source is missing from the package — mark as a hard failure
|
||
// so the user knows the changelog preview will not work rather than silently degrading.
|
||
failures.push('scripts/changeset/ (source missing from package — reinstall from npm)');
|
||
} else {
|
||
const changesetDest = path.join(targetDir, 'scripts', 'changeset');
|
||
const scriptsLibDest = path.join(targetDir, 'scripts', 'lib');
|
||
fs.mkdirSync(changesetDest, { recursive: true });
|
||
fs.mkdirSync(scriptsLibDest, { recursive: true });
|
||
// Copy scripts/changeset/ — all .cjs and .md files
|
||
for (const entry of fs.readdirSync(changesetSrc)) {
|
||
const srcFile = path.join(changesetSrc, entry);
|
||
if (fs.statSync(srcFile).isFile()) {
|
||
fs.copyFileSync(srcFile, path.join(changesetDest, entry));
|
||
}
|
||
}
|
||
// Copy scripts/lib/ — cli-exit.cjs (required by cli.cjs) and any future lib helpers.
|
||
// Hard-fail if missing: without cli-exit.cjs the installed CLI throws MODULE_NOT_FOUND.
|
||
if (!fs.existsSync(scriptsLibSrc)) {
|
||
failures.push('scripts/lib/ (source missing from package — reinstall from npm)');
|
||
} else {
|
||
for (const entry of fs.readdirSync(scriptsLibSrc)) {
|
||
const srcFile = path.join(scriptsLibSrc, entry);
|
||
if (fs.statSync(srcFile).isFile()) {
|
||
fs.copyFileSync(srcFile, path.join(scriptsLibDest, entry));
|
||
}
|
||
}
|
||
// Verify the critical dep cli-exit.cjs landed
|
||
if (!verifyFileInstalled(path.join(scriptsLibDest, 'cli-exit.cjs'), 'scripts/lib/cli-exit.cjs')) {
|
||
failures.push('scripts/lib/cli-exit.cjs');
|
||
}
|
||
}
|
||
if (verifyFileInstalled(path.join(changesetDest, 'cli.cjs'), 'scripts/changeset/cli.cjs')) {
|
||
console.log(` ${green}✓${reset} Installed scripts/changeset/ (changelog preview CLI)`);
|
||
} else {
|
||
failures.push('scripts/changeset/cli.cjs');
|
||
}
|
||
}
|
||
|
||
// Copy scripts/fix-slash-commands.cjs — required by msd-core/bin/lib/command-roster.cjs
|
||
// at load time via require('../../../scripts/fix-slash-commands.cjs'). Without this file
|
||
// every msd-tools command crashes with MODULE_NOT_FOUND (#1223).
|
||
// This copy is independent of scripts/changeset/ — it must land even when the
|
||
// changeset CLI source is absent.
|
||
{
|
||
const fixSlashSrc = path.join(src, 'scripts', 'fix-slash-commands.cjs');
|
||
const fixSlashDest = path.join(targetDir, 'scripts', 'fix-slash-commands.cjs');
|
||
fs.mkdirSync(path.join(targetDir, 'scripts'), { recursive: true });
|
||
if (!fs.existsSync(fixSlashSrc)) {
|
||
failures.push('scripts/fix-slash-commands.cjs (source missing from package — reinstall from npm)');
|
||
} else {
|
||
fs.copyFileSync(fixSlashSrc, fixSlashDest);
|
||
if (!verifyFileInstalled(fixSlashDest, 'scripts/fix-slash-commands.cjs')) {
|
||
failures.push('scripts/fix-slash-commands.cjs');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Copy scripts/gen-capability-registry.cjs + scripts/gen-loop-host-contract.cjs —
|
||
// required by msd-core/bin/lib/capability-loader.cjs at overlay-composition time via
|
||
// require('../../../scripts/gen-capability-registry.cjs') (which itself requires
|
||
// gen-loop-host-contract.cjs). Without these, the loader's never-crash invariant
|
||
// discards EVERY third-party capability overlay and silently falls back to the frozen
|
||
// first-party registry, so installed capabilities are inert (#1920). Same class of
|
||
// gap as #1223 (fix-slash-commands.cjs) and copied unconditionally for the same reason:
|
||
// any runtime that installs msd-core/ needs the capability system to compose.
|
||
{
|
||
const capGenDestDir = path.join(targetDir, 'scripts');
|
||
fs.mkdirSync(capGenDestDir, { recursive: true });
|
||
for (const gen of ['gen-capability-registry.cjs', 'gen-loop-host-contract.cjs']) {
|
||
const genSrc = path.join(src, 'scripts', gen);
|
||
const genDest = path.join(capGenDestDir, gen);
|
||
if (!fs.existsSync(genSrc)) {
|
||
failures.push(`scripts/${gen} (source missing from package — reinstall from npm)`);
|
||
} else {
|
||
fs.copyFileSync(genSrc, genDest);
|
||
if (!verifyFileInstalled(genDest, `scripts/${gen}`)) {
|
||
failures.push(`scripts/${gen}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Remove legacy get-shit-done-cc artifacts and stale update caches (#607).
|
||
// cleanupLegacyMsdCc handles both the legacy shared cache and the per-package
|
||
// cache (formerly an inline unlinkSync here). A cleanup failure must never
|
||
// abort a successful install — log a warning and continue.
|
||
// install() is never reached in --dry-run mode (the early-exit at the CLI
|
||
// dispatch handles preview), so cleanup here always applies for real.
|
||
//
|
||
// #3799: when --config-dir redirected the install, the scan is SCOPED to
|
||
// that destination ([targetDir]) — the default home's live install must
|
||
// never be planned for removal from a sandboxed install. --no-legacy-cleanup
|
||
// skips the scan entirely.
|
||
const skipNoLegacyCleanup = parseNoLegacyCleanupArg();
|
||
const legacyCleanupScope = (explicitConfigDir !== null && isGlobal)
|
||
? [targetDir]
|
||
: undefined;
|
||
if (!skipNoLegacyCleanup) {
|
||
try {
|
||
cleanupLegacyMsdCc({ dryRun: false, ...(legacyCleanupScope ? { configDirs: legacyCleanupScope } : {}) });
|
||
} catch (cleanupErr) {
|
||
console.warn(` ${yellow}Warning: legacy cleanup failed: ${cleanupErr.message}${reset}`);
|
||
}
|
||
}
|
||
|
||
if (failures.length > 0) {
|
||
console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// Write file manifest for future modification detection
|
||
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
|
||
console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
|
||
|
||
// Report any backed-up local patches
|
||
reportLocalPatches(targetDir, runtime);
|
||
|
||
// #2873: cross-scope shadow report. Fires ONCE per install (this is the
|
||
// only writeManifest call site that gets it — the other four sites are
|
||
// sub-writes within a single install, not separate installs). A shadowed
|
||
// install is a warning, never a failure (ADR-2866 Consequences), so this
|
||
// never touches `failures` or `process.exit`, and the whole block is
|
||
// wrapped in a try/catch that swallows everything: a report failure must
|
||
// never fail an otherwise-successful install (design row C5). No options
|
||
// are injected into buildShadowReport — this is the production call shape,
|
||
// resolving the real machine via os.homedir()/process.cwd() defaults
|
||
// inside the resolver.
|
||
try {
|
||
const shadowReport = buildShadowReport(runtime);
|
||
const shadowLines = renderShadowReport(shadowReport);
|
||
if (shadowLines.length > 0) {
|
||
console.warn(`\n ${yellow}⚠${reset} ${shadowLines[0]}`);
|
||
for (const line of shadowLines.slice(1)) {
|
||
console.warn(` ${dim}${line}${reset}`);
|
||
}
|
||
}
|
||
} catch (_shadowReportErr) {
|
||
// Never fail an install over a reporting concern — see comment above.
|
||
}
|
||
|
||
// Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped)
|
||
if (!_hostBehaviors(runtime).ownsClaudePaths) {
|
||
const leakedPaths = [];
|
||
// Only scan files that were written by this install (manifest-tracked).
|
||
// Scanning the entire targetDir can match user-authored content that
|
||
// legitimately references ~/.claude (e.g. personal notes), producing
|
||
// false-positive warnings. Restricting to the manifest avoids that.
|
||
let manifestFiles = null;
|
||
try {
|
||
const manifestPath = path.join(targetDir, MANIFEST_NAME);
|
||
if (fs.existsSync(manifestPath)) {
|
||
const manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||
if (manifestData && typeof manifestData.files === 'object') {
|
||
manifestFiles = Object.keys(manifestData.files);
|
||
}
|
||
}
|
||
} catch (_manifestParseErr) {
|
||
// If we cannot read/parse the manifest, skip the scan entirely to
|
||
// avoid false positives rather than falling back to a full directory walk.
|
||
manifestFiles = null;
|
||
}
|
||
if (manifestFiles !== null) {
|
||
// #4667: codex-installed artifacts must not keep `@~/.claude/msd-core/…`
|
||
// include references — the `@` form resolves into the CLAUDE install
|
||
// (wrong copy on dual-runtime machines at divergent versions, nothing at
|
||
// all on codex-only ones; #570 cause 2 residue). Every target ships in
|
||
// the codex install, so rewriting the `@~/` include form to the codex
|
||
// root is mechanical and correct. This runs after all .md emitters
|
||
// (several bypass the per-runtime converters — that is how the leak
|
||
// survived the per-emitter fixes; the agent .tomls are generated later
|
||
// and prefix themselves), and before the scan below, which stays as the
|
||
// verification backstop. The `_MSD_RUNTIME_ROOT`/`$PREFERRED_CONFIG_DIR`
|
||
// fallback chains and prose `.claude` mentions carry no `@~/` prefix and
|
||
// are deliberately untouched, as is CHANGELOG.md.
|
||
if (runtime === 'codex') {
|
||
for (const relPath of manifestFiles) {
|
||
const fileName = path.basename(relPath);
|
||
if (!(fileName.endsWith('.md') || fileName.endsWith('.toml'))) continue;
|
||
if (fileName === 'CHANGELOG.md') continue;
|
||
const rewritePath = path.join(targetDir, relPath);
|
||
let rewriteContent;
|
||
try {
|
||
rewriteContent = fs.readFileSync(rewritePath, 'utf8');
|
||
} catch (rewriteErr) {
|
||
continue; // inaccessible or missing — the scan below reports or skips it
|
||
}
|
||
const rewritten = rewriteContent
|
||
.split('@~/.claude/msd-core/').join('@~/.codex/msd-core/')
|
||
.split('@$HOME/.claude/msd-core/').join('@$HOME/.codex/msd-core/');
|
||
if (rewritten !== rewriteContent) {
|
||
try {
|
||
fs.writeFileSync(rewritePath, rewritten);
|
||
} catch (writeErr) {
|
||
continue; // never fail the install over the rewrite; the scan still warns
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (const relPath of manifestFiles) {
|
||
const fileName = path.basename(relPath);
|
||
if (!(fileName.endsWith('.md') || fileName.endsWith('.toml'))) continue;
|
||
if (fileName === 'CHANGELOG.md') continue;
|
||
const fullPath = path.join(targetDir, relPath);
|
||
let content;
|
||
try {
|
||
content = fs.readFileSync(fullPath, 'utf8');
|
||
} catch (err) {
|
||
if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ENOENT') {
|
||
continue; // skip inaccessible or missing files
|
||
}
|
||
throw err;
|
||
}
|
||
const matches = content.match(/(?:~|\$HOME)\/\.claude\b/g);
|
||
if (matches) {
|
||
leakedPaths.push({ file: relPath, count: matches.length });
|
||
}
|
||
}
|
||
}
|
||
if (leakedPaths.length > 0) {
|
||
const totalLeaks = leakedPaths.reduce((sum, l) => sum + l.count, 0);
|
||
console.warn(`\n ${yellow}⚠${reset} Found ${totalLeaks} unreplaced .claude path reference(s) in ${leakedPaths.length} file(s):`);
|
||
for (const leak of leakedPaths.slice(0, 5)) {
|
||
console.warn(` ${dim}${leak.file}${reset} (${leak.count})`);
|
||
}
|
||
if (leakedPaths.length > 5) {
|
||
console.warn(` ${dim}... and ${leakedPaths.length - 5} more file(s)${reset}`);
|
||
}
|
||
console.warn(` ${dim}These paths may not resolve correctly for ${runtimeLabel}.${reset}`);
|
||
}
|
||
}
|
||
|
||
} catch (_earlyInstallErr) {
|
||
// Installer Migration Module Phase 4: docs/installer-migrations.md
|
||
// requires safe migrations to run before package materialization without
|
||
// leaving stale state behind when materialization fails. Roll migration
|
||
// actions back for every runtime; Codex then layers its broader runtime
|
||
// snapshot rollback on top.
|
||
rollbackInstallerMigrations();
|
||
// #3245 CR finding 2 — any throw in the pre-config install operations (skills copy,
|
||
// agents copy, VERSION write, manifest write, etc.) triggers the Codex pre-config
|
||
// rollback so the caller is never left in a partially-installed state.
|
||
// (The second, identical rollbackInstallerMigrations() that used to sit here was
|
||
// a duplicate of the line above, not a second phase — removed in #3725 review.)
|
||
// #3712 — the test-home guard refuses before any LAYOUT-DRIVEN write, so no
|
||
// msd-* directory in the skills root has been touched and there is nothing
|
||
// there to undo. (Legacy install migrations DO run first; that is why the
|
||
// rollbackInstallerMigrations() calls above still execute, and why the one
|
||
// migration that can reach a `home` override carries its own assertion.)
|
||
// Running the codex rollback anyway would delete and recreate every
|
||
// snapshotted msd-* directory in the resolved skills root, which for an
|
||
// un-sandboxed codex install IS the real ~/.agents/skills: the guard's own
|
||
// refusal would provoke the mutation it exists to prevent. This is the only
|
||
// _codexPreConfigRollback() call site, and applySurface/uninstall cannot
|
||
// reach it. Every other error still rolls back. Found by review, not by CI.
|
||
if (isTestHomeGuardRefusal(_earlyInstallErr)) throw _earlyInstallErr;
|
||
if (_codexPreConfigRollback) {
|
||
_codexPreConfigRollback();
|
||
}
|
||
throw _earlyInstallErr;
|
||
}
|
||
|
||
// #2695: this branch runs for BOTH `core` (minimal) and `full` profiles.
|
||
// Hooks are lightweight infrastructure (update-check + context monitor), not the
|
||
// "full agent surface" that `core` deliberately omits. The config.toml / agent
|
||
// generation below is still gated by its own inner `!isMinimalMode` guard, so
|
||
// `core` enters the branch to receive the hook-file copy + hooks.json wiring but
|
||
// does NOT get agent roles generated. Before #2695 the outer `!isMinimalMode`
|
||
// here skipped the whole branch for `core`, so the registered parent hook pointed
|
||
// at a worker/registry the same installer never delivered.
|
||
if (plan.installSurface === 'codex-toml') {
|
||
// Capture pre-install snapshots before ANY MSD mutation
|
||
// (#2760 fix 3). On post-write schema-validation failure OR any throw
|
||
// during the mutation sequence (write failure, merge throw, etc.) we
|
||
// restore these exact bytes so the user is never left with a broken
|
||
// Codex CLI (#2760 fix 4 — extends snapshot coverage to write-failure
|
||
// paths, paired with atomic temp-file writes in mergeCodexConfig and
|
||
// the final hooks-write below).
|
||
const codexConfigPathPreInstall = path.join(targetDir, 'config.toml');
|
||
const codexConfigPreInstallSnapshot = fs.existsSync(codexConfigPathPreInstall)
|
||
? fs.readFileSync(codexConfigPathPreInstall)
|
||
: null;
|
||
const codexHooksJsonPathPreInstall = path.join(targetDir, 'hooks.json');
|
||
const codexHooksJsonPreInstallSnapshot = fs.existsSync(codexHooksJsonPathPreInstall)
|
||
? fs.readFileSync(codexHooksJsonPathPreInstall)
|
||
: null;
|
||
const migrationTouchesHooksJson =
|
||
!!(installerMigrationResult
|
||
&& installerMigrationResult.plan
|
||
&& Array.isArray(installerMigrationResult.plan.actions)
|
||
&& installerMigrationResult.plan.actions.some((action) => action && action.relPath === 'hooks.json'));
|
||
|
||
// #3245 — unified idempotent rollback. Reverts ALL Codex-specific mutations:
|
||
// config.toml — restore pre-install bytes (or remove if was absent)
|
||
// hooks.json — restore pre-install bytes (or remove if was absent)
|
||
// skills/msd-* — restore pre-existing dirs from content snapshot; remove
|
||
// newly-created dirs (i.e. those not in the pre-install Set)
|
||
// agents/msd-* — restore pre-existing files from content snapshot; remove
|
||
// newly-created files
|
||
// msd-core/VERSION — restore or remove
|
||
// *.tmp-* — best-effort cleanup of installer-owned atomic-write temps
|
||
//
|
||
// Safe to call multiple times (idempotent): each remove/write is guarded by
|
||
// existence checks. Safe to call before any snapshots are captured (variables
|
||
// default to empty Set / null). Does NOT touch non-msd-* user content.
|
||
const restoreCodexSnapshot = () => {
|
||
rollbackInstallerMigrations();
|
||
// 1. config.toml
|
||
if (codexConfigPreInstallSnapshot !== null) {
|
||
try { fs.writeFileSync(codexConfigPathPreInstall, codexConfigPreInstallSnapshot); }
|
||
catch (_) { /* best-effort restore — surface the original error */ }
|
||
} else if (fs.existsSync(codexConfigPathPreInstall)) {
|
||
try { fs.rmSync(codexConfigPathPreInstall); } catch (_) { /* best-effort */ }
|
||
}
|
||
|
||
// 1b. hooks.json
|
||
// If installer migrations touched hooks.json, rollbackInstallerMigrations()
|
||
// already restored the pre-migration file. Don't overwrite that state with
|
||
// a post-migration snapshot.
|
||
if (!migrationTouchesHooksJson) {
|
||
if (codexHooksJsonPreInstallSnapshot !== null) {
|
||
try { fs.writeFileSync(codexHooksJsonPathPreInstall, codexHooksJsonPreInstallSnapshot); }
|
||
catch (_) { /* best-effort restore — surface the original error */ }
|
||
} else if (fs.existsSync(codexHooksJsonPathPreInstall)) {
|
||
try { fs.rmSync(codexHooksJsonPathPreInstall); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
|
||
// 2. skills/msd-*
|
||
// • Dirs that pre-existed: wipe current contents, restore snapshotted files.
|
||
// The restore iterates the SNAPSHOT manifest (codexPreInstallSkillNames) rather
|
||
// than just the current filesystem so that dirs deleted during the install
|
||
// (copyCommandsAsCodexSkills removes pre-existing msd-* dirs before re-writing)
|
||
// are restored even when they are absent from disk at rollback time (#3245 CR).
|
||
// • Dirs that did not pre-exist: remove entirely.
|
||
const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
|
||
// Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
|
||
for (const skillName of codexPreInstallSkillNames) {
|
||
const skillDirPath = path.join(_rollbackSkillsDir, skillName);
|
||
const fileMap = codexPreInstallSkillContents.get(skillName);
|
||
try {
|
||
fs.rmSync(skillDirPath, { recursive: true, force: true });
|
||
fs.mkdirSync(skillDirPath, { recursive: true });
|
||
if (fileMap) {
|
||
for (const [relPath, buf] of fileMap) {
|
||
const destFile = path.join(skillDirPath, relPath);
|
||
try {
|
||
fs.mkdirSync(path.dirname(destFile), { recursive: true });
|
||
fs.writeFileSync(destFile, buf);
|
||
} catch (_) { /* best-effort file restore */ }
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort dir restore */ }
|
||
}
|
||
// Pass 2 — remove any newly-created msd-* dirs (not in the pre-install snapshot).
|
||
if (fs.existsSync(_rollbackSkillsDir)) {
|
||
try {
|
||
for (const entry of fs.readdirSync(_rollbackSkillsDir, { withFileTypes: true })) {
|
||
if (!entry.isDirectory() || !entry.name.startsWith('msd-')) continue;
|
||
if (!codexPreInstallSkillNames.has(entry.name)) {
|
||
// New dir written this session: remove entirely.
|
||
try { fs.rmSync(path.join(_rollbackSkillsDir, entry.name), { recursive: true, force: true }); }
|
||
catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
|
||
// 3. agents/msd-*.{md,toml}
|
||
// • Files that pre-existed: restore bytes from content snapshot.
|
||
// Iterates the SNAPSHOT manifest (codexPreInstallAgentFiles) so that files
|
||
// deleted by the pre-copy stale-removal pass (lines 7862-7870) are restored
|
||
// even when absent from disk at rollback time (#3245 CR).
|
||
// • Files that did not pre-exist: remove.
|
||
const _rollbackAgentsDir = path.join(targetDir, 'agents');
|
||
// Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
|
||
for (const file of codexPreInstallAgentFiles) {
|
||
const buf = codexPreInstallAgentContents.get(file);
|
||
if (buf !== undefined) {
|
||
try {
|
||
fs.mkdirSync(_rollbackAgentsDir, { recursive: true });
|
||
fs.writeFileSync(path.join(_rollbackAgentsDir, file), buf);
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
// Pass 2 — remove any newly-created msd-* agent files (not in the pre-install snapshot).
|
||
if (fs.existsSync(_rollbackAgentsDir)) {
|
||
try {
|
||
for (const file of fs.readdirSync(_rollbackAgentsDir)) {
|
||
if (!file.startsWith('msd-') || (!file.endsWith('.md') && !file.endsWith('.toml'))) continue;
|
||
if (!codexPreInstallAgentFiles.has(file)) {
|
||
// New file written this session: remove.
|
||
try { fs.unlinkSync(path.join(_rollbackAgentsDir, file)); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
|
||
// 4. msd-core/VERSION
|
||
const _rollbackVersionPath = path.join(targetDir, 'msd-core', 'VERSION');
|
||
if (codexPreInstallVersionBytes !== null) {
|
||
try { fs.writeFileSync(_rollbackVersionPath, codexPreInstallVersionBytes); }
|
||
catch (_) { /* best-effort */ }
|
||
} else if (fs.existsSync(_rollbackVersionPath)) {
|
||
try { fs.unlinkSync(_rollbackVersionPath); } catch (_) { /* best-effort */ }
|
||
}
|
||
|
||
// 4b. #4544 — manifest-driven surfaces: the staged hooks/ tree, every
|
||
// MSD-owned path the prior manifest recorded (scripts/, msd-core/
|
||
// payload), and the prior manifest file itself.
|
||
restoreCodexManagedSnapshot();
|
||
|
||
// 5. Orphaned atomic-write temp files (<file>.tmp-<pid>-<n>) in targetDir.
|
||
// These can accumulate if an atomic write fails mid-rename. Best-effort scan.
|
||
//
|
||
// Only delete temp files whose absolute path is in __atomicWrittenTmps —
|
||
// the Set populated by atomicWriteFileSync for every temp this installer
|
||
// process actually created. This scopes cleanup to installer-owned writes
|
||
// and avoids clobbering unrelated tools' temp files that happen to match
|
||
// the same *.tmp-<pid>-<n> suffix pattern.
|
||
const _tmpPattern = /\.tmp-\d+-\d+$/;
|
||
function _cleanTmpFiles(dir) {
|
||
if (!fs.existsSync(dir)) return;
|
||
let entries;
|
||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
||
for (const entry of entries) {
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
_cleanTmpFiles(full);
|
||
} else if (_tmpPattern.test(entry.name) && __atomicWrittenTmps.has(full)) {
|
||
try { fs.unlinkSync(full); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
}
|
||
_cleanTmpFiles(targetDir);
|
||
};
|
||
|
||
let agentCount = 0;
|
||
if (!isMinimalMode(_effectiveInstallMode)) {
|
||
// #2834: write ~/.msd/defaults.json (resolve_model_ids + runtime) BEFORE generating
|
||
// agent TOMLs — installCodexConfig reads defaults.json at generation time, so on a
|
||
// clean first install the runtime-aware model resolver must already know the runtime.
|
||
writeNonClaudeDefaults(runtime);
|
||
try {
|
||
// Generate Codex config.toml and per-agent .toml files.
|
||
agentCount = installCodexConfig(targetDir, agentsSrc, plan.sandboxTier);
|
||
} catch (e) {
|
||
restoreCodexSnapshot();
|
||
throw e;
|
||
}
|
||
console.log(` ${green}✓${reset} Generated config.toml with ${agentCount} agent roles`);
|
||
console.log(` ${green}✓${reset} Generated ${agentCount} agent .toml config files`);
|
||
// Re-write the manifest now that .toml agent files exist on disk.
|
||
// The initial writeManifest call (before Codex config generation) could
|
||
// not include agents/msd-*.toml because those files did not yet exist.
|
||
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
|
||
} else {
|
||
console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
|
||
}
|
||
|
||
// Copy only the hook files that Codex actually registers via its hook configuration (#2153).
|
||
// #772: added msd-context-monitor.js for the new SubagentStart/Stop/PostToolUse events.
|
||
// #2695: the parent msd-check-update.js spawn()s msd-check-update-worker.js, which
|
||
// require()s managed-hooks-registry.cjs for MANAGED_HOOKS — so all four must be
|
||
// installed/refreshed together for every profile, or Codex is wired to a dependency
|
||
// chain the same installer never delivers.
|
||
// We deliberately do *not* copy msd-graphify-update.sh for Codex in this
|
||
// change (graphify auto-update support for Codex is out of scope for #3579).
|
||
// hooks/lib/ WAS excluded here for the same reason, and that stopped being
|
||
// correct when #3911 (2ea5efc15) gave msd-context-monitor.js a real
|
||
// `require('./lib/hook-exit.js')`: the allowlist below is flat and never
|
||
// recursed, so the hook shipped without its helper and died with
|
||
// MODULE_NOT_FOUND at load, before its own try/catch, on every registered
|
||
// event (#4087, #4098). The libs are now derived from what the staged
|
||
// scripts actually require rather than hand-listed — see the
|
||
// stageTransitiveHookLibs call after the copy loop. The #3579 boundary is
|
||
// preserved: helpers no staged Codex hook requires (graphify tooling among
|
||
// them) are still not shipped.
|
||
// #2586: msd-context-monitor.js is deliberately NOT copied for Codex.
|
||
// It reads the statusline bridge file (${TMPDIR}/claude-ctx-{session_id}.json)
|
||
// written only by hooks/msd-statusline.js, which Codex never installs — so
|
||
// every registered event was a guaranteed silent no-op (readSentinel throws
|
||
// ENOENT -> allow(undefined), every invocation, every event, no exceptions).
|
||
// A pre-#2586 install's stale copy + hooks.json registrations are cleaned
|
||
// up below (see the CODEX_EXTENDED_HOOK_EVENTS loop), not re-added here.
|
||
// CODEX_HOOKS_TO_COPY itself lives at module scope (#4544) — the rollback's
|
||
// incomplete-capture path must name the same set without a second literal.
|
||
const codexHooksSrc = path.join(src, 'hooks', 'dist');
|
||
if (fs.existsSync(codexHooksSrc)) {
|
||
const codexHooksDest = path.join(targetDir, 'hooks');
|
||
fs.mkdirSync(codexHooksDest, { recursive: true });
|
||
const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
|
||
// #2544: track whether anything was actually staged. hooks/dist existing
|
||
// is not the same as an allowlisted file landing in it — see the marker
|
||
// gate below.
|
||
let codexStagedHooks = false;
|
||
// The entries THIS invocation actually staged. Seeding the lib scan from
|
||
// `existsSync` over the destination instead would also pick up a file
|
||
// left by a PREVIOUS install whose source is no longer staged — e.g. a
|
||
// name dropped from the allowlist — and derive helpers for a hook that is
|
||
// no longer shipped (review of #4087).
|
||
const codexStagedEntries = [];
|
||
for (const entry of fs.readdirSync(codexHooksSrc)) {
|
||
if (!CODEX_HOOKS_TO_COPY.includes(entry)) continue;
|
||
const srcFile = path.join(codexHooksSrc, entry);
|
||
if (!fs.statSync(srcFile).isFile()) continue;
|
||
const destFile = path.join(codexHooksDest, entry);
|
||
if (entry.endsWith('.js')) {
|
||
let content = fs.readFileSync(srcFile, 'utf8');
|
||
content = content.replace(/'\.claude'/g, configDirReplacement);
|
||
content = content.replace(/\/\.claude\//g, `/${getDirName(runtime)}/`);
|
||
content = content.replace(/\.claude\//g, `${getDirName(runtime)}/`);
|
||
content = content.replace(/\{\{MSD_VERSION\}\}/g, pkg.version);
|
||
fs.writeFileSync(destFile, content);
|
||
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
||
} else if (entry.endsWith('.sh')) {
|
||
// #2136: any .sh hook reaching this loop must have {{MSD_VERSION}}
|
||
// stamped so installed scripts carry a concrete version header and
|
||
// stale-hook detection keeps working across upgrades. The current
|
||
// CODEX_HOOKS_TO_COPY allowlist excludes .sh files, so this branch
|
||
// is defensive — it preserves the invariant if the allowlist is
|
||
// extended later (e.g. to ship msd-graphify-update.sh for Codex).
|
||
let content = fs.readFileSync(srcFile, 'utf8');
|
||
content = content.replace(/\{\{MSD_VERSION\}\}/g, pkg.version);
|
||
fs.writeFileSync(destFile, content);
|
||
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
||
} else {
|
||
// #2695: raw byte-for-byte copy for allowlisted artifacts that carry
|
||
// no {{MSD_VERSION}} placeholder and no runtime path token (e.g.
|
||
// managed-hooks-registry.cjs, whose only `.claude` mention is inside a
|
||
// doc comment). Version/path transforms would be a no-op at best and a
|
||
// surprise at worst; the issue requires the registry copied verbatim.
|
||
fs.copyFileSync(srcFile, destFile);
|
||
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
||
}
|
||
codexStagedHooks = true;
|
||
codexStagedEntries.push(entry);
|
||
}
|
||
// Stage the hooks/lib/ helpers the staged scripts require, transitively
|
||
// (#4087, #4098). Shares writeCursorHooksJson's walker rather than a
|
||
// second copy: both reduced bundles hand-pick SCRIPTS, and the identical
|
||
// MODULE_NOT_FOUND was already fixed once for Cursor in 704859e9c. A flat
|
||
// list of today's three helpers would re-break the next time a
|
||
// Codex-bundled hook grows a lib dependency, which is exactly how this
|
||
// regressed. Gated on codexStagedHooks for the same reason the CommonJS
|
||
// marker below is: hooks/ is shared space, and staging nothing must not
|
||
// leave a MSD-owned lib/ behind in a directory MSD created but did not
|
||
// fill (#2544). Seeded from the copies staged by THIS invocation, so the
|
||
// scan sees the same bytes Node will load and never derives helpers for a
|
||
// hook left behind by an earlier install.
|
||
let codexStagedLibs = [];
|
||
if (codexStagedHooks) {
|
||
codexStagedLibs = hooksSurface.stageTransitiveHookLibs({
|
||
seedSources: codexStagedEntries
|
||
.map((entry) => fs.readFileSync(path.join(codexHooksDest, entry), 'utf8')),
|
||
srcLibDir: path.join(codexHooksSrc, 'lib'),
|
||
destLibDir: path.join(codexHooksDest, 'lib'),
|
||
runtimeLabel: 'Codex',
|
||
// Same substitutions the .js branch above applies to hook scripts, so
|
||
// a helper that ever gains a runtime path or version token is
|
||
// rewritten identically instead of shipping a Claude-shaped path.
|
||
// No-ops on today's helpers, which carry neither.
|
||
transform: (content) => content
|
||
.replace(/'\.claude'/g, configDirReplacement)
|
||
.replace(/\/\.claude\//g, `/${getDirName(runtime)}/`)
|
||
.replace(/\.claude\//g, `${getDirName(runtime)}/`)
|
||
.replace(/\{\{MSD_VERSION\}\}/g, pkg.version),
|
||
});
|
||
}
|
||
console.log(` ${green}✓${reset} Installed hooks (Codex)`);
|
||
if (codexStagedLibs.length > 0) {
|
||
console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (${codexStagedLibs.join(', ')})`);
|
||
}
|
||
// #2717: write the CommonJS marker into hooks/ alongside the staged .js
|
||
// scripts. Codex is excluded from installSharedHooksBundle by the
|
||
// !isCodex gate, so it never received the marker the shared-bundle path
|
||
// writes for the other runtimes. Without it, a ~/.codex/package.json
|
||
// declaring {"type":"module"} makes Node load msd-check-update.js /
|
||
// msd-context-monitor.js as ESM and their require() calls fail silently.
|
||
// Reuses the same helper the Cursor writer calls so the marker
|
||
// content + user-file-preservation contract is identical everywhere.
|
||
//
|
||
// #2544: gated on codexStagedHooks, mirroring installSharedHooksBundle's
|
||
// `stagedHooks`. The enclosing guard only proves hooks/dist EXISTS; if it
|
||
// holds none of CODEX_HOOKS_TO_COPY, this block mkdirs hooks/ and stages
|
||
// nothing, and an ungated marker would claim a directory MSD did not fill.
|
||
if (codexStagedHooks && hooksSurface.ensureCommonJsMarker(codexHooksDest)) {
|
||
console.log(` ${green}✓${reset} Wrote hooks/package.json (CommonJS mode)`);
|
||
}
|
||
}
|
||
|
||
// Add Codex hooks (SessionStart for update checking) — requires codex_hooks feature flag
|
||
const configPath = path.join(targetDir, 'config.toml');
|
||
// Use the pre-install snapshot captured before installCodexConfig ran so
|
||
// restore returns the file to its true pre-MSD state on validation
|
||
// failure (#2760 fix 3) — not to the post-agent-merge state.
|
||
const preWriteBackup = codexConfigPreInstallSnapshot;
|
||
try {
|
||
let configContent = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : '';
|
||
const eol = detectLineEnding(configContent);
|
||
|
||
// Strip ALL prior MSD-managed hook blocks BEFORE migration so the migration
|
||
// only touches user-authored hooks, not MSD-owned stale entries. Running
|
||
// strip after migration causes Shape 1 (legacy msd-update-check filename)
|
||
// to be converted by migration before the strip regex can match it (#2698).
|
||
//
|
||
// Historical shapes stripped, in order:
|
||
// Shape 1 — legacy msd-update-check filename (pre-#1755): flat [[hooks]] + event
|
||
// Shape 2 — flat [[hooks]] + event = "SessionStart" (#2637 era, never correct)
|
||
// Shape 4 — correct two-block nested (strip before shape 3 to avoid orphaned header)
|
||
// Shape 3 — single-block [[hooks.SessionStart]] without nested .hooks (#2760 era)
|
||
configContent = stripStaleMsdHookBlocks(configContent);
|
||
|
||
// Migrate legacy [hooks] map format and flat [[hooks]] AoT entries to the
|
||
// namespaced [[hooks.<EVENT>]] form after stripping MSD-managed stale blocks.
|
||
// Running migration after strip ensures only user-authored hooks are migrated
|
||
// (#2698 regression: migration before strip converts stale MSD blocks before
|
||
// the strip regexes can match their original shape).
|
||
const migratedContent = migrateCodexHooksMapFormat(configContent);
|
||
if (migratedContent !== configContent) {
|
||
configContent = migratedContent;
|
||
console.log(` ${green}✓${reset} Migrated legacy Codex [hooks] format to two-level nested AoT`);
|
||
}
|
||
|
||
const codexHooksFeature = ensureCodexHooksFeature(configContent);
|
||
configContent = setManagedCodexHooksOwnership(codexHooksFeature.content, codexHooksFeature.ownership);
|
||
|
||
// MSD-managed Codex hook payloads now live in hooks.json to avoid mixed
|
||
// representation warnings when a single layer contains both hooks.json
|
||
// and inline [hooks] entries. Keep config.toml focused on feature flags
|
||
// and agent metadata.
|
||
const codexNodeRunner = resolveNodeRunner();
|
||
|
||
// #2760 fix 3 — post-write schema validation. Parse the bytes we are
|
||
// about to commit and assert they match Codex's expected shape. If
|
||
// validation fails we restore the pre-install backup and abort so the
|
||
// user is never left with a Codex CLI that won't load.
|
||
// Test seam: tests can inject `__codexSchemaValidator` to force the
|
||
// validator to fail and exercise the restore-and-abort path.
|
||
const validatorFn = (typeof module !== 'undefined' && module.exports && module.exports.__codexSchemaValidator)
|
||
? module.exports.__codexSchemaValidator
|
||
: validateCodexConfigSchema;
|
||
const validation = validatorFn(configContent);
|
||
if (!validation.ok) {
|
||
restoreCodexSnapshot();
|
||
throw new Error(
|
||
`post-write Codex schema validation failed: ${validation.reason}. ` +
|
||
`Restored ${preWriteBackup !== null ? 'pre-install backup' : 'empty state'}.`
|
||
);
|
||
}
|
||
|
||
// Atomic write (#2760 fix 4) — write to a sibling temp file, then
|
||
// renameSync over the target. A mid-write failure cannot truncate the
|
||
// existing config; the snapshot restore below is a second line of
|
||
// defense if even the rename fails.
|
||
try {
|
||
atomicWriteFileSync(configPath, configContent, 'utf-8');
|
||
} catch (writeErr) {
|
||
// #2760 CR4 finding 1 — write failure must be loud and fatal. Wrap
|
||
// with a `post-write` prefix the outer catch recognises so install
|
||
// aborts with a clear error rather than warn-and-continue (which
|
||
// produced "Done!" with no Codex agents configured).
|
||
restoreCodexSnapshot();
|
||
const wrapped = new Error(
|
||
`post-write Codex install failed: ${writeErr && writeErr.message ? writeErr.message : String(writeErr)}. ` +
|
||
`Restored ${preWriteBackup !== null ? 'pre-install backup' : 'empty state'}.`
|
||
);
|
||
throw wrapped;
|
||
}
|
||
if (hasEnabledCodexHooksFeature(configContent)) {
|
||
const checkUpdateFile = path.join(targetDir, 'hooks', 'msd-check-update.js');
|
||
if (!fs.existsSync(checkUpdateFile)) {
|
||
console.warn(` ${yellow}⚠${reset} Skipped Codex SessionStart hook registration — msd-check-update.js not found at target`);
|
||
} else if (!codexNodeRunner) {
|
||
console.warn(` ${yellow}⚠${reset} Skipping Codex SessionStart hook registration — Node executable path unavailable (process.execPath is empty). See #2979 / #3002 / #3017.`);
|
||
} else {
|
||
const hookWrite = ensureCodexHooksJsonSessionStart(targetDir, {
|
||
absoluteRunner: codexNodeRunner,
|
||
platform: process.platform,
|
||
});
|
||
configuredEntrypoints.push(...(hookWrite.configuredEntrypoints || []));
|
||
if (hookWrite.wrote) {
|
||
console.log(` ${green}✓${reset} Configured Codex hooks (SessionStart via hooks.json)`);
|
||
} else {
|
||
console.log(` ${green}✓${reset} Verified Codex hooks (SessionStart via hooks.json)`);
|
||
}
|
||
}
|
||
|
||
// #2586: Codex's hook payload carries no context/token-usage field
|
||
// (confirmed against codex-rs/hooks/src/schema.rs), so agent-facing
|
||
// context warnings and MSD phase/lifecycle display cannot be
|
||
// supported on this runtime — state that plainly during install
|
||
// rather than silently omitting the capability. Matches
|
||
// capabilities/codex/capability.json's hostBehaviors.unsupportedFeatures.
|
||
console.log(` ${dim}↳${reset} Codex: agent-facing context warnings and MSD phase/lifecycle display are unsupported (Codex's hook payload has no context-usage metric)`);
|
||
|
||
// ── Codex extended hook events (#772, #2088) — REMOVED by #2586 ──────
|
||
// msd-context-monitor.js is no longer copied or registered for Codex
|
||
// (see the CODEX_HOOKS_TO_COPY comment above): every one of these
|
||
// events was a guaranteed silent no-op, since the metrics bridge file
|
||
// it reads is only ever written by Claude's own statusline hook.
|
||
// Every event in CODEX_EXTENDED_HOOK_EVENTS is unconditionally
|
||
// reconciled here — not gated on the script existing — so a
|
||
// pre-#2586 install's stale registrations (exact current shape, or a
|
||
// recognized legacy shape via isManagedHookCommand's
|
||
// includeLegacyAliases) are stripped on reinstall. Mirrors the
|
||
// unconditional uninstall-time loop over the same constant. A
|
||
// registration whose command does not match the managed shape (a
|
||
// hand-customized entry) survives untouched — see
|
||
// reconcileCodexHooksJsonEvent's isManagedHookCommand filter.
|
||
for (const codexEvent of CODEX_EXTENDED_HOOK_EVENTS) {
|
||
const eventCleanup = removeCodexHooksJsonEvent(targetDir, codexEvent);
|
||
if (eventCleanup.changed) {
|
||
console.log(` ${green}✓${reset} Removed stale Codex ${codexEvent} context-monitor hook from hooks.json`);
|
||
}
|
||
}
|
||
// Delete the orphaned script (+ Windows .cmd shim) left by a
|
||
// pre-#2586 install, but ONLY once no surviving hooks.json
|
||
// registration under any event still references it, and only when
|
||
// the on-disk file is MSD's own (see design doc's Ownership check —
|
||
// a content-signature check, not manifest membership, so this works
|
||
// on the very first reinstall after upgrading, with no bootstrap
|
||
// gap). A deletion failure never reverts the (already safe,
|
||
// already-written) hooks.json cleanup above — must-have #8.
|
||
const monitorCleanup = hooksSurface.cleanupOrphanedCodexContextMonitorScript(targetDir);
|
||
for (const deletedPath of monitorCleanup.deleted) {
|
||
console.log(` ${green}✓${reset} Removed orphaned Codex hook script (${path.basename(deletedPath)})`);
|
||
}
|
||
for (const warning of monitorCleanup.warnings) {
|
||
console.warn(` ${yellow}⚠${reset} Could not remove orphaned Codex hook script ${warning.path}: ${warning.reason}`);
|
||
}
|
||
// ── end Codex extended hook events ────────────────────────────────────
|
||
}
|
||
} catch (e) {
|
||
// #2760 — schema-validation and write failures must be loud and fatal
|
||
// so the user is never left with a config Codex refuses to load (or no
|
||
// Codex agents configured at all). The pre-install snapshot restore has
|
||
// already run for write-side throws via the inner catch above and via
|
||
// restoreCodexSnapshot in the validation branch.
|
||
if (e && typeof e.message === 'string' && e.message.startsWith('post-write')) {
|
||
console.error(` ${red}✗${reset} ${e.message}`);
|
||
throw e;
|
||
}
|
||
// #2760 CR5 finding 1 — pre-write failures (migrateCodexHooksMapFormat,
|
||
// ensureCodexHooksFeature, config reads, configContent construction,
|
||
// etc.) must ALSO be fatal. Previously this branch downgraded to a
|
||
// console.warn, leaving the install to print "Done!" with no Codex
|
||
// hooks configured — same defect class as finding 1, different layer.
|
||
// Restore the pre-install snapshot and rethrow so the outer install
|
||
// pipeline aborts.
|
||
restoreCodexSnapshot();
|
||
const wrapped = new Error(
|
||
`Codex hook configuration failed (pre-write): ${e && e.message ? e.message : String(e)}. ` +
|
||
`Restored ${preWriteBackup !== null ? 'pre-install backup' : 'empty state'}.`
|
||
);
|
||
console.error(` ${red}✗${reset} ${wrapped.message}`);
|
||
throw wrapped;
|
||
}
|
||
|
||
persistActiveProfileMarker();
|
||
// #4249: expose restoreCodexSnapshot (#3245) as a SECOND, separately named
|
||
// rollback rather than rebinding `rollbackInstallerMigrations` to it. A
|
||
// configured-entrypoint validation failure discovered later (outside this
|
||
// function, after Codex's own hooks.json/config.toml write already
|
||
// succeeded) previously had only the installer-migrations closure to call,
|
||
// leaving the just-written config.toml/hooks.json broken on disk despite
|
||
// Codex already owning a full pre-install snapshot/restore for exactly this.
|
||
//
|
||
// Every runtime's `rollbackInstallerMigrations` therefore still means what
|
||
// it says — the installer-migrations-only closure, which is what a
|
||
// finalize-stage failure that is NOT an entrypoint-validation failure gets
|
||
// (the Phase 4 contract). `rollbackPreInstallSnapshot` is Codex-only and is
|
||
// chosen only for entrypoint-validation failures. See the selection in
|
||
// installAllRuntimes' rollbackFinalizedInstallerMigrations.
|
||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints, rollbackInstallerMigrations, rollbackPreInstallSnapshot: restoreCodexSnapshot };
|
||
}
|
||
|
||
if (plan.installSurface === 'cursor-hooks-json') {
|
||
// ADR-1239 / #2089: Cursor hooks.json driven by the descriptor-managed hook-bus
|
||
// adapter. Registers all 6 managed events (sessionStart, postToolUse, preToolUse,
|
||
// stop, subagentStart, subagentStop) via runtime-hooks-surface.cts, which reads
|
||
// the event list from the descriptor-driven adapter module.
|
||
const cursorHookResult = writeCursorHooksJson(targetDir, src, {
|
||
managedHookEvents: _hostBehaviors(runtime).managedHookEvents,
|
||
});
|
||
if (cursorHookResult.changed) {
|
||
console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop)`);
|
||
} else {
|
||
console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`);
|
||
}
|
||
// Re-run the manifest pass to capture any files the hooks-json write path
|
||
// produced. NOTE: hooks.json and the msd-cursor-*.js scripts are NOT
|
||
// manifest-tracked (verified) — uninstall removes them explicitly via
|
||
// removeCursorHooksJson + its script list, and reconcile is idempotent.
|
||
// The re-run is retained for parity with the settings.json install path.
|
||
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
|
||
persistActiveProfileMarker();
|
||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints: cursorHookResult.configuredEntrypoints, rollbackInstallerMigrations };
|
||
}
|
||
|
||
if (plan.installSurface === 'profile-marker-only') {
|
||
// ZCode uses an artifact-only surface — no config.toml or settings.json
|
||
// hooks needed.
|
||
persistActiveProfileMarker();
|
||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints, rollbackInstallerMigrations };
|
||
}
|
||
|
||
// Configure statusline and hooks in settings.json (or settings.local.json for local Claude installs).
|
||
// ADR-857 phase 5f-2: drive the hook event dialect from the registry descriptor.
|
||
// runtimes with hookEvents='gemini' use AfterTool/BeforeTool; all others use PostToolUse/PreToolUse.
|
||
// Equivalence: hookEvents='gemini' iff runtime===antigravity — identical to the old check.
|
||
// A missing registry or missing descriptor defaults to 'not gemini' → PostToolUse (safe).
|
||
const _hookEventsDialect = plan.hookEvents;
|
||
const postToolEvent = _hookEventsDialect === 'gemini' ? 'AfterTool' : 'PostToolUse';
|
||
// #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
|
||
// so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
|
||
// settings.json. Global installs and all other runtimes continue to use settings.json.
|
||
// #2870: the CURRENT scope's settings filename is sourced from the Install
|
||
// Scope Module (_installScope.settingsFile, resolveScope's per-scope field)
|
||
// instead of indexing _scopedSettings by hand. _scopedSettings itself is
|
||
// retained unchanged as the #338-privacy fail-safe path: _hostBehaviors
|
||
// already degrades to FALLBACK_HOST_BEHAVIORS (see that constant's comment
|
||
// above) when the registry fails to load, whereas resolveScope's registry
|
||
// lookup throws in that same scenario (_installScope is null when it did).
|
||
// Falling back to _scopedSettings[_installScopeId] there — and keeping the
|
||
// non-local-claude branch's expression untouched — means this is
|
||
// byte-identical to the pre-migration computation in every case, including
|
||
// the broken-registry fail-safe floor.
|
||
const _scopedSettings = _hostBehaviors(runtime).settingsFileByScope || null;
|
||
const _currentScopeSettingsFile = _installScope
|
||
? _installScope.settingsFile
|
||
: (_scopedSettings ? (_scopedSettings[_installScopeId] ?? null) : null);
|
||
const isLocalClaude = (!isGlobal && !!_currentScopeSettingsFile);
|
||
const settingsFileName = isLocalClaude
|
||
? _currentScopeSettingsFile
|
||
: ((_scopedSettings && _scopedSettings.global) || 'settings.json');
|
||
// ADR-1239 Phase B write-confinement: the descriptor-sourced settings filename
|
||
// must resolve under targetDir (this path also drives a recursive mkdirSync).
|
||
const settingsPath = assertDestWithinConfigHome(targetDir, settingsFileName);
|
||
|
||
// #338 migration: if a prior local Claude install wrote MSD-shaped entries to settings.json,
|
||
// relocate them to settings.local.json and clear them from the shared file in the same run.
|
||
if (isLocalClaude) {
|
||
const sharedSettingsPath = path.join(targetDir, 'settings.json');
|
||
const sharedRaw = readSettings(sharedSettingsPath);
|
||
if (sharedRaw && typeof sharedRaw === 'object') {
|
||
const hasMsdHooks = sharedRaw.hooks && Object.values(sharedRaw.hooks).some(
|
||
entries => Array.isArray(entries) && entries.some(
|
||
entry => entry && entry.hooks && Array.isArray(entry.hooks) && entry.hooks.some(
|
||
h => h && typeof h.command === 'string' && isManagedHookCommand(h.command, { surface: 'settings-json' })
|
||
)
|
||
)
|
||
);
|
||
const hasMsdStatusline = sharedRaw.statusLine && sharedRaw.statusLine.command &&
|
||
isManagedHookCommand(sharedRaw.statusLine.command, { surface: 'settings-json' });
|
||
const needsMigration = hasMsdHooks || hasMsdStatusline;
|
||
// readSettings returns null ONLY for an unparseable file — its documented
|
||
// "preserve existing, don't touch" signal. Stand the WHOLE migration down
|
||
// in that case: skipping just the local merge while still stripping the
|
||
// shared file below would destroy the MSD entries outright instead of
|
||
// relocating them. Leaving both files untouched lets the migration retry
|
||
// once the user repairs the local file.
|
||
const localRaw = needsMigration ? readSettings(settingsPath) : null;
|
||
if (needsMigration && localRaw === null) {
|
||
console.log(' ' + yellow + 'i' + reset + ' Skipping #338 migration — ' + settingsFileName +
|
||
' could not be parsed. Your existing settings are preserved.');
|
||
} else if (needsMigration) {
|
||
// Merge MSD entries into settings.local.json
|
||
if (hasMsdStatusline && !localRaw.statusLine) {
|
||
localRaw.statusLine = sharedRaw.statusLine;
|
||
}
|
||
if (hasMsdHooks) {
|
||
if (!localRaw.hooks) localRaw.hooks = {};
|
||
for (const [eventName, entries] of Object.entries(sharedRaw.hooks || {})) {
|
||
if (!Array.isArray(entries)) continue;
|
||
const msdEntries = entries.filter(
|
||
entry => entry && entry.hooks && Array.isArray(entry.hooks) && entry.hooks.some(
|
||
h => h && typeof h.command === 'string' && isManagedHookCommand(h.command, { surface: 'settings-json' })
|
||
)
|
||
);
|
||
if (msdEntries.length > 0) {
|
||
if (!localRaw.hooks[eventName]) localRaw.hooks[eventName] = [];
|
||
// Only merge entries not already present in local
|
||
for (const entry of msdEntries) {
|
||
const alreadyPresent = localRaw.hooks[eventName].some(
|
||
le => le && le.hooks && Array.isArray(le.hooks) && le.hooks.some(
|
||
lh => lh && entry.hooks.some(eh => eh && eh.command === lh.command)
|
||
)
|
||
);
|
||
if (!alreadyPresent) localRaw.hooks[eventName].push(entry);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||
writeSettings(settingsPath, localRaw);
|
||
|
||
// Remove MSD entries from shared settings.json
|
||
if (hasMsdStatusline) {
|
||
delete sharedRaw.statusLine;
|
||
}
|
||
if (hasMsdHooks) {
|
||
for (const [eventName, entries] of Object.entries(sharedRaw.hooks || {})) {
|
||
if (!Array.isArray(entries)) continue;
|
||
sharedRaw.hooks[eventName] = entries.filter(
|
||
entry => !(entry && entry.hooks && Array.isArray(entry.hooks) && entry.hooks.some(
|
||
h => h && typeof h.command === 'string' && isManagedHookCommand(h.command, { surface: 'settings-json' })
|
||
))
|
||
);
|
||
if (sharedRaw.hooks[eventName].length === 0) {
|
||
delete sharedRaw.hooks[eventName];
|
||
}
|
||
}
|
||
if (sharedRaw.hooks && Object.keys(sharedRaw.hooks).length === 0) {
|
||
delete sharedRaw.hooks;
|
||
}
|
||
}
|
||
writeSettings(sharedSettingsPath, sharedRaw);
|
||
console.log(` ${green}✓${reset} Migrated MSD hook entries from settings.json to settings.local.json (#338)`);
|
||
}
|
||
}
|
||
}
|
||
|
||
const rawSettings = readSettings(settingsPath);
|
||
if (rawSettings === null) {
|
||
console.log(' ' + yellow + 'i' + reset + ' Skipping settings.local.json configuration — file could not be parsed (comments or malformed JSON). Your existing settings are preserved.');
|
||
persistActiveProfileMarker();
|
||
// Callers index this result by `runtime` (installAllRuntimes' statusline
|
||
// lookup), so every early exit must return the full shape — a bare return
|
||
// crashes the install rather than skipping one file. That includes
|
||
// configuredEntrypoints/rollbackInstallerMigrations: rollbackFinalizedInstallerMigrations
|
||
// reads result.rollbackInstallerMigrations unconditionally, and an omitted
|
||
// field there silently skips this runtime's rollback on a finalize-stage failure.
|
||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir, configuredEntrypoints: [], rollbackInstallerMigrations };
|
||
}
|
||
const settings = validateHookFields(cleanupOrphanedHooks(rawSettings));
|
||
// #3002 CR / #3662: rewrite legacy `node .../msd-*.js` command strings (pre-
|
||
// #2979 installs) AND entries baked with another environment's absolute node
|
||
// path onto the runtime-resolving runner. Without this, existing managed
|
||
// hook entries stay bare-`node`-prefixed or foreign-absolute across
|
||
// reinstalls and remain broken under GUI/minimal-PATH runtimes and shared
|
||
// config roots — the #3662 mixed state where no environment can run all
|
||
// hooks.
|
||
const settingsRunner = buildNodeRunnerChainToken();
|
||
if (settingsRunner && rewriteLegacyManagedNodeHookCommands(settings, settingsRunner, { platform: process.platform, runtime })) {
|
||
console.log(` ${green}✓${reset} Rewrote legacy managed-hook commands to the runtime-resolving node runner (#2979/#3662)`);
|
||
}
|
||
// Local installs anchor hook paths so they resolve regardless of cwd (#1906).
|
||
// Claude Code sets $CLAUDE_PROJECT_DIR; Antigravity does not — and on
|
||
// Windows its own substitution logic doubles the path (#2557). It runs
|
||
// project hooks with the project dir as cwd, so bare relative paths work.
|
||
// Descriptor-driven (ADR-1239 / #2096): hookPathStyle comes from the
|
||
// runtime's hostBehaviors instead of a hardcoded `runtime === 'antigravity'`
|
||
// check inside projectLocalHookPrefix.
|
||
const localPrefix = projectLocalHookPrefix({ runtime, dirName, hookPathStyle: _hostBehaviors(runtime).hookPathStyle });
|
||
const settingsEntrypoints = [];
|
||
const hookOpts = {
|
||
portableHooks: hasPortableHooks,
|
||
runtime,
|
||
configPath: settingsPath,
|
||
// #4249: track unconditionally. Gating on `plan.hooksSurface ===
|
||
// 'settings-json'` made tracking depend on an unasserted
|
||
// installSurface/hooksSurface coupling — a descriptor that broke it would
|
||
// silently drop this runtime out of validation. Everything recorded here
|
||
// lands in settings.json by construction, and the registered-command
|
||
// filter below already discards entries no hook actually references.
|
||
configuredEntrypoints: settingsEntrypoints,
|
||
};
|
||
// #2979: local-install hook commands also use a runner GUI/minimal-PATH
|
||
// runtimes can resolve. Bare `node` fails when the host launches the
|
||
// runtime with a stripped PATH (Finder/Antigravity/etc) — #3662 replaces
|
||
// the baked absolute path with the runtime-resolving chain (baked path
|
||
// first, so the minimal-PATH guarantee is unchanged).
|
||
const localNodeRunner = buildNodeRunnerChainToken();
|
||
const localBashRunner = resolveBashRunner({ platform: process.platform });
|
||
// If we cannot resolve a node runner AND this is a local install,
|
||
// skip managed-hook registration. Returning null from buildHookCommand on
|
||
// global installs has the same effect. Better to skip than to emit a bare
|
||
// `node` command that recreates the #2979 failure.
|
||
const localCmd = (hookFile) => localNodeRunner === null
|
||
? null
|
||
: hooksSurface.recordConfiguredHookCommand(projectShellCommandText({
|
||
runnerToken: localNodeRunner,
|
||
argTokens: [`${localPrefix}/hooks/${hookFile}`],
|
||
runtime,
|
||
platform: process.platform,
|
||
}), targetDir, hookFile, hookOpts);
|
||
const localShellCmd = (hookFile) => hooksSurface.recordConfiguredHookCommand(buildLocalShellHookCommand({
|
||
localPrefix,
|
||
hookFile,
|
||
bashRunner: localBashRunner,
|
||
runtime,
|
||
platform: process.platform,
|
||
}), targetDir, hookFile, hookOpts);
|
||
const statuslineCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'msd-statusline.js', hookOpts)
|
||
: localCmd('msd-statusline.js');
|
||
const updateCheckCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'msd-check-update.js', hookOpts)
|
||
: localCmd('msd-check-update.js');
|
||
const contextMonitorCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'msd-context-monitor.js', hookOpts)
|
||
: localCmd('msd-context-monitor.js');
|
||
const promptGuardCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'msd-prompt-guard.js', hookOpts)
|
||
: localCmd('msd-prompt-guard.js');
|
||
const readGuardCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'msd-read-guard.js', hookOpts)
|
||
: localCmd('msd-read-guard.js');
|
||
const readInjectionScannerCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'msd-read-injection-scanner.js', hookOpts)
|
||
: localCmd('msd-read-injection-scanner.js');
|
||
const configReloadCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'msd-config-reload.js', hookOpts)
|
||
: localCmd('msd-config-reload.js');
|
||
|
||
// #3002 CR: when resolveNodeRunner() returns null, every dependent JS-hook
|
||
// command is null too. Emit one warning here so the operator sees the cause
|
||
// ONCE instead of per-hook. Each registration site below also guards on its
|
||
// own *Command variable being truthy, so we never write `command: null`
|
||
// entries to settings.json (which the runtime's hook schema would reject).
|
||
const anyJsHookCommandNull = !statuslineCommand
|
||
|| !updateCheckCommand
|
||
|| !contextMonitorCommand
|
||
|| !promptGuardCommand
|
||
|| !readGuardCommand
|
||
|| !readInjectionScannerCommand;
|
||
if (anyJsHookCommandNull) {
|
||
console.warn(` ${yellow}⚠${reset} Skipping managed JS hook registration — Node executable path unavailable (process.execPath is empty). See #2979 / #3002.`);
|
||
}
|
||
|
||
// Register all MSD-managed hook entries into settings.hooks.* for runtimes
|
||
// that use the settings.json hook surface (ADR-857 phase 5f-1b).
|
||
// settings is mutated in place by applySettingsJsonHooks.
|
||
applySettingsJsonHooks(settings, {
|
||
runtime,
|
||
isGlobal,
|
||
targetDir,
|
||
postToolEvent,
|
||
hookEvents: _hookEventsDialect,
|
||
extendedHookEvents: plan.extendedHookEvents,
|
||
hooksSurface: plan.hooksSurface,
|
||
updateCheckCommand,
|
||
contextMonitorCommand,
|
||
promptGuardCommand,
|
||
readGuardCommand,
|
||
readInjectionScannerCommand,
|
||
configReloadCommand,
|
||
hookOpts,
|
||
localCmd,
|
||
localShellCmd,
|
||
});
|
||
|
||
// Compute the update-banner hook command alongside the others so
|
||
// installAllRuntimes can register it at finalize time when the user opts
|
||
// in (#2795). Computed here (not in finishInstall) so the same buildHookCommand
|
||
// / localCmd resolution logic is shared with the other JS hooks.
|
||
const updateBannerCommand = _hostBehaviors(runtime).skipUpdateBannerCommand
|
||
? null
|
||
: (isGlobal
|
||
? buildHookCommand(targetDir, 'msd-update-banner.js', hookOpts)
|
||
: localCmd('msd-update-banner.js'));
|
||
|
||
const registeredHookCommands = Object.values(settings.hooks || {})
|
||
.flatMap(groups => Array.isArray(groups) ? groups : [])
|
||
.flatMap(group => Array.isArray(group && group.hooks) ? group.hooks : [])
|
||
.map(hook => hook && hook.command)
|
||
.filter(command => typeof command === 'string');
|
||
// #4249: match by the managed script's `/hooks/<basename>` path segment, not
|
||
// by exact command-string equality. The blocking-guard hooks above register
|
||
// only-if-absent, so a hook already present from a prior install keeps its
|
||
// OLD command untouched — but `track()` always records the FRESHLY computed
|
||
// command for it, which never equals what's actually persisted. Matching on
|
||
// the segment (present in the persisted command either way, since every
|
||
// entry.scriptPath is <configDir>/hooks/<name> by construction) keeps an
|
||
// already-registered, still-active hook in the validated set instead of
|
||
// silently dropping it (#4154 Blocker) — anchored on `/hooks/` rather than a
|
||
// bare basename so an unrelated user command that merely mentions the same
|
||
// filename can't false-positive into MSD's validated set.
|
||
configuredEntrypoints.push(
|
||
...settingsEntrypoints.filter(entry => {
|
||
const hooksSegment = '/hooks/' + path.basename(entry.scriptPath);
|
||
return registeredHookCommands.some(command => command.includes(hooksSegment));
|
||
}),
|
||
);
|
||
const statuslineEntrypoints = settingsEntrypoints.filter(entry => entry.command === statuslineCommand);
|
||
const updateBannerEntrypoints = settingsEntrypoints.filter(entry => entry.command === updateBannerCommand);
|
||
|
||
// #683: Set worktree.baseRef:"head" in settings.local.json for local Claude installs.
|
||
// Both fresh and upgrade paths apply only when worktrees are enabled for the project.
|
||
// Never applies to global installs, non-Claude runtimes, or when the user already
|
||
// has an explicit baseRef in EITHER settings.local.json OR settings.json (no-clobber).
|
||
// Guard: skip entirely when settings is not a plain object (e.g. parsed to [] or primitive)
|
||
// to avoid crashing applyWorktreeBaseRef on unexpected top-level shapes.
|
||
if (isLocalClaude && settings !== null && typeof settings === 'object' && !Array.isArray(settings)) {
|
||
// Read shared settings.json baseRef so no-clobber spans both files (#683 FIX 1).
|
||
// shared settings.json no-clobber is checked here; settings.local.json no-clobber
|
||
// is enforced inside applyWorktreeBaseRef itself.
|
||
const sharedSettingsForBaseRef = readSettings(path.join(targetDir, 'settings.json')) || {};
|
||
const sharedBaseRef = readBaseRefFromSettings(sharedSettingsForBaseRef);
|
||
|
||
// Compute worktrees-enabled ONCE for both fresh and upgrade paths (FIX A: DRY + consistency).
|
||
// Read workflow.use_worktrees from .planning/config.json by walking up from
|
||
// targetDir (same walk-up pattern as readMsdRuntimeProfileResolver). Defaults
|
||
// to enabled (true) when the file is missing, unreadable, or the key is absent;
|
||
// only boolean false disables (string "false" stays enabled).
|
||
let worktreesEnabled = true; // default: enabled
|
||
try {
|
||
let probeDir = path.resolve(targetDir);
|
||
for (let depth = 0; depth < 8; depth += 1) {
|
||
const candidate = path.join(probeDir, '.planning', 'config.json');
|
||
if (fs.existsSync(candidate)) {
|
||
try {
|
||
const parsed = JSON.parse(stripJsonComments(fs.readFileSync(candidate, 'utf-8')));
|
||
if (parsed && typeof parsed === 'object' &&
|
||
parsed.workflow && parsed.workflow.use_worktrees === false) {
|
||
worktreesEnabled = false;
|
||
}
|
||
} catch {
|
||
// Malformed config.json — treat as enabled (safe fallback).
|
||
}
|
||
break;
|
||
}
|
||
const parent = path.dirname(probeDir);
|
||
if (parent === probeDir) break;
|
||
probeDir = parent;
|
||
}
|
||
} catch {
|
||
// Any unexpected error reading .planning — default to enabled.
|
||
}
|
||
|
||
if (worktreesEnabled && sharedBaseRef === null) {
|
||
if (!priorInstallExisted) {
|
||
// Fresh install — apply no-clobber baseRef set.
|
||
// canonical no-clobber logic: src/worktree-base-ref.cts applyWorktreeBaseRef (#683)
|
||
const { changed } = applyWorktreeBaseRef(settings);
|
||
if (changed) {
|
||
console.log(` ${green}✓${reset} Set worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
|
||
}
|
||
} else {
|
||
// Upgrade — auto-apply no-clobber baseRef set when worktrees are enabled.
|
||
const { changed } = applyWorktreeBaseRef(settings);
|
||
if (changed) {
|
||
console.log(` ${green}✓${reset} Enabled worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
|
||
}
|
||
}
|
||
}
|
||
// When worktreesEnabled is false: do nothing, print nothing (both fresh and upgrade).
|
||
}
|
||
|
||
persistActiveProfileMarker();
|
||
return {
|
||
settingsPath,
|
||
settings,
|
||
statuslineCommand,
|
||
updateBannerCommand,
|
||
statuslineEntrypoints,
|
||
updateBannerEntrypoints,
|
||
runtime,
|
||
configDir: targetDir,
|
||
rollbackInstallerMigrations,
|
||
configuredEntrypoints,
|
||
};
|
||
}
|
||
|
||
// #4249 (review, Major): rollback consequence differs by runtime surface —
|
||
// see docs/how-to/update-msd.md's rollback-matrix paragraph, which this
|
||
// mirrors. Codex reverts (pre-install snapshot restore); Cursor already
|
||
// wrote its config file inside install(), ahead of this gate, with no revert
|
||
// path, so it is left on disk broken; every other (settings.json-based)
|
||
// runtime writes strictly after this gate, so a failure here means nothing
|
||
// new was persisted for it.
|
||
const ENTRYPOINT_LEFT_UNREVERTED_RUNTIMES = new Set(['cursor']);
|
||
function describeEntrypointConsequence(invalidRuntime) {
|
||
if (invalidRuntime === 'codex') return 'reverted: its pre-install snapshot was restored';
|
||
if (ENTRYPOINT_LEFT_UNREVERTED_RUNTIMES.has(invalidRuntime)) return 'NOT reverted: its config file is already written and was left on disk — fix the reported path and rerun install';
|
||
return 'not persisted: this runtime writes its config after this check';
|
||
}
|
||
|
||
function assertConfiguredEntrypoints(entries) {
|
||
// #4249: some writers push the same (configPath, scriptPath) pair more than
|
||
// once (e.g. a context-monitor hook registered under several events, or
|
||
// the portable resolver script shared by every portable JS hook) — keep
|
||
// one so a broken entry is reported once, not once per duplicate.
|
||
const seen = new Set();
|
||
const deduped = (entries || []).filter((entry) => {
|
||
const key = JSON.stringify([entry.configPath, entry.scriptPath]);
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
const validation = hooksSurface.validateConfiguredEntrypoints(deduped);
|
||
if (validation.ok) return;
|
||
|
||
const error = new Error(
|
||
// #4249: lead each entry with its runtime, and name the actual consequence
|
||
// for that runtime (review, Major) — the aggregate gate is all-or-nothing
|
||
// across every runtime being installed, and a failure here can revert a
|
||
// runtime whose own entrypoints were fine (see
|
||
// rollbackFinalizedInstallerMigrations) while leaving another runtime's
|
||
// already-written config broken on disk with no revert at all, so an
|
||
// operator reading only this message must be able to tell WHOSE
|
||
// entrypoint broke and WHAT that means for their config, not just that
|
||
// something did.
|
||
`Configured entrypoint validation failed: ${validation.invalid.map(({ runtime: invalidRuntime, role, path: invalidPath, reason }) => `${invalidRuntime} ${role} ${invalidPath} (${reason}) [${describeEntrypointConsequence(invalidRuntime)}]`).join(', ')}`,
|
||
);
|
||
error.configuredEntrypointValidation = validation;
|
||
throw error;
|
||
}
|
||
|
||
// #4249: `bannerOpts.configuredEntrypoints` is the ONLY source assertConfiguredEntrypoints
|
||
// checks below — a caller that omits it (or calls finishInstall directly instead of
|
||
// through installAllRuntimes) gets zero entrypoint validation, silently. installAllRuntimes
|
||
// always passes the full set (per-runtime entries plus statusline/updateBanner); any other
|
||
// caller must do the same for this gate to mean anything.
|
||
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = DEFAULT_RUNTIME, isGlobal = true, configDir = null, bannerOpts = {}) {
|
||
const { isOpencode, isCodex, isCursor } = runtimeFlags(runtime);
|
||
const plan = resolveInstallPlan(runtime);
|
||
|
||
// #4249 Major: validate BEFORE this function's own settings.json write (and
|
||
// before writeNonClaudeDefaults) instead of after. Cursor
|
||
// already persisted their config inside install() by this point, with no
|
||
// rollback path covering those writes; Codex also persists inside install()
|
||
// but its rollback binds to a full pre-install snapshot restore, so it IS
|
||
// covered (see docs/how-to/update-msd.md). For the settings-json surface
|
||
// this ordering means a failing validation never reaches this function's
|
||
// own write at all. On the production path this is a redundant backstop —
|
||
// installAllRuntimes's own aggregate assertConfiguredEntrypoints call
|
||
// already validates the superset before finishInstall runs for any
|
||
// runtime — kept for a caller that invokes finishInstall directly.
|
||
assertConfiguredEntrypoints(bannerOpts.configuredEntrypoints);
|
||
|
||
if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
|
||
if (!isGlobal && !forceStatusline) {
|
||
// Local installs skip statusLine by default: repo settings.json takes precedence over
|
||
// profile-level settings.json in Claude Code, so writing here would silently clobber
|
||
// any profile-level statusLine the user has configured (#2248).
|
||
// Pass --force-statusline to override this guard.
|
||
console.log(` ${yellow}⚠${reset} Skipping statusLine for local install (avoids overriding profile-level settings; use --force-statusline to override)`);
|
||
} else if (!statuslineCommand) {
|
||
// #3002 CR: don't write { type: 'command', command: null } — the
|
||
// runtime's settings schema rejects null commands and the failure
|
||
// surfaces as a confusing parse error rather than a usable diagnostic.
|
||
console.warn(` ${yellow}⚠${reset} Skipped statusline registration — Node executable path unavailable (process.execPath is empty). See #2979 / #3002.`);
|
||
} else {
|
||
settings.statusLine = {
|
||
type: 'command',
|
||
command: statuslineCommand
|
||
};
|
||
console.log(` ${green}✓${reset} Configured statusline`);
|
||
}
|
||
}
|
||
|
||
// Register the opt-in update banner (#2795) when the user accepted the
|
||
// banner offer at install time. Only applies to runtimes that own a
|
||
// settings.json hooks block — opencode/codex/cursor/zcode either lack the
|
||
// surface or use a different config schema.
|
||
const { shouldInstallBanner, bannerCommand } = bannerOpts;
|
||
if (shouldInstallBanner && settings && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
|
||
if (!bannerCommand) {
|
||
console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
|
||
} else {
|
||
if (!settings.hooks) settings.hooks = {};
|
||
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
||
const alreadyRegistered = settings.hooks.SessionStart.some(entry =>
|
||
entry && entry.hooks && entry.hooks.some(h => h && referencesHook(h, 'msd-update-banner'))
|
||
);
|
||
const bannerHookFile = configDir ? path.join(configDir, 'hooks', 'msd-update-banner.js') : null;
|
||
const bannerInstalled = bannerHookFile ? fs.existsSync(bannerHookFile) : false;
|
||
if (alreadyRegistered) {
|
||
// Idempotent re-install: don't double-register.
|
||
} else if (!bannerInstalled) {
|
||
console.warn(` ${yellow}⚠${reset} Skipped update banner — msd-update-banner.js not found at target`);
|
||
} else {
|
||
const entry = buildUpdateBannerHookEntry(bannerCommand);
|
||
if (entry) {
|
||
settings.hooks.SessionStart.push(entry);
|
||
console.log(` ${green}✓${reset} Configured update banner hook (opt-in)`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// #768 — Pre-populate permissions.allow/deny for Claude Code installs.
|
||
// Merges MSD-owned entries non-destructively (preserves existing user permissions).
|
||
// Scoped to Claude only: antigravity also writes settings.json but uses a
|
||
// different runtime and does not use these permission strings.
|
||
if (_hostBehaviors(runtime).permissionsSchema === 'claude') {
|
||
mergeClaudePermissions(settings);
|
||
}
|
||
|
||
// Write settings when runtime supports settings.json.
|
||
// #3002 CR: defense-in-depth — re-run validateHookFields right before
|
||
// serialization. The push-site guards above already skip null-command
|
||
// entries, but a future regression that bypasses them would still produce
|
||
// {type: 'command', command: null} items that the runtime hook schema
|
||
// rejects at parse time. validateHookFields filters those out so the file
|
||
// we write is always schema-valid.
|
||
if (settingsPath && settings && plan.writesSharedSettings) {
|
||
writeSettings(settingsPath, validateHookFields(settings));
|
||
}
|
||
|
||
// Configure OpenCode permissions
|
||
if (plan.finishPermissionWriter === 'opencode' && !process.env.MSD_TEST_MODE) {
|
||
configureOpencodePermissions(isGlobal, configDir);
|
||
}
|
||
|
||
// Configure Antigravity permissions + MCP companion server (#2096 Phase B
|
||
// Upgrades 1+2). Not MSD_TEST_MODE-gated; both writers target files (settings.json, mcp_config.json) scoped under
|
||
// this runtime's own configDir, so they are safe to run unconditionally.
|
||
if (plan.finishPermissionWriter === 'antigravity') {
|
||
configureAntigravityPermissions(isGlobal, configDir);
|
||
configureAntigravityMcpConfig(isGlobal, configDir);
|
||
}
|
||
|
||
// #2834: defaults.json (resolve_model_ids + runtime) is now written BEFORE
|
||
// installCodexConfig via writeNonClaudeDefaults(runtime) — extracted into a
|
||
// function so it can run at the right point in the flow (before agent TOML
|
||
// generation reads it). This call is idempotent (preserves existing values).
|
||
writeNonClaudeDefaults(runtime);
|
||
|
||
// program + command are now single-source lookups (ADR-1239 Phase B / #1679):
|
||
// program is the runtime display label; command is the per-host /msd-new-project
|
||
// invocation syntax.
|
||
const program = getRuntimeLabel(runtime);
|
||
const command = getRuntimeNewProjectCommand(runtime);
|
||
|
||
// Claude Code global installs use the skills/ format (CC 2.1.88+).
|
||
// Restart is required for CC to pick up newly-installed skills, and the
|
||
// slash-menu surface depends on CC version — so the instruction needs to
|
||
// cover both invocation paths to avoid #2957-style "no commands appear".
|
||
if (_hostBehaviors(runtime).skillsGlobalOnboarding && isGlobal) {
|
||
console.log(`
|
||
${green}Done!${reset} Restart ${program}, then in any directory either type ${cyan}${command}${reset} or ask Claude to run the ${cyan}msd-new-project${reset} skill.
|
||
|
||
${cyan}Join the community:${reset} https://discord.gg/mYgfVNfA2r
|
||
`);
|
||
return;
|
||
}
|
||
|
||
console.log(`
|
||
${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}.
|
||
|
||
${cyan}Join the community:${reset} https://discord.gg/mYgfVNfA2r
|
||
`);
|
||
}
|
||
|
||
/**
|
||
* Handle statusline configuration with optional prompt
|
||
*/
|
||
function handleStatusline(settings, isInteractive, callback) {
|
||
const hasExisting = settings.statusLine != null;
|
||
|
||
if (!hasExisting) {
|
||
callback(true);
|
||
return;
|
||
}
|
||
|
||
if (forceStatusline) {
|
||
callback(true);
|
||
return;
|
||
}
|
||
|
||
if (!isInteractive) {
|
||
console.log(` ${yellow}⚠${reset} Skipping statusline (already configured)`);
|
||
console.log(` Use ${cyan}--force-statusline${reset} to replace\n`);
|
||
callback(false);
|
||
return;
|
||
}
|
||
|
||
const existingCmd = settings.statusLine.command || settings.statusLine.url || '(custom)';
|
||
|
||
const rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout
|
||
});
|
||
|
||
console.log(`
|
||
${yellow}⚠${reset} Existing statusline detected\n
|
||
Your current statusline:
|
||
${dim}command: ${existingCmd}${reset}
|
||
|
||
MSD includes a statusline showing:
|
||
• Model name
|
||
• Current task (from todo list)
|
||
• Context window usage (color-coded)
|
||
|
||
${cyan}1${reset}) Keep existing
|
||
${cyan}2${reset}) Replace with MSD statusline
|
||
`);
|
||
|
||
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
|
||
rl.close();
|
||
const choice = answer.trim() || '1';
|
||
callback(choice === '2');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Prompt for runtime selection
|
||
*/
|
||
/**
|
||
* Runtime selection options for the interactive installer prompt.
|
||
* Module-level so tests can import and assert structurally without grepping source.
|
||
*/
|
||
const runtimeMap = {
|
||
'1': 'claude',
|
||
'2': 'antigravity',
|
||
'3': 'codex',
|
||
'4': 'cursor',
|
||
'5': 'opencode',
|
||
'6': 'zcode'
|
||
};
|
||
const allRuntimes = ['claude', 'antigravity', 'codex', 'cursor', 'opencode', 'zcode'];
|
||
const ALL_RUNTIMES_OPTION = '7';
|
||
|
||
/**
|
||
* Build the runtime-selection prompt text shown by the interactive installer.
|
||
* Pure function — no I/O. Exported for tests so they can assert against the
|
||
* rendered prompt instead of grepping bin/install.js source text.
|
||
*/
|
||
function buildRuntimePromptText() {
|
||
return ` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset}
|
||
${cyan}2${reset}) Antigravity ${dim}(~/.gemini/antigravity)${reset}
|
||
${cyan}3${reset}) Codex ${dim}(~/.codex)${reset}
|
||
${cyan}4${reset}) Cursor ${dim}(~/.cursor)${reset}
|
||
${cyan}5${reset}) OpenCode ${dim}(~/.config/opencode)${reset}
|
||
${cyan}6${reset}) ZCode ${dim}(~/.zcode)${reset}
|
||
${cyan}7${reset}) All
|
||
|
||
${dim}Select multiple: 1,2,6 or 1 2 6${reset}
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Parse user input from the runtime-selection prompt into a runtime list.
|
||
* Pure function — exported so tests can verify split/dedupe/fallback behavior.
|
||
* - Accepts comma- and/or whitespace-separated choices
|
||
* - Deduplicates while preserving order
|
||
* - Maps option 7 ("All") to every runtime
|
||
* - Falls back to ['claude'] when nothing valid is selected
|
||
*/
|
||
function parseRuntimeInput(answer) {
|
||
const input = (answer == null ? '' : String(answer)).trim() || '1';
|
||
|
||
// Tokenize first so the all-runtimes shortcut also fires for inputs the
|
||
// prompt encourages — "16,", "16 1", etc. — not just the bare "16".
|
||
const choices = input.split(/[\s,]+/).filter(Boolean);
|
||
if (choices.includes(ALL_RUNTIMES_OPTION)) {
|
||
return allRuntimes.slice();
|
||
}
|
||
|
||
const selected = [];
|
||
for (const c of choices) {
|
||
const runtime = runtimeMap[c];
|
||
if (runtime && !selected.includes(runtime)) {
|
||
selected.push(runtime);
|
||
}
|
||
}
|
||
|
||
return selected.length > 0 ? selected : [DEFAULT_RUNTIME];
|
||
}
|
||
|
||
function promptRuntime(callback) {
|
||
const rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout
|
||
});
|
||
|
||
let answered = false;
|
||
|
||
rl.on('close', () => {
|
||
if (!answered) {
|
||
answered = true;
|
||
console.log(`\n ${yellow}Installation cancelled${reset}\n`);
|
||
process.exit(0);
|
||
}
|
||
});
|
||
|
||
console.log(buildRuntimePromptText());
|
||
|
||
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
|
||
answered = true;
|
||
rl.close();
|
||
callback(parseRuntimeInput(answer));
|
||
});
|
||
}
|
||
|
||
// ─── Update banner (#2795) ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Build the prompt text shown when offering the opt-in update banner.
|
||
* Pure function — no I/O. Exported for tests so they can assert against the
|
||
* rendered prompt structurally instead of grepping bin/install.js source.
|
||
*/
|
||
function buildUpdateBannerPromptText() {
|
||
return `
|
||
${yellow}Optional: MSD update banner${reset}
|
||
Without MSD's statusline, update notifications won't be visible. You can
|
||
install a SessionStart banner that surfaces a one-line message when a new
|
||
MSD release is available. The banner appears only at session start and
|
||
only when an update exists.
|
||
|
||
${cyan}1${reset}) ${dim}No banner (default)${reset}
|
||
${cyan}2${reset}) Install update banner
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Parse user input from the banner prompt. Returns true when the user opted
|
||
* in. Pure function — exported for direct unit testing.
|
||
*
|
||
* - Empty input or "1" → false (default: no banner).
|
||
* - "2" → true.
|
||
* - "y" / "yes" (case-insensitive) → true. Affirmative shortcuts.
|
||
*/
|
||
function parseUpdateBannerInput(answer) {
|
||
const input = (answer == null ? '' : String(answer)).trim().toLowerCase();
|
||
if (input === '2' || input === 'y' || input === 'yes') return true;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Build a SessionStart hook entry (settings.json shape) that runs the
|
||
* update-banner script. Returns null when the input command is empty so
|
||
* callers can warn-and-skip rather than writing { command: null } and
|
||
* tripping the runtime's hook schema (#3002).
|
||
*
|
||
* @param {string|null} bannerCommand - Result of buildHookCommand() / localCmd().
|
||
* @returns {{hooks: Array<{type: 'command', command: string}>}|null}
|
||
*/
|
||
function buildUpdateBannerHookEntry(bannerCommand) {
|
||
if (!bannerCommand) return null;
|
||
return {
|
||
hooks: [
|
||
{
|
||
type: 'command',
|
||
command: bannerCommand,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Interactive prompt that asks the user whether to install the opt-in
|
||
* update banner. Used by `installAllRuntimes` only when MSD's statusline
|
||
* was declined or skipped.
|
||
*
|
||
* @param {boolean} isInteractive
|
||
* @param {(shouldInstallBanner: boolean) => void} callback
|
||
*/
|
||
function handleUpdateBanner(isInteractive, callback) {
|
||
if (!isInteractive) {
|
||
// Never auto-install in non-interactive mode — user can re-run install
|
||
// interactively or hand-edit settings.json to opt in later.
|
||
callback(false);
|
||
return;
|
||
}
|
||
|
||
const rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout,
|
||
});
|
||
|
||
console.log(buildUpdateBannerPromptText());
|
||
|
||
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
|
||
rl.close();
|
||
callback(parseUpdateBannerInput(answer));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Prompt for install location
|
||
*/
|
||
function promptLocation(runtimes) {
|
||
if (!process.stdin.isTTY) {
|
||
console.log(` ${yellow}Non-interactive terminal detected, defaulting to global install${reset}\n`);
|
||
installAllRuntimes(runtimes, true, false);
|
||
return;
|
||
}
|
||
|
||
const rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout
|
||
});
|
||
|
||
let answered = false;
|
||
|
||
rl.on('close', () => {
|
||
if (!answered) {
|
||
answered = true;
|
||
console.log(`\n ${yellow}Installation cancelled${reset}\n`);
|
||
process.exit(0);
|
||
}
|
||
});
|
||
|
||
const pathExamples = runtimes.map(r => {
|
||
const globalPath = getGlobalConfigDir(r, explicitConfigDir);
|
||
return globalPath.replace(os.homedir(), '~');
|
||
}).join(', ');
|
||
|
||
const localExamples = runtimes.map(r => `./${getDirName(r)}`).join(', ');
|
||
|
||
console.log(` ${yellow}Where would you like to install?${reset}\n\n ${cyan}1${reset}) Global ${dim}(${pathExamples})${reset} - available in all projects
|
||
${cyan}2${reset}) Local ${dim}(${localExamples})${reset} - this project only
|
||
`);
|
||
|
||
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
|
||
answered = true;
|
||
rl.close();
|
||
const choice = answer.trim() || '1';
|
||
const isGlobal = choice !== '2';
|
||
installAllRuntimes(runtimes, isGlobal, true);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Check whether any common shell rc file already contains a `PATH=` line
|
||
* whose HOME-expanded value places `globalBin` on PATH (#2620).
|
||
*
|
||
* Parses `~/.zshrc`, `~/.bashrc`, `~/.bash_profile`, `~/.profile` (or the
|
||
* override list in `rcFileNames`), matches `export PATH=` / bare `PATH=`
|
||
* lines, and substitutes the common HOME forms (`$HOME`, `${HOME}`, `~`)
|
||
* with `homeDir` before comparing each PATH segment against `globalBin`.
|
||
*
|
||
* Best-effort: any unreadable / malformed / non-existent rc file is ignored
|
||
* and the fallback is the caller's existing absolute-path suggestion. Only
|
||
* the `$HOME/…`, `${HOME}/…`, and `~/…` forms are handled — we do not try
|
||
* to fully parse bash syntax.
|
||
*
|
||
* @param {string} globalBin Absolute path to npm's global bin directory.
|
||
* @param {string} homeDir Absolute path used to substitute HOME / ~.
|
||
* @param {string[]} [rcFileNames] Override the default rc file list.
|
||
* @returns {boolean} true iff any rc file adds globalBin to PATH.
|
||
*/
|
||
function homePathCoveredByRc(globalBin, homeDir, rcFileNames) {
|
||
if (!globalBin || !homeDir) return false;
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
|
||
const normalise = (p) => {
|
||
if (!p) return '';
|
||
let n = p.replace(/[\\/]+$/g, '');
|
||
if (n === '') n = p.startsWith('/') ? '/' : p;
|
||
return n;
|
||
};
|
||
|
||
const targetAbs = normalise(path.resolve(globalBin));
|
||
const homeAbs = path.resolve(homeDir);
|
||
const files = rcFileNames || ['.zshrc', '.bashrc', '.bash_profile', '.profile'];
|
||
|
||
const expandHome = (segment) => {
|
||
let s = segment;
|
||
s = s.replace(/\$\{HOME\}/g, homeAbs);
|
||
s = s.replace(/\$HOME/g, homeAbs);
|
||
if (s.startsWith('~/') || s === '~') {
|
||
s = s === '~' ? homeAbs : path.join(homeAbs, s.slice(2));
|
||
}
|
||
return s;
|
||
};
|
||
|
||
// Match `PATH=…` (optionally prefixed with `export `). The RHS captures
|
||
// through end-of-line; surrounding quotes are stripped before splitting.
|
||
const assignRe = /^\s*(?:export\s+)?PATH\s*=\s*(.+?)\s*$/;
|
||
|
||
for (const name of files) {
|
||
const rcPath = path.join(homeAbs, name);
|
||
let content;
|
||
try {
|
||
content = fs.readFileSync(rcPath, 'utf8');
|
||
} catch {
|
||
continue;
|
||
}
|
||
|
||
for (const rawLine of content.split(/\r?\n/)) {
|
||
const line = rawLine.replace(/^\s+/, '');
|
||
if (line.startsWith('#')) continue;
|
||
|
||
const m = assignRe.exec(rawLine);
|
||
if (!m) continue;
|
||
|
||
let rhs = m[1];
|
||
if ((rhs.startsWith('"') && rhs.endsWith('"')) ||
|
||
(rhs.startsWith("'") && rhs.endsWith("'"))) {
|
||
rhs = rhs.slice(1, -1);
|
||
}
|
||
|
||
for (const segment of rhs.split(':')) {
|
||
if (!segment) continue;
|
||
const trimmed = segment.trim();
|
||
const expanded = expandHome(trimmed);
|
||
if (expanded.includes('$')) continue;
|
||
// Skip segments that are still relative after HOME expansion. A bare
|
||
// `bin` entry (or `./bin`, `node_modules/.bin`, etc.) depends on the
|
||
// shell's cwd at lookup time — it is NOT equivalent to `$HOME/bin`,
|
||
// so resolving against homeAbs would produce false positives.
|
||
if (!path.isAbsolute(expanded)) continue;
|
||
try {
|
||
const abs = normalise(path.resolve(expanded));
|
||
if (abs === targetAbs) return true;
|
||
} catch {
|
||
// ignore unresolvable segments
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Decode fish's universal-variable value escaping (the inverse of fish's
|
||
* `full_escape`). fish serializes every non-`[A-Za-z0-9/_]` byte in
|
||
* `fish_variables` — e.g. space -> `\x20`, hyphen -> `\x2d`, dot -> `\x2e` —
|
||
* and joins list elements with the literal 4-char token `\x1e` (NOT a raw
|
||
* 0x1e byte). Callers split on `\x1e` first, then decode each element here.
|
||
*
|
||
* Pure and total: any unrecognised `\`-sequence is passed through verbatim,
|
||
* so `decode(fishEscape(p)) === p` holds for every path string. Exported for
|
||
* a fast-check round-trip property test (#323).
|
||
*
|
||
* @param {string} s A single (already `\x1e`-split) escaped value.
|
||
* @returns {string} The decoded literal.
|
||
*/
|
||
function decodeFishUniversalValue(s) {
|
||
let out = '';
|
||
for (let i = 0; i < s.length; i++) {
|
||
const c = s[i];
|
||
if (c !== '\\') { out += c; continue; }
|
||
const n = s[i + 1];
|
||
if (n === 'n') { out += '\n'; i += 1; }
|
||
else if (n === 'r') { out += '\r'; i += 1; }
|
||
else if (n === 't') { out += '\t'; i += 1; }
|
||
else if (n === '\\') { out += '\\'; i += 1; }
|
||
else if (n === 'x' || n === 'X') {
|
||
const hex = s.slice(i + 2, i + 4);
|
||
if (/^[0-9a-fA-F]{2}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 3; }
|
||
else { out += c; }
|
||
} else if (n === 'u') {
|
||
const hex = s.slice(i + 2, i + 6);
|
||
if (/^[0-9a-fA-F]{4}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 5; }
|
||
else { out += c; }
|
||
} else if (n === 'U') {
|
||
const hex = s.slice(i + 2, i + 10);
|
||
if (/^[0-9a-fA-F]{8}$/.test(hex)) { out += String.fromCodePoint(parseInt(hex, 16)); i += 9; }
|
||
else { out += c; }
|
||
} else { out += c; }
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Check whether fish's configuration already places `globalBin` on PATH (#323).
|
||
*
|
||
* fish does not use the sh-style `export PATH=` rc files that
|
||
* `homePathCoveredByRc()` parses, so a fish user whose `fish_user_paths`
|
||
* already covers the global bin would otherwise see a false-positive
|
||
* "not on your PATH" warning on every install. Two detection routes,
|
||
* mirroring how `fish_add_path` actually persists:
|
||
*
|
||
* 1. The universal-variable store `fish_variables` — a
|
||
* `SETUVAR fish_user_paths:<a>\x1e<b>…` line whose `\x1e`-separated
|
||
* entries are absolute paths (fish does not HOME-expand them here).
|
||
* 2. `config.fish` — explicit `fish_add_path …`, `set -gx PATH …`, or
|
||
* `set -Ux fish_user_paths …` lines that name the directory after
|
||
* HOME expansion.
|
||
*
|
||
* Best-effort and side-effect-free: any unreadable / missing file is ignored
|
||
* (no fish subprocess is spawned). Honours `$XDG_CONFIG_HOME` and always also
|
||
* checks `~/.config/fish`. Pass `fishConfigDir` to override the lookup
|
||
* directory (tests).
|
||
*
|
||
* @param {string} globalBin Absolute path to npm's global bin directory.
|
||
* @param {string} homeDir Absolute path used to substitute HOME / ~.
|
||
* @param {string} [fishConfigDir] Override the fish config directory.
|
||
* @returns {boolean} true iff fish config adds globalBin to PATH.
|
||
*/
|
||
function homePathCoveredByFishConfig(globalBin, homeDir, fishConfigDir) {
|
||
if (!globalBin || !homeDir) return false;
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
|
||
const normalise = (p) => {
|
||
if (!p) return '';
|
||
let n = p.replace(/[\\/]+$/g, '');
|
||
if (n === '') n = p.startsWith('/') ? '/' : p;
|
||
return n;
|
||
};
|
||
|
||
const targetAbs = normalise(path.resolve(globalBin));
|
||
const homeAbs = path.resolve(homeDir);
|
||
|
||
const baseDirs = [];
|
||
if (fishConfigDir) {
|
||
baseDirs.push(fishConfigDir);
|
||
} else {
|
||
if (process.env.XDG_CONFIG_HOME) {
|
||
baseDirs.push(path.join(process.env.XDG_CONFIG_HOME, 'fish'));
|
||
}
|
||
baseDirs.push(path.join(homeAbs, '.config', 'fish'));
|
||
}
|
||
|
||
const expandHome = (segment) => {
|
||
let s = segment;
|
||
s = s.replace(/\$\{HOME\}/g, homeAbs).replace(/\$HOME/g, homeAbs);
|
||
if (s.startsWith('~/') || s === '~') {
|
||
s = s === '~' ? homeAbs : path.join(homeAbs, s.slice(2));
|
||
}
|
||
return s;
|
||
};
|
||
|
||
// Compare an already-resolved absolute literal (a decoded fish_user_paths
|
||
// entry — fish stores these resolved, never as `$VAR`/`~`). A literal `$`
|
||
// here is part of the directory name, so it must NOT be treated as an
|
||
// unexpanded variable.
|
||
const matchesLiteral = (segment) => {
|
||
if (!segment || !path.isAbsolute(segment)) return false;
|
||
try {
|
||
return normalise(path.resolve(segment)) === targetAbs;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// Compare a config.fish shell token: strip surrounding quotes, expand the
|
||
// common HOME forms, and skip anything still holding a `$` (an unexpanded
|
||
// variable such as `$PATH` / `$fish_user_paths`) or still relative.
|
||
const matchesTarget = (rawSegment) => {
|
||
if (!rawSegment) return false;
|
||
let seg = rawSegment.trim();
|
||
if ((seg.startsWith('"') && seg.endsWith('"')) ||
|
||
(seg.startsWith("'") && seg.endsWith("'"))) {
|
||
seg = seg.slice(1, -1);
|
||
}
|
||
const expanded = expandHome(seg);
|
||
if (expanded.includes('$')) return false;
|
||
return matchesLiteral(expanded);
|
||
};
|
||
|
||
const readLines = (filePath) => {
|
||
try {
|
||
return fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
for (const baseDir of baseDirs) {
|
||
// Route 1: universal variable store.
|
||
const uvarLines = readLines(path.join(baseDir, 'fish_variables'));
|
||
if (uvarLines) {
|
||
for (const rawLine of uvarLines) {
|
||
const m = /^SETUVAR(?:\s+--\S+)*\s+fish_user_paths:(.*)$/.exec(rawLine);
|
||
if (!m) continue;
|
||
// Elements are joined by the literal `\x1e` token; decode each. The
|
||
// decoded entry is an absolute literal — compare it directly.
|
||
for (const entry of m[1].split('\\x1e')) {
|
||
if (matchesLiteral(decodeFishUniversalValue(entry))) return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Route 2: config.fish explicit PATH mutations.
|
||
const configLines = readLines(path.join(baseDir, 'config.fish'));
|
||
if (configLines) {
|
||
for (const rawLine of configLines) {
|
||
const line = rawLine.replace(/^\s+/, '');
|
||
if (line.startsWith('#')) continue;
|
||
|
||
let rest = null;
|
||
let m;
|
||
if ((m = /^fish_add_path\s+(.+)$/.exec(line))) {
|
||
rest = m[1];
|
||
} else if ((m = /^set\s+(?:-\S+\s+)*PATH\s+(.+)$/.exec(line))) {
|
||
rest = m[1];
|
||
} else if ((m = /^set\s+(?:-\S+\s+)*fish_user_paths\s+(.+)$/.exec(line))) {
|
||
rest = m[1];
|
||
}
|
||
if (rest === null) continue;
|
||
|
||
// Tokens are whitespace-separated; flag tokens (`-g`, `--path`) and
|
||
// variable references are skipped by matchesTarget / the `-` guard.
|
||
for (const tok of rest.split(/\s+/)) {
|
||
if (!tok || tok.startsWith('-')) continue;
|
||
if (matchesTarget(tok)) return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Emit a PATH-export suggestion if globalBin is not already on PATH AND
|
||
* the user's shell rc files do not already cover it via a HOME-relative
|
||
* entry (#2620).
|
||
*
|
||
* Prints one of:
|
||
* - nothing, if `globalBin` is already present on `process.env.PATH`
|
||
* - a diagnostic "already covered via rc file" note, if an rc file has
|
||
* `export PATH="$HOME/…/bin:$PATH"` (or equivalent) and the user just
|
||
* needs to reopen their shell
|
||
* - projected shell actions that append `export PATH="…:$PATH"` to
|
||
* `~/.zshrc` / `~/.bashrc` when neither PATH nor rc files cover globalBin
|
||
* if neither PATH nor any rc file covers globalBin
|
||
*
|
||
* Exported for tests; the installer calls this from finishInstall.
|
||
*
|
||
* @param {string} globalBin Absolute path to npm's global bin directory.
|
||
* @param {string} homeDir Absolute HOME path.
|
||
*/
|
||
function maybeSuggestPathExport(globalBin, homeDir) {
|
||
if (!globalBin || !homeDir) return;
|
||
const path = require('path');
|
||
|
||
const pathEnv = process.env.PATH || '';
|
||
const targetAbs = path.resolve(globalBin).replace(/[\\/]+$/g, '') || globalBin;
|
||
const onPath = pathEnv.split(path.delimiter).some((seg) => {
|
||
if (!seg) return false;
|
||
const abs = path.resolve(seg).replace(/[\\/]+$/g, '') || seg;
|
||
return abs === targetAbs;
|
||
});
|
||
if (onPath) return;
|
||
|
||
// Already added to PATH via an rc file, but the current shell predates that
|
||
// edit — tell the user to reopen rather than (wrongly) suggesting they add it
|
||
// again. Applies to whatever bin dir we install into (retained shim-agnostic).
|
||
if (homePathCoveredByRc(globalBin, homeDir)) {
|
||
console.log('');
|
||
console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset}'s directory is already on your PATH via an rc file entry — try reopening your shell (or ${cyan}source ~/.zshrc${reset}).`);
|
||
console.log('');
|
||
return;
|
||
}
|
||
|
||
// Same idea for fish users: fish_user_paths / config.fish already covers the
|
||
// dir, the current session just predates it. fish has no sh-style rc file so
|
||
// homePathCoveredByRc never sees it — check the fish config explicitly (#323).
|
||
if (homePathCoveredByFishConfig(globalBin, homeDir)) {
|
||
console.log('');
|
||
console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset}'s directory is already on your PATH via fish's universal variables — open a new fish session (or run ${cyan}exec fish${reset}).`);
|
||
console.log('');
|
||
return;
|
||
}
|
||
|
||
console.log('');
|
||
console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset} is not on your PATH.`);
|
||
const projected = projectPersistentPathExportActions({
|
||
targetDir: globalBin,
|
||
platform: process.platform,
|
||
});
|
||
if (projected.reason === PATH_ACTION_REASON.WIN32_RESERVED_QUOTE) {
|
||
// #3118 review MINOR: a win32 targetDir containing `"` makes
|
||
// projectPathActionProjection return [] (no command can quote it safely
|
||
// on Windows) — printing the "Add it with one of:" header with nothing
|
||
// under it is a silent dead-end. Name the cause instead.
|
||
console.log(` No command can be suggested: the path contains a ${cyan}"${reset} character, which cannot appear in a Windows path.`);
|
||
} else if (projected.shellActions.length === 0) {
|
||
// #3118: no target directory to talk about (reason === NO_TARGET_DIR, or
|
||
// no reason at all) — there is nothing to print beyond the "not on your
|
||
// PATH" line above.
|
||
} else {
|
||
console.log(` Add it with one of:`);
|
||
for (const action of projected.shellActions) {
|
||
const labelPrefix = action.label ? `${action.label}: ` : '';
|
||
console.log(` ${cyan}${labelPrefix}${action.command}${reset}`);
|
||
}
|
||
}
|
||
console.log('');
|
||
}
|
||
|
||
// Runtime subdir names to scan for legacy get-shit-done-cc artifacts (#607).
|
||
// Covers both local (project-relative) and common global forms.
|
||
const _LEGACY_SCAN_SUBDIR_NAMES = [
|
||
'.claude',
|
||
'.gemini',
|
||
'.opencode',
|
||
'.config/opencode',
|
||
'.codex',
|
||
'.agents', // antigravity local form (canonical, #791)
|
||
'.agent', // antigravity local form (legacy, backward-compat)
|
||
'.cursor',
|
||
];
|
||
|
||
/**
|
||
* Detect and remove leftover get-shit-done-cc artifacts across ALL known
|
||
* runtime config directories (issue #607).
|
||
*
|
||
* Exported so tests can call it directly without spawning a subprocess.
|
||
*
|
||
* Scans ONLY subdirs under homeDir — never cwd — to avoid touching the
|
||
* user's active-project hooks when the installer is run from a project dir.
|
||
*
|
||
* @param {object} [opts]
|
||
* @param {string} [opts.homeDir=os.homedir()] - home directory to scan
|
||
* @param {boolean} [opts.dryRun=false] - preview only; no mutations
|
||
* @param {object} [opts.logger=console] - injectable logger
|
||
* @returns {{ plan: {path:string,reason:string}[], result: object }}
|
||
*/
|
||
function cleanupLegacyMsdCc({ homeDir = os.homedir(), configDirs = null, dryRun = false, logger = console } = {}) {
|
||
// Build de-duplicated list of candidate config dirs to scan.
|
||
// Only scan under homeDir — never cwd — to prevent accidental deletion of
|
||
// the user's active-project hooks when the installer is invoked from a
|
||
// project directory that has .claude/hooks or similar subdirs.
|
||
// #3799: an explicit configDirs override (install() passes [targetDir]
|
||
// whenever --config-dir redirected the destination) scopes the WHOLE scan
|
||
// to that dir — the default-home scan must never plan removals of a live
|
||
// install that lives outside the destination the user chose.
|
||
const seen = new Set();
|
||
const scanDirs = [];
|
||
if (Array.isArray(configDirs) && configDirs.length > 0) {
|
||
for (const candidate of configDirs) {
|
||
if (!seen.has(candidate) && fs.existsSync(candidate)) {
|
||
seen.add(candidate);
|
||
scanDirs.push(candidate);
|
||
}
|
||
}
|
||
} else {
|
||
for (const name of _LEGACY_SCAN_SUBDIR_NAMES) {
|
||
const candidate = path.join(homeDir, name);
|
||
if (!seen.has(candidate) && fs.existsSync(candidate)) {
|
||
seen.add(candidate);
|
||
scanDirs.push(candidate);
|
||
}
|
||
}
|
||
}
|
||
|
||
// planLegacyCleanup scans each configDir and already includes the legacy
|
||
// shared cache (msd-update-check.json) as a plan entry.
|
||
const plan = planLegacyCleanup(scanDirs, { homeDir, ...(Array.isArray(configDirs) && configDirs.length > 0 ? { configDirs } : {}) });
|
||
|
||
// Apply the plan (dryRun honors the flag).
|
||
const result = applyLegacyCleanup(plan, { dryRun, logger });
|
||
|
||
// Also clear / preview the per-package cache so next session re-evaluates
|
||
// hook versions (replaces the former inline unlinkSync on line ~9104).
|
||
// #3799: under a configDirs override the cache is read/cleared under the
|
||
// SCOPE root, never the default home — same invariant as the scan itself.
|
||
const perPkgCacheRoot = (Array.isArray(configDirs) && configDirs.length > 0)
|
||
? configDirs[0]
|
||
: homeDir;
|
||
const perPkgCacheFile = path.join(perPkgCacheRoot, '.cache', 'msd', updateCacheFileName);
|
||
if (dryRun) {
|
||
logger.log('[dry-run] would remove: ' + perPkgCacheFile + ' (per-package-update-cache)');
|
||
} else {
|
||
try { fs.unlinkSync(perPkgCacheFile); } catch (_e) { /* cache may not exist yet */ }
|
||
}
|
||
|
||
// Concise summary
|
||
if (plan.length > 0 || !dryRun) {
|
||
const verb = dryRun ? 'Would remove' : 'Removed';
|
||
const count = dryRun ? plan.length : result.removed.length;
|
||
logger.log(`[legacy-cleanup] ${verb} ${count} legacy artifact(s).`);
|
||
}
|
||
|
||
return { plan, result };
|
||
}
|
||
|
||
/**
|
||
* Install MSD for all selected runtimes
|
||
*/
|
||
function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
||
const results = [];
|
||
const installerMigrations = discoverInstallerMigrations({
|
||
migrationsDir: path.join(_msdLibDir, 'installer-migrations'),
|
||
});
|
||
|
||
const rollbackFinalizedInstallerMigrations = (error) => {
|
||
const rollbackFailures = [];
|
||
// #4249: this discriminates on the error's KIND, never on which runtime
|
||
// owns the failing entrypoint. `wide` is true for ANY entrypoint-validation
|
||
// failure from ANY runtime, by design: the aggregate gate exists so a
|
||
// multi-runtime install cannot report success while one of its entrypoints
|
||
// is broken, so an invalid Cursor entrypoint reverts Codex's pre-install
|
||
// snapshot too — even though Codex itself was fine and its own "Done!"
|
||
// summary already printed. tests/configured-entrypoint-validation.test.cjs
|
||
// ('an aggregate entrypoint validation failure rolls the Codex install
|
||
// back') exercises exactly that, and it is the all-or-nothing behaviour
|
||
// docs/how-to/update-msd.md documents.
|
||
//
|
||
// What this narrows is the OTHER axis: a finalize-stage exception that is
|
||
// not an entrypoint-validation failure at all — e.g. a sibling runtime's
|
||
// permission-config write dying with EACCES — gets only the
|
||
// installer-migrations-only rollback that Phase 4 specifies
|
||
// (docs/installer-migrations.md#phase-4-installupdate-integration).
|
||
// Un-installing (and, on update, downgrading) an already-"Done!" Codex over
|
||
// an unrelated error is not an outcome any doc promises, while the sibling
|
||
// surfaces that write config inside install() would keep theirs regardless.
|
||
const wide = !!(error && error.configuredEntrypointValidation);
|
||
for (const result of [...results].reverse()) {
|
||
if (!result) continue;
|
||
const rollback = (wide && result.rollbackPreInstallSnapshot) || result.rollbackInstallerMigrations;
|
||
if (typeof rollback !== 'function') continue;
|
||
try {
|
||
rollback();
|
||
} catch (rollbackError) {
|
||
rollbackFailures.push({
|
||
runtime: result.runtime,
|
||
error: rollbackError.message,
|
||
});
|
||
}
|
||
}
|
||
if (rollbackFailures.length > 0) {
|
||
error.installerMigrationRollbackFailures = rollbackFailures;
|
||
}
|
||
};
|
||
|
||
try {
|
||
for (const runtime of runtimes) {
|
||
const result = install(isGlobal, runtime, { installerMigrations });
|
||
results.push(result);
|
||
}
|
||
} catch (error) {
|
||
rollbackFinalizedInstallerMigrations(error);
|
||
throw error;
|
||
}
|
||
|
||
const statuslineRuntimes = [DEFAULT_RUNTIME];
|
||
const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
|
||
|
||
const finalize = (shouldInstallStatusline, shouldInstallBanner) => {
|
||
try {
|
||
const selectedConfiguredEntrypoints = (result) => {
|
||
if (!result || result.skipped) return [];
|
||
const useStatusline = statuslineRuntimes.includes(result.runtime)
|
||
&& shouldInstallStatusline
|
||
&& (isGlobal || forceStatusline);
|
||
return [
|
||
...(result.configuredEntrypoints || []),
|
||
...(useStatusline ? (result.statuslineEntrypoints || []) : []),
|
||
...(shouldInstallBanner ? (result.updateBannerEntrypoints || []) : []),
|
||
];
|
||
};
|
||
assertConfiguredEntrypoints(results.flatMap(selectedConfiguredEntrypoints));
|
||
|
||
const printSummaries = () => {
|
||
for (const result of results) {
|
||
if (result && result.skipped) continue;
|
||
if (!result) continue;
|
||
const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline;
|
||
finishInstall(
|
||
result.settingsPath,
|
||
result.settings,
|
||
result.statuslineCommand,
|
||
useStatusline,
|
||
result.runtime,
|
||
isGlobal,
|
||
result.configDir,
|
||
{
|
||
shouldInstallBanner: !!shouldInstallBanner,
|
||
bannerCommand: result.updateBannerCommand,
|
||
configuredEntrypoints: selectedConfiguredEntrypoints(result),
|
||
}
|
||
);
|
||
}
|
||
};
|
||
|
||
printSummaries();
|
||
} catch (error) {
|
||
// Phase 4 install/update integration requires safe migrations to roll
|
||
// back when later package/finalization materialization fails:
|
||
// docs/installer-migrations.md#phase-4-installupdate-integration.
|
||
rollbackFinalizedInstallerMigrations(error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// Statusline first; if it won't actually be installed (declined, or local
|
||
// install without --force-statusline silently skips it per #2248), offer
|
||
// the opt-in update banner (#2795) as the secondary surface for update
|
||
// notifications. Skip the banner prompt entirely when no runtime in this
|
||
// install set can host the banner (e.g. Codex/Cursor-only installs whose
|
||
// updateBannerCommand is null).
|
||
//
|
||
// CR #3035: gate on actual installability — `shouldInstallStatusline`
|
||
// returned by handleStatusline is the raw user choice, but
|
||
// `finishInstall` later skips the statusline write on local installs
|
||
// unless --force-statusline is set. Passing the raw flag to
|
||
// continueAfterStatusline previously caused two bugs: (1) interactive
|
||
// local installs got neither a statusline nor a banner offer, and (2)
|
||
// banner-incapable runtimes got prompted even though every
|
||
// updateBannerCommand was null.
|
||
const canInstallBanner = results.some((r) => r && r.updateBannerCommand);
|
||
const continueAfterStatusline = (shouldInstallStatusline) => {
|
||
const willInstallStatusline =
|
||
shouldInstallStatusline && (isGlobal || forceStatusline);
|
||
if (willInstallStatusline) {
|
||
finalize(true, false);
|
||
return;
|
||
}
|
||
if (!canInstallBanner) {
|
||
finalize(shouldInstallStatusline, false);
|
||
return;
|
||
}
|
||
handleUpdateBanner(isInteractive, (shouldInstallBanner) => {
|
||
finalize(shouldInstallStatusline, shouldInstallBanner);
|
||
});
|
||
};
|
||
|
||
// `settings` is null on every early exit (unparseable file, skipped runtime),
|
||
// and handleStatusline dereferences it — an install that declined to touch a
|
||
// settings file has no statusline to prompt about, so fall through.
|
||
if (primaryStatuslineResult && primaryStatuslineResult.settings) {
|
||
handleStatusline(primaryStatuslineResult.settings, isInteractive, continueAfterStatusline);
|
||
} else if (canInstallBanner) {
|
||
// No statusline-capable runtime, but at least one runtime can host the
|
||
// banner — still offer it.
|
||
handleUpdateBanner(isInteractive, (shouldInstallBanner) => {
|
||
finalize(false, shouldInstallBanner);
|
||
});
|
||
} else {
|
||
// Nothing to prompt about — no statusline, no banner-capable runtime.
|
||
finalize(false, false);
|
||
}
|
||
}
|
||
|
||
// Always export so runtime-artifact-layout.cjs's lazy loader can access
|
||
// converter functions when called from within the CLI path (circular require).
|
||
// The main() block below is gated on !MSD_TEST_MODE, as before.
|
||
module.exports = {
|
||
// #3677 — hyphen-namespace normalization seam for agent bodies
|
||
shouldNormalizeHyphenNamespaceInAgentBody,
|
||
// #3664: --config-dir foreign-agent-destination warning (warn-and-proceed)
|
||
warnIfForeignAgentDest,
|
||
normalizeAgentBodyForRuntime,
|
||
yamlIdentifier,
|
||
getCodexSkillAdapterHeader,
|
||
convertClaudeCommandToCursorSkill,
|
||
convertClaudeAgentToCursorAgent,
|
||
convertClaudeAgentToCodexAgent,
|
||
generateCodexAgentToml,
|
||
_resetCodexWarningDedupeForTests,
|
||
cleanupCodexSkillMetadataSidecars,
|
||
cleanupMovedSkillsOldLocation,
|
||
_resolveMovedSkillsOldDir,
|
||
_resolveSkillsRootDir,
|
||
codexBareAgentsHasOnlyKnownScalars,
|
||
extractCodexUserAgentsScalars,
|
||
CODEX_EXTENDED_HOOK_EVENTS,
|
||
generateCodexConfigBlock,
|
||
stripMsdFromCodexConfig,
|
||
migrateCodexHooksMapFormat,
|
||
stripStaleMsdHookBlocks,
|
||
hasUserNamespacedAotHooks,
|
||
parseTomlToObject,
|
||
validateCodexConfigSchema,
|
||
mergeCodexConfig,
|
||
installCodexConfig,
|
||
install,
|
||
installAllRuntimes,
|
||
uninstall,
|
||
// #2086 — host-behavior resolution + the #338 privacy fail-safe floor (exported for tests)
|
||
_resolveHostBehaviors,
|
||
FALLBACK_HOST_BEHAVIORS,
|
||
// #3023 — shared hook bundle directory name, descriptor-driven
|
||
SHARED_HOOKS_DIR,
|
||
// #3184 — uninstall-side MSD-managed file enumerations, exported for
|
||
// parity assertions against the wholesale-copy source directories
|
||
MSD_CHANGESET_FILES,
|
||
MSD_SCRIPTS_LIB_FILES,
|
||
convertSlashCommandsToCodexSkillMentions,
|
||
convertClaudeCommandToCodexSkill,
|
||
convertClaudeToOpencodeFrontmatter,
|
||
configureOpencodePermissions,
|
||
neutralizeAgentReferences,
|
||
// #768 — Claude Code permissions pre-population
|
||
mergeClaudePermissions,
|
||
MSD_CLAUDE_ALLOW_PERMISSIONS,
|
||
MSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS,
|
||
MSD_CLAUDE_LEGACY_DENY_PERMISSIONS,
|
||
MSD_CODEX_MARKER,
|
||
// #3897 rung 3 (ADR-3473 §8.3, HALT.md option 2)
|
||
CODEX_SANDBOX_HOLDS,
|
||
deriveCodexSandboxMode,
|
||
validateCodexSandboxHolds,
|
||
getGlobalDir,
|
||
getConfigDirFromHome,
|
||
// #2096 Phase B Upgrades 1+2 — Antigravity permission-writer + MCP companion
|
||
toTildePosixPath,
|
||
buildAntigravityAllowRules,
|
||
configureAntigravityPermissions,
|
||
configureAntigravityMcpConfig,
|
||
convertClaudeToAntigravityContent,
|
||
convertClaudeCommandToAntigravitySkill,
|
||
convertClaudeAgentToAntigravityAgent,
|
||
convertClaudeCommandToClaudeSkill,
|
||
skillFrontmatterName,
|
||
MSD_CURSOR_SESSION_HOOK_SCRIPT,
|
||
MSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
|
||
MSD_CURSOR_HOOK_MARKER,
|
||
writeManifest,
|
||
saveLocalPatches,
|
||
reportLocalPatches,
|
||
validateHookFields,
|
||
populatePristineDir,
|
||
describeBaselineCoverage,
|
||
_resolveUserArtifactStagingRoot,
|
||
_tryResolveUserArtifactStagingRoot,
|
||
finishInstall,
|
||
homePathCoveredByRc,
|
||
homePathCoveredByFishConfig,
|
||
decodeFishUniversalValue,
|
||
maybeSuggestPathExport,
|
||
runtimeMap,
|
||
allRuntimes,
|
||
selectRuntimesFromArgs,
|
||
MSD_UNINSTALL_HOOKS,
|
||
parseRuntimeInput,
|
||
buildRuntimePromptText,
|
||
buildUpdateBannerPromptText,
|
||
parseUpdateBannerInput,
|
||
buildUpdateBannerHookEntry,
|
||
parseConfigDirFromArgs,
|
||
cleanupLegacyMsdCc,
|
||
// #1191 — exported so tests exercise the REAL readSettings, not a replica
|
||
readSettings,
|
||
writeSettings,
|
||
writeNonClaudeDefaults,
|
||
stripJsonComments,
|
||
copyWithPathReplacement,
|
||
};
|
||
|
||
// Main logic — only run when not loaded as a module for testing
|
||
if (require.main === module && !process.env.MSD_TEST_MODE) {
|
||
if (hasDryRun) {
|
||
// --dry-run: preview legacy cleanup and exit without installing.
|
||
if (hasUninstall) {
|
||
console.log('Note: --dry-run previews legacy get-shit-done-cc cleanup only; it does not preview --uninstall.');
|
||
}
|
||
console.log('Dry run — no files will be modified.\n');
|
||
// cleanupLegacyMsdCc with dryRun:true is the single source of truth for
|
||
// both the legacy artifacts and the per-package cache path — no duplicate
|
||
// printing here. #3799: the preview honors the SAME scope and skip the
|
||
// real install would apply (--config-dir scopes; --no-legacy-cleanup
|
||
// skips) — a preview that listed default-home paths a real install would
|
||
// never touch misrepresents the run.
|
||
if (parseNoLegacyCleanupArg()) {
|
||
console.log(' (--no-legacy-cleanup — legacy scan skipped)');
|
||
} else {
|
||
const previewScope = (explicitConfigDir !== null)
|
||
? [getGlobalConfigDir(DEFAULT_RUNTIME, explicitConfigDir)]
|
||
: undefined;
|
||
const { plan } = cleanupLegacyMsdCc({
|
||
dryRun: true,
|
||
...(previewScope ? { configDirs: previewScope } : {}),
|
||
});
|
||
if (plan.length === 0) {
|
||
console.log(' (no legacy get-shit-done-cc artifacts found)');
|
||
}
|
||
}
|
||
process.exit(0);
|
||
} else if (hasSkillsRoot) {
|
||
// Print the skills root directory for a given runtime (used by /msd-sync-skills).
|
||
// Usage: node install.js --skills-root <runtime>
|
||
const runtimeArg = args[args.indexOf('--skills-root') + 1];
|
||
if (!runtimeArg || runtimeArg.startsWith('--')) {
|
||
console.error('Usage: node install.js --skills-root <runtime>');
|
||
process.exit(1);
|
||
}
|
||
// #3024: validate the runtime id against the shipped capability registry
|
||
// BEFORE resolving anything. getGlobalSkillsBase's bare `runtimes[runtime]`
|
||
// lookup falls through the prototype chain to claude's skills root for an
|
||
// unregistered/hostile id (`__proto__`, `constructor`, `prototype`, …)
|
||
// instead of failing loudly. isRegisteredRuntimeId is the SAME validator
|
||
// msd-tools' `routeSkillsRoot` calls, so this entry point and the shipped
|
||
// `msd-tools query skills-root` entry point can never diverge on which
|
||
// runtime ids they accept.
|
||
if (!isRegisteredRuntimeId(runtimeArg)) {
|
||
console.error(`Unknown runtime "${runtimeArg}" — must be a registered runtime id`);
|
||
process.exit(1);
|
||
}
|
||
const skillsRoot = getGlobalSkillsBase(runtimeArg.trim());
|
||
if (skillsRoot === null) {
|
||
console.error(`${runtimeArg} does not use a skills directory`);
|
||
process.exit(1);
|
||
}
|
||
console.log(skillsRoot);
|
||
} else if (hasGlobal && hasLocal) {
|
||
console.error(` ${yellow}Cannot specify both --global and --local${reset}`);
|
||
process.exit(1);
|
||
} else if (explicitConfigDir && hasLocal) {
|
||
console.error(` ${yellow}Cannot use --config-dir with --local${reset}`);
|
||
process.exit(1);
|
||
} else if (hasUninstall) {
|
||
if (!hasGlobal && !hasLocal) {
|
||
console.error(` ${yellow}--uninstall requires --global or --local${reset}`);
|
||
process.exit(1);
|
||
}
|
||
const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : [DEFAULT_RUNTIME];
|
||
for (const runtime of runtimes) {
|
||
uninstall(hasGlobal, runtime);
|
||
}
|
||
} else if (selectedRuntimes.length > 0) {
|
||
if (!hasGlobal && !hasLocal) {
|
||
promptLocation(selectedRuntimes);
|
||
} else {
|
||
installAllRuntimes(selectedRuntimes, hasGlobal, false);
|
||
}
|
||
} else if (hasGlobal || hasLocal) {
|
||
// Default to Claude if no runtime specified but location is
|
||
installAllRuntimes([DEFAULT_RUNTIME], hasGlobal, false);
|
||
} else {
|
||
// Interactive
|
||
if (!process.stdin.isTTY) {
|
||
console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code global install${reset}\n`);
|
||
installAllRuntimes([DEFAULT_RUNTIME], true, false);
|
||
} else {
|
||
promptRuntime((runtimes) => {
|
||
promptLocation(runtimes);
|
||
});
|
||
}
|
||
}
|
||
|
||
} // end of !MSD_TEST_MODE main logic block
|