Files
msd-core/bin/install.js
Tom Boucher fb5f89db10 feat(#1704): destSubpath write-confinement (ADR-1239 Phase B) (#1706)
* feat(#1679): confine install writes within configHome

ADR-1239 Phase B write-confinement: a pure assertDestWithinConfigHome(configDir, destSubpath) rejects a destSubpath that escapes configHome (path traversal / NUL byte) at plan-build time on BOTH the install and uninstall plan paths; surface.applySurface and installOpencodeFamilySkills route through it, and _copyStaged carries a defense-in-depth containment check. Security-load-bearing for the Phase C third-party-descriptor loader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1704): add changeset for destSubpath write-confinement

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1704): fix windows path-portability in confinement test

The N1 'accepts a true child subpath' assertion compared against path.join (no drive resolution) while the helper uses path.resolve — on Windows that mismatches the C: drive prefix. Compute the expected via path.resolve to mirror the helper. Windows-CI-only failure (local gsd-test is Mac+Linux).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:20:25 -04:00

12437 lines
523 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const os = require('os');
const readline = require('readline');
const crypto = require('crypto');
const {
isManagedHookBasename,
isManagedHookCommand,
projectLocalHookPrefix,
projectLegacySettingsHookCommand,
projectManagedHookCommand,
projectPathActionProjection,
projectPortableHookBaseDir,
projectPersistentPathExportActions,
projectShellCommandText,
projectCodexHookTomlCommand,
shellHookOmitsBashRunner,
buildLocalShellHookCommand,
} = require('../gsd-core/bin/lib/shell-command-projection.cjs');
// Bidirectional GSD 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,
readGsdCommandNames,
} = require('../gsd-core/bin/lib/command-roster.cjs');
const {
resolveAntigravityGlobalDir,
getGlobalConfigDir,
getGlobalSkillsBase,
} = require('../gsd-core/bin/lib/runtime-homes.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. Re-exported below for back-compat consumers/tests.
const { getDirName } = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
const {
applyWorktreeBaseRef,
readBaseRefFromSettings,
} = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.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.
// bin/install.js re-exports everything from hooksSurface so existing callers
// (require('../bin/install.js').writeCursorHooksJson etc.) continue to work.
const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
/**
* Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
* verbatim (only branding swaps, no namespace conversion), so retired
* `/gsd:<cmd>` colon refs leak into installed agent prose. Sibling fixes
* #3583 / #3629 covered SKILL.md bodies, #3584 / #3606 covered runtime
* emissions — this is the agent-body surface (#3677).
*
* Explicit allow-list rather than deny-list so unknown / future runtimes
* default to "no rewrite" (better to leak than to mangle a runtime whose
* namespace behavior we haven't verified).
*/
const HYPHEN_NAME_AGENT_RUNTIMES = new Set(['claude', 'qwen', 'hermes']);
/**
* #3677 predicate — true when an agent body needs `/gsd:<cmd>` → `/gsd-<cmd>`
* normalization at install time.
*/
function shouldNormalizeHyphenNamespaceInAgentBody(runtime) {
if (typeof runtime !== 'string' || runtime === '') return false;
return HYPHEN_NAME_AGENT_RUNTIMES.has(runtime);
}
/**
* #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 GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by gsd-core installer';
const GSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# GSD codex_hooks ownership: ';
// 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 / permissions.deny entries.
// Pre-populated during Claude installs to eliminate first-run approval friction
// for gsd-core's own known-safe tool calls, and to add defense-in-depth deny
// entries for common credential files.
//
// Format: each string uses Claude Code's documented permission rule syntax \u2014
// "Tool(pattern)" e.g. "Bash(npx gsd-core *)", "Read(.planning/*)"
// "Tool" (bare tool name, no pattern)
//
// Merge policy: additive, non-destructive \u2014 existing user entries are preserved;
// GSD entries are appended only when not already present (idempotent).
const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([
'Bash(npx gsd-core *)',
'Read(.planning/*)',
'Write(.planning/*)',
'Read(STATE.md)',
'Write(STATE.md)',
]);
const GSD_CLAUDE_DENY_PERMISSIONS = Object.freeze([
'Read(.env)',
'Read(.env.*)',
'Read(.secrets)',
]);
/**
* Merge GSD-owned permission entries into a Claude Code settings object.
*
* Additive and idempotent: existing allow/deny entries are preserved; GSD
* entries are appended only if not already present. No other permission sub-keys
* (ask, disableBypassPermissionsMode, etc.) are touched.
*
* 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 (!Array.isArray(settings.permissions.deny)) {
settings.permissions.deny = [];
}
for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) {
if (!settings.permissions.allow.includes(entry)) {
settings.permissions.allow.push(entry);
}
}
for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) {
if (!settings.permissions.deny.includes(entry)) {
settings.permissions.deny.push(entry);
}
}
}
// Copilot instructions marker constants
const GSD_COPILOT_INSTRUCTIONS_MARKER = '<!-- GSD Configuration \u2014 managed by gsd-core installer -->';
const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = '<!-- /GSD Configuration -->';
// #786 \u2014 GitHub Copilot CLI lifecycle hook constants.
// Copilot reads hook configs from <config>/hooks/*.json (repo scope: .github/hooks/,
// user scope: ~/.copilot/hooks/) with the shape { version, hooks: { <event>: [...] } }.
// Events use camelCase (sessionStart, preToolUse, postToolUse, ...). A `command`
// hook runs an INLINE shell command (bash / powershell), so the GSD hook is fully
// self-contained \u2014 there is no separate hook script to install, and therefore
// nothing that can dangle if a script copy is skipped. See
// https://docs.github.com/en/copilot/reference/hooks-configuration
const GSD_COPILOT_HOOK_FILE = 'gsd-session.json';
// Copilot parses a command hook's stdout as the hook-output JSON. For sessionStart
// the schema is `{ additionalContext?: string }` (the text is prepended to the
// session as context). So the hook must emit that JSON envelope — not bare text.
// The two messages contain no JSON-special characters, so they embed verbatim.
const GSD_COPILOT_SESSION_MSG_PRESENT =
'GSD: .planning/STATE.md present - review the current phase and any blockers before acting.';
const GSD_COPILOT_SESSION_MSG_ABSENT =
'GSD: no .planning/ workflow found - run /gsd-new-project to start a tracked workflow.';
const GSD_COPILOT_SESSION_HOOK_BASH =
'if [ -f .planning/STATE.md ]; then ' +
`printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}'; else ` +
`printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}'; fi`;
const GSD_COPILOT_SESSION_HOOK_PWSH =
'if (Test-Path .planning/STATE.md) ' +
`{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` +
`else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`;
// #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. GSD registers two managed hooks:
// sessionStart → gsd-cursor-session-start.js (context injection)
// postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor)
// Cursor docs: https://cursor.com/docs/hooks
const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
// Marker comment embedded in managed hook entries so GSD can find+remove them.
const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
// GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
// git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
const CODEX_AGENT_SANDBOX = {
'gsd-executor': 'workspace-write',
'gsd-planner': 'workspace-write',
'gsd-phase-researcher': 'workspace-write',
'gsd-project-researcher': 'workspace-write',
'gsd-research-synthesizer': 'workspace-write',
'gsd-verifier': 'workspace-write',
'gsd-codebase-mapper': 'workspace-write',
'gsd-roadmapper': 'workspace-write',
'gsd-debugger': 'workspace-write',
'gsd-plan-checker': 'read-only',
'gsd-integration-checker': 'read-only',
};
// Copilot tool name mapping — Claude Code tools to GitHub Copilot tools
// Tool mapping applies ONLY to agents, NOT to skills (per CONTEXT.md decision)
const claudeToCopilotTools = {
Read: 'read',
Write: 'edit',
Edit: 'edit',
Bash: 'execute',
Grep: 'search',
Glob: 'search',
Task: 'agent',
WebSearch: 'web',
WebFetch: 'web',
TodoWrite: 'todo',
AskUserQuestion: 'ask_user',
SlashCommand: 'skill',
};
// 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 `gsd install codex` works
// when invoked via npm global install (cwd is the user's project, not the gsd repo
// root). Inline `require('../gsd-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 _gsdLibDir = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
const { MODEL_PROFILES: GSD_MODEL_PROFILES } = require(path.join(_gsdLibDir, 'model-profiles.cjs'));
const {
RUNTIME_PROFILE_MAP: GSD_RUNTIME_PROFILE_MAP,
} = require(path.join(_gsdLibDir, 'model-catalog.cjs'));
const {
resolveTierEntry: gsdResolveTierEntry,
EFFORT_SET: GSD_EFFORT_SET,
} = require(path.join(_gsdLibDir, 'model-resolver.cjs'));
// #443 — model-catalog and config-defaults.manifest.json exports needed only
// by effort-resolution code paths (resolveInstallTimeEffort /
// generateCodexAgentToml / Claude .md effort injection). Loaded lazily the
// first time they are needed so that requiring install.js in test contexts that
// never trigger an install does NOT produce module-load-time side effects (the
// manifest read + hard throw) that could alter subprocess exit codes or stderr.
let _gsdEffortCatalogCache = null;
function _getGsdEffortCatalog() {
if (_gsdEffortCatalogCache) return _gsdEffortCatalogCache;
const { AGENT_DEFAULT_TIERS, renderEffortForRuntime } = require(path.join(_gsdLibDir, 'model-catalog.cjs'));
const manifestPath = path.join(
__dirname,
'..',
'gsd-core',
'bin',
'shared',
'config-defaults.manifest.json'
);
let manifestData;
try {
manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch (_err) {
// Fail loudly — a missing manifest is a broken install, not a soft degradation.
throw new Error(
`gsd install: cannot load config-defaults.manifest.json at ${manifestPath}: ${_err.message}`
);
}
const tierDefaults =
(manifestData.effort &&
manifestData.effort.routing_tier_defaults &&
typeof manifestData.effort.routing_tier_defaults === 'object' &&
!Array.isArray(manifestData.effort.routing_tier_defaults))
? manifestData.effort.routing_tier_defaults
: { light: 'low', standard: 'high', heavy: 'xhigh' }; // guard: unreachable if manifest is valid
const effortDefault =
(manifestData.effort && typeof manifestData.effort.default === 'string')
? manifestData.effort.default
: 'high'; // guard: unreachable if manifest is valid
_gsdEffortCatalogCache = {
AGENT_DEFAULT_TIERS,
renderEffortForRuntime,
EFFORT_MANIFEST_TIER_DEFAULTS: tierDefaults,
EFFORT_MANIFEST_DEFAULT: effortDefault,
};
return _gsdEffortCatalogCache;
}
const {
MINIMAL_SKILL_ALLOWLIST,
PROFILES,
isMinimalMode,
stageSkillsForMode,
readActiveProfile,
writeActiveProfile,
resolveEffectiveProfile,
mostRestrictiveProfile,
resolveProfile,
loadSkillsManifest,
stageSkillsForProfile,
stageAgentsForProfile,
stageSkillsForRuntimeAsSkills,
} = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
// ADR-857 phase 4c: load capability registry (optional; missing → falls back to undefined)
let _capabilityRegistry;
try {
_capabilityRegistry = require(path.join(_gsdLibDir, 'capability-registry.cjs'));
} catch (_) {
_capabilityRegistry = undefined;
}
const {
applyInstallerMigrationPlan,
discoverInstallerMigrations,
runInstallerMigrations,
} = require(path.join(_gsdLibDir, 'installer-migrations.cjs'));
const {
assertInstallerMigrationsUnblocked,
resolveInstallerMigrationPromptsForNonTty,
summarizeInstallerMigrationResult,
} = require(path.join(_gsdLibDir, 'installer-migration-report.cjs'));
const {
resolveRuntimeArtifactLayout,
} = require(path.join(_gsdLibDir, 'runtime-artifact-layout.cjs'));
const {
assertDestWithinConfigHome,
createRuntimeArtifactInstallPlan,
createRuntimeArtifactUninstallPlan,
} = require(path.join(_gsdLibDir, 'runtime-artifact-install-plan.cjs'));
const {
planLegacyCleanup,
applyLegacyCleanup,
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'legacy-cleanup.cjs'));
const {
updateCacheFileName,
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
// 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.GSD_PORTABLE_HOOKS === '1';
const hasMinimal = args.includes('--minimal') || args.includes('--core-only');
const hasDryRun = args.includes('--dry-run');
// --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 `gsd 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', 'kimi', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline'];
}
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('--gemini')) selected.push('gemini');
if (runtimeArgs.includes('--kilo')) selected.push('kilo');
if (runtimeArgs.includes('--codex')) selected.push('codex');
if (runtimeArgs.includes('--copilot')) selected.push('copilot');
if (runtimeArgs.includes('--antigravity')) selected.push('antigravity');
if (runtimeArgs.includes('--cursor')) selected.push('cursor');
if (runtimeArgs.includes('--windsurf') || runtimeArgs.includes('--devin-desktop')) selected.push('windsurf');
if (runtimeArgs.includes('--augment')) selected.push('augment');
if (runtimeArgs.includes('--trae')) selected.push('trae');
if (runtimeArgs.includes('--qwen')) selected.push('qwen');
if (runtimeArgs.includes('--hermes')) selected.push('hermes');
if (runtimeArgs.includes('--kimi')) selected.push('kimi');
if (runtimeArgs.includes('--codebuddy')) selected.push('codebuddy');
if (runtimeArgs.includes('--cline')) selected.push('cline');
return selected;
}
// Runtime selection - can be set by flags or interactive prompt
let selectedRuntimes = selectRuntimesFromArgs(args);
// 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 + re-exported.
/**
* 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', 'gemini', 'codex', or 'copilot'
* @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 - OpenCode uses XDG path structure
if (runtime === 'copilot') return "'.copilot'";
if (runtime === 'opencode') {
// OpenCode: ~/.config/opencode -> '.config', 'opencode'
// Return as comma-separated for path.join() replacement
return "'.config', 'opencode'";
}
if (runtime === 'gemini') return "'.gemini'";
if (runtime === 'kilo') return "'.config', 'kilo'";
if (runtime === 'codex') return "'.codex'";
if (runtime === 'antigravity') {
if (!isGlobal) return "'.agents'";
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'";
}
if (runtime === 'cursor') return "'.cursor'";
if (runtime === 'windsurf') return "'.windsurf'";
if (runtime === 'augment') return "'.augment'";
if (runtime === 'trae') return "'.trae'";
if (runtime === 'qwen') return "'.qwen'";
if (runtime === 'hermes') return "'.hermes'";
if (runtime === 'codebuddy') return "'.codebuddy'";
if (runtime === 'cline') return "'.cline'";
if (runtime === 'kimi') return "'.config', 'agents'";
return "'.claude'";
}
/**
* 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' +
' GSD Core ' + dim + 'v' + pkg.version + reset + '\n' +
' Git. Ship. Done.\n' +
' A meta-prompting, context engineering and spec-driven\n' +
' development workflows for Claude Code, OpenCode, Gemini, Kimi CLI, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline and CodeBuddy.\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 --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}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\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 (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=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 \`gsd 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}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx ${pkg.name} --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\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} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD 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 / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`);
process.exit(0);
}
// computePathPrefix: implementation moved to runtimeArtifactConversion._computePathPrefix
// (ADR-1508 / #1511 Phase 2 — single owner). The const binding above (~line 638)
// re-exports it here for call sites and module.exports.
// 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).
// normalizeNodePath, 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.
const normalizeNodePath = hooksSurface.normalizeNodePath;
const resolveNodeRunner = hooksSurface.resolveNodeRunner;
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 GSD-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;
// computePathPrefix / applyRuntimeContentRewritesInPlace / applyRuntimeContentRewritesForCommandsInPlace:
// Single implementations now live in runtimeArtifactConversion (ADR-1508 / #1511 Phase 2).
// Re-bound here so install.js call sites and exports continue to work unchanged.
// Local bodies replaced by breadcrumb comments at their original locations.
// All call sites are below this line → no TDZ hazard.
const computePathPrefix = runtimeArtifactConversion._computePathPrefix;
const applyRuntimeContentRewritesInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesInPlace;
const applyRuntimeContentRewritesForCommandsInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesForCommandsInPlace;
// #1675 (ADR-1508): the augment converter family is single-sourced in the
// conversion module. install.js re-binds (does not re-define) these so there
// is exactly one body — the generative-drift hazard the dedup removes. The two
// private helpers (getAugmentSkillAdapterHeader, convertSlashCommandsToAugmentSkillMentions)
// live only in the conversion module now; they are no longer duplicated here.
// (All call sites are below this line → no TDZ hazard.)
const convertClaudeToAugmentMarkdown = runtimeArtifactConversion.convertClaudeToAugmentMarkdown;
const convertClaudeCommandToAugmentSkill = runtimeArtifactConversion.convertClaudeCommandToAugmentSkill;
const convertClaudeAgentToAugmentAgent = runtimeArtifactConversion.convertClaudeAgentToAugmentAgent;
function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
}
/**
* Build the GSD-managed Codex SessionStart hook block for config.toml.
*
* Issue #3017: the previous shape inlined `command = "node ${path}"` which
* fails under GUI/minimal-PATH runtimes where bare `node` doesn't resolve
* (same failure mode as #2979 → fixed for settings.json by #3002, this
* helper closes the gap for Codex's TOML hook surface).
*
* Returns null when `absoluteRunner` is null so callers can warn-and-skip
* registration — emitting a broken bare-node hook is strictly worse than
* not registering one (the user can re-run install once node is on PATH).
*
* @param {string} targetDir - Resolved absolute Codex config dir (e.g. ~/.codex).
* @param {{ absoluteRunner: string|null, eol?: string }} opts
* absoluteRunner: result of resolveNodeRunner() — a JSON-stringified
* absolute node path with forward slashes (e.g. `"/usr/local/bin/node"`),
* or null when process.execPath was unavailable.
* eol: line ending to emit ('\n' or '\r\n') — caller passes
* detectLineEnding(configContent) so existing CRLF files stay CRLF.
* Defaults to '\n'.
* @returns {string|null} The toml block to append, or null on missing runner.
*/
function buildCodexHookBlock(targetDir, opts) {
return hooksSurface.buildCodexHookBlock(targetDir, opts);
}
/**
* Rewrite legacy bare-`node` managed-hook command lines in a Codex
* config.toml string to use the absolute Node runner. Mirror of
* rewriteLegacyManagedNodeHookCommands but for the toml surface (#3017).
*
* Only rewrites entries whose script basename matches CODEX_MANAGED_HOOK_BASENAMES
* (basename equality, not substring containment) — user-authored bare-node
* hooks pointing at scripts outside the managed allowlist are left alone.
*
* @param {string} content - Current config.toml contents.
* @param {string|null} absoluteRunner - Result of resolveNodeRunner().
* @returns {{ content: string, changed: boolean }}
*/
function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
return hooksSurface.rewriteLegacyCodexHookBlock(content, absoluteRunner, opts);
}
/**
* Generic reconcile helper: ensure hooks.json contains exactly one managed GSD
* hook entry for `eventName`, while preserving all user-owned entries.
*
* Supports both known hooks.json shapes:
* 1) { "<EventName>": [...] }
* 2) { "hooks": { "<EventName>": [...] } }
*
* @param {string} targetDir - Codex config dir (e.g. ~/.codex or <project>/.codex).
* @param {string} eventName - Codex hook event name (e.g. 'SessionStart', 'Stop').
* @param {{ managedCommand?: string|null, commandWindows?: string|null, matcher?: string|null, timeout?: number|null }} opts
* managedCommand: POSIX hook command string to register, or null to remove.
* commandWindows: Windows .cmd shim path to emit as `commandWindows` field
* (#772). When provided, Codex uses this path on Windows and `managedCommand`
* on POSIX without needing per-platform config regeneration.
* matcher: optional Codex MatcherGroup pattern (e.g. 'Bash|Edit|Write').
* timeout: optional timeout in seconds.
* @returns {{ changed: boolean, wrote: boolean, path: string }}
*/
function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
return hooksSurface.reconcileCodexHooksJsonEvent(targetDir, eventName, opts);
}
/**
* Reconcile the GSD-managed SessionStart hook entry in hooks.json.
* Delegates to the generic reconcileCodexHooksJsonEvent helper.
*
* @param {string} targetDir
* @param {{ managedCommand?: string|null, commandWindows?: string|null }} opts
* @returns {{ changed: boolean, wrote: boolean, path: string }}
*/
function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
return hooksSurface.reconcileCodexHooksJsonSessionStart(targetDir, opts);
}
/**
* Build a typed IR for the Codex hook .cmd shim used on Windows (#3426).
*
* On Windows, Codex runs hook commands from a PowerShell/cmd execution
* environment. The previous command format was:
*
* "C:/Program Files/nodejs/node.exe" "C:/path/.codex/hooks/gsd-check-update.js"
*
* This caused `bash.exe: bash.exe: cannot execute binary file` because
* Codex's hook dispatch shell (Git Bash / MSYS) tried to POSIX-exec node.exe
* (a Windows PE binary) via execvp(), which fails with ENOEXEC on Windows PE
* binaries that the MSYS layer doesn't know how to fork-exec natively.
*
* Fix: write a .cmd shim (using the same CRLF .cmd shim pattern) whose
* content is `@ECHO OFF / @SETLOCAL / @"node.exe" "script.js" %*`.
* cmd.exe executes
* .cmd natively via CreateProcess — no POSIX exec layer, no MSYS shebang
* walk, no PE binary fork-exec failure.
*
* Returns the typed IR `{ invocation, cmdPath, hookCommand, render }` so
* callers can assert on the structured shape (CONTRIBUTING.md L558–L565
* IR-first discipline). Returns null when absoluteRunnerToken is null so
* callers can warn-and-skip instead of writing a broken hook.
*
* @param {string} scriptAbsPath - Absolute path to the .js hook script.
* @param {string|null} absoluteRunnerToken - JSON-quoted absolute node path
* (result of resolveNodeRunner()), e.g. `"C:/Program Files/nodejs/node.exe"`.
* @returns {{ invocation: { interpreter: string, target: string }, cmdPath: string, hookCommand: string, render: { cmd: () => string } }|null}
*/
function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
return hooksSurface.buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken);
}
/**
* Ensure Codex hooks.json contains exactly one managed SessionStart
* gsd-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, GSD 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);
}
/**
* Ensure hooks.json contains exactly one managed GSD hook entry for the given
* Codex event, wired to gsd-context-monitor.js. Preserves user-owned entries.
*
* Used for the new Codex events added in #772:
* SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open
* Stop — post-session context headroom tracking
* PostToolUse — mirror the Claude Code PostToolUse context monitor
*
* All three events are routed through gsd-context-monitor.js — the same hook
* used for PostToolUse in the Claude Code baseline — so context-headroom
* warnings surface at these key Codex session lifecycle moments.
*
* On Windows (#3426): writes a gsd-context-monitor.cmd shim alongside the .js
* file and uses the .cmd path as the hook command — exactly the same fix as
* SessionStart uses for gsd-check-update — to avoid the bash.exe POSIX-exec
* failure when Codex's hook dispatcher tries to run node.exe through Git Bash.
*
* @param {string} targetDir
* @param {string} eventName - One of 'SubagentStart', 'Stop', 'PostToolUse'.
* @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts
* @returns {{ changed: boolean, wrote: boolean, path: string }}
*/
function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
return hooksSurface.ensureCodexHooksJsonEvent(targetDir, eventName, opts);
}
/**
* Remove a GSD-managed event entry from hooks.json. Called during uninstall.
*
* @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. 'gsd-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');
}
/**
* Resolve the Kilo config file path, preferring .jsonc if it exists.
*/
function resolveKiloConfigPath(configDir) {
const jsoncPath = path.join(configDir, 'kilo.jsonc');
if (fs.existsSync(jsoncPath)) {
return jsoncPath;
}
return path.join(configDir, 'kilo.json');
}
/**
* 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
*/
function writeSettings(settingsPath, settings) {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
/**
* Read model_overrides from ~/.gsd/defaults.json at install time.
* Returns an object mapping agent names to model IDs, or null if the file
* doesn't exist or has no model_overrides entry.
* Used by Codex TOML and OpenCode agent file generators to embed per-agent
* model assignments so that model_overrides is respected on non-Claude runtimes (#2256).
*/
function readGsdGlobalModelOverrides() {
try {
const defaultsPath = path.join(os.homedir(), '.gsd', 'defaults.json');
if (!fs.existsSync(defaultsPath)) return null;
const raw = fs.readFileSync(defaultsPath, 'utf-8');
const parsed = JSON.parse(raw);
const overrides = parsed.model_overrides;
if (!overrides || typeof overrides !== 'object') return null;
return overrides;
} catch {
return null;
}
}
/**
* Effective per-agent model_overrides for the Codex / OpenCode install paths.
*
* Merges `~/.gsd/defaults.json` (global) with per-project
* `<project>/.planning/config.json`. Per-project keys win on conflict so a
* user can tune a single agent's model in one repo without re-setting the
* global defaults for every other repo. Non-conflicting keys from both
* sources are preserved.
*
* This is the fix for #2256: both adapters previously read only the global
* file, so a per-project `model_overrides` (the common case the reporter
* described — a per-project override for `gsd-codebase-mapper` in
* `.planning/config.json`) was silently dropped and child agents inherited
* the session default.
*
* `targetDir` is the consuming runtime's install root (e.g. `~/.codex` for
* a global install, or `<project>/.codex` for a local install). We walk up
* from there looking for `.planning/` so both cases resolve the correct
* project root. When `targetDir` is null/undefined only the global file is
* consulted (matches prior behavior for code paths that have no project
* context).
*
* Returns a plain `{ agentName: modelId }` object, or `null` when neither
* source defines `model_overrides`.
*/
function readGsdEffectiveModelOverrides(targetDir = null) {
const global = readGsdGlobalModelOverrides();
let projectOverrides = null;
if (targetDir) {
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(fs.readFileSync(candidate, 'utf-8'));
if (parsed && typeof parsed === 'object' && parsed.model_overrides
&& typeof parsed.model_overrides === 'object') {
projectOverrides = parsed.model_overrides;
}
} catch {
// Malformed config.json — fall back to global; readGsdRuntimeProfileResolver
// surfaces a parse warning via _readGsdConfigFile already.
}
break;
}
const parent = path.dirname(probeDir);
if (parent === probeDir) break;
probeDir = parent;
}
}
if (!global && !projectOverrides) return null;
// Per-project wins on conflict; preserve non-conflicting global keys.
return { ...(global || {}), ...(projectOverrides || {}) };
}
/**
* #443 — Read the merged `effort` config block for install-time effort resolution.
*
* Probes the same config sources as readGsdRuntimeProfileResolver (per-project
* `.planning/config.json` wins over `~/.gsd/defaults.json`) but extracts the
* `effort` object instead of the model-profile fields.
*
* Returns the merged `effort` object or null when neither source defines one.
* The caller can pass this to resolveInstallTimeEffort() which is pure and
* requires no filesystem access beyond what this helper already performs.
*
* @param {string|null} targetDir Runtime install root (walks up to find .planning/).
* @returns {object|null}
*/
function readGsdEffectiveEffortConfig(targetDir = null) {
const homeDefaults = _readGsdConfigFile(
path.join(os.homedir(), '.gsd', 'defaults.json'),
'~/.gsd/defaults.json'
);
let projectConfig = null;
if (targetDir) {
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)) {
projectConfig = _readGsdConfigFile(candidate, '.planning/config.json');
break;
}
const parent = path.dirname(probeDir);
if (parent === probeDir) break;
probeDir = parent;
}
}
const homeEffort = (homeDefaults && homeDefaults.effort && typeof homeDefaults.effort === 'object' && !Array.isArray(homeDefaults.effort))
? homeDefaults.effort
: null;
const projectEffort = (projectConfig && projectConfig.effort && typeof projectConfig.effort === 'object' && !Array.isArray(projectConfig.effort))
? projectConfig.effort
: null;
if (!homeEffort && !projectEffort) return null;
// Per-project wins on conflict within each sub-field. Merge field-by-field so
// a project config that only sets agent_overrides still inherits global
// routing_tier_defaults and default.
return {
...(homeEffort || {}),
...(projectEffort || {}),
// Deep-merge agent_overrides (project wins per-key)
agent_overrides: {
...((homeEffort && homeEffort.agent_overrides) || {}),
...((projectEffort && projectEffort.agent_overrides) || {}),
},
};
}
/**
* #443 — Resolve install-time effort for a given agent, using the same
* precedence chain as resolveEffortInternal() in core.cjs, but operating
* on a pre-loaded effortCfg object (no loadConfig side-effects at install).
*
* Precedence (mirrors resolveEffortInternal):
* 1. effortCfg.agent_overrides[agentName]
* 2. effortCfg.routing_tier_defaults[agentTier] (if effortCfg present)
* — OR manifest tier defaults when effortCfg is null
* 3. effortCfg.default
* 4. 'high' (hardcoded fallback)
*
* @param {object|null} effortCfg Result of readGsdEffectiveEffortConfig().
* @param {string} agentName e.g. 'gsd-planner'
* @returns {string} Universal effort string (low/medium/high/xhigh/max/minimal)
*/
function resolveInstallTimeEffort(effortCfg, agentName) {
// Validates each candidate against the canonical EFFORT_SET (sourced once
// from core.cjs) before accepting it, mirroring resolveEffortInternal exactly.
// Invalid values fall through to the next precedence layer; final fallback 'high'.
// Step 1: agent_overrides
if (effortCfg) {
const ao = effortCfg.agent_overrides;
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
const v = ao[agentName];
if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v;
}
}
// Step 2: routing_tier_defaults keyed by the agent's catalog tier
const { AGENT_DEFAULT_TIERS, EFFORT_MANIFEST_TIER_DEFAULTS, EFFORT_MANIFEST_DEFAULT } = _getGsdEffortCatalog();
const agentTier = AGENT_DEFAULT_TIERS[agentName];
if (agentTier) {
if (effortCfg && effortCfg.routing_tier_defaults &&
typeof effortCfg.routing_tier_defaults === 'object' &&
!Array.isArray(effortCfg.routing_tier_defaults)) {
const v = effortCfg.routing_tier_defaults[agentTier];
if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v;
} else if (!effortCfg) {
// No effort config — use manifest tier defaults
const v = EFFORT_MANIFEST_TIER_DEFAULTS[agentTier];
if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v;
}
// effortCfg exists but has no routing_tier_defaults — fall through
}
// Step 3: effort.default
if (effortCfg) {
const d = effortCfg.default;
if (typeof d === 'string' && GSD_EFFORT_SET.has(d)) return d;
}
// Step 4: manifest default (sourced from config-defaults.manifest.json effort.default)
// If even the manifest default is invalid, fall back to 'high'.
if (typeof EFFORT_MANIFEST_DEFAULT === 'string' && GSD_EFFORT_SET.has(EFFORT_MANIFEST_DEFAULT)) {
return EFFORT_MANIFEST_DEFAULT;
}
return 'high';
}
/**
* #443 — Inject `effort: <value>` into YAML frontmatter of a Claude .md agent
* file in a newline-agnostic way (LF and CRLF source files are both handled).
*
* The function:
* - Detects the file's EOL (CRLF if the first `---` line ends with \r\n,
* otherwise LF).
* - Skips injection if an `effort:` key already exists in the frontmatter
* (idempotent).
* - Inserts `effort: <value>` immediately before the closing `---` delimiter,
* using the same EOL as the surrounding frontmatter so the output file
* stays EOL-consistent.
* - Returns the original content unchanged when no YAML frontmatter is found.
*
* @param {string} content Raw file content (may have LF or CRLF endings).
* @param {string} effortValue Rendered effort string, e.g. "xhigh".
* @returns {string} Updated content with `effort:` injected, or the
* original content when no frontmatter is found.
*/
function injectEffortFrontmatter(content, effortValue) {
// Detect the dominant EOL from the first line (the opening `---`).
// If the very first `---` is followed by \r\n, treat the whole file as CRLF.
const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
// Build a frontmatter-matching regex that tolerates an optional \r before
// each \n, so we handle both LF and CRLF files without needing to normalise
// the whole content.
//
// Breakdown:
// ^---\r?\n — opening delimiter (with optional \r)
// ([\s\S]*?) — frontmatter body (non-greedy)
// ^---\r?$ — closing delimiter line (optional \r, $ before \n in
// multiline mode)
// (\r?\n|$) — newline after closing --- (or end of string)
//
// The `m` flag makes ^ / $ match at every line boundary.
const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
const match = fmRe.exec(content);
if (!match) return content; // no YAML frontmatter — leave unchanged
// Idempotency guard: don't insert a second effort: line.
const fmBody = match[1]; // content between the two `---` lines
if (/^effort:/m.test(fmBody)) return content;
// Locate the exact position of the closing `---` line so we can insert
// before it using a simple string splice (avoids re-running the regex and
// avoids any edge-cases with $ matching \r differently per engine).
const closeIdx = match.index + 4 + fmBody.length; // 4 = len("---\n") (opening)
// Actually compute based on the full match start + captured group length:
// match[0] = full frontmatter block; match.index = start of that block.
// The closing `---` starts at: match.index + ("---" + eol).length + fmBody.length
const openLen = 3 + eol.length; // "---" + eol
const closingStart = match.index + openLen + fmBody.length;
const before = content.slice(0, closingStart);
const after = content.slice(closingStart);
return `${before}effort: ${effortValue}${eol}${after}`;
}
/**
* #767 — Inject `disallowedTools: <value>` into the YAML frontmatter of a Claude .md agent.
* Mirrors injectEffortFrontmatter: idempotent (skips if disallowedTools: already present),
* inserts immediately before the closing `---`. Claude-only — never call for other runtimes,
* which break on unknown frontmatter keys.
*/
function injectDisallowedToolsFrontmatter(content, disallowedValue) {
// Detect the dominant EOL from the first line (the opening `---`).
// If the very first `---` is followed by \r\n, treat the whole file as CRLF.
const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
// Build a frontmatter-matching regex that tolerates an optional \r before
// each \n, so we handle both LF and CRLF files without needing to normalise
// the whole content.
const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
const match = fmRe.exec(content);
if (!match) return content; // no YAML frontmatter — leave unchanged
// Idempotency guard: don't insert a second disallowedTools: line.
const fmBody = match[1]; // content between the two `---` lines
if (/^disallowedTools:/m.test(fmBody)) return content;
// Locate the exact position of the closing `---` line so we can insert
// before it using a simple string splice.
const openLen = 3 + eol.length; // "---" + eol
const closingStart = match.index + openLen + fmBody.length;
const before = content.slice(0, closingStart);
const after = content.slice(closingStart);
return `${before}disallowedTools: ${disallowedValue}${eol}${after}`;
}
// #767 — Read-only verifier/auditor agents get a Claude-Code disallowedTools deny-list.
// Group A (pure read-only) deny Write,Edit,MultiEdit. Group B report-writers Write one
// output file so they deny only Edit,MultiEdit. gsd-nyquist-auditor is intentionally
// excluded (it legitimately uses Write AND Edit to create/patch test files).
const READONLY_AGENT_DISALLOWED_TOOLS = {
'gsd-plan-checker': 'Write, Edit, MultiEdit',
'gsd-integration-checker': 'Write, Edit, MultiEdit',
'gsd-ui-checker': 'Write, Edit, MultiEdit',
'gsd-verifier': 'Edit, MultiEdit',
'gsd-doc-verifier': 'Edit, MultiEdit',
'gsd-eval-auditor': 'Edit, MultiEdit',
'gsd-ui-auditor': 'Edit, MultiEdit',
};
/**
* #2517 — Read a single GSD config file (defaults.json or per-project
* config.json) into a plain object, returning null on missing/empty files
* and warning to stderr on JSON parse failures so silent corruption can't
* mask broken configs (review finding #5).
*/
function _readGsdConfigFile(absPath, label) {
if (!fs.existsSync(absPath)) return null;
let raw;
try {
raw = fs.readFileSync(absPath, 'utf-8');
} catch (err) {
process.stderr.write(`gsd: warning — could not read ${label} (${absPath}): ${err.message}\n`);
return null;
}
try {
return JSON.parse(raw);
} catch (err) {
process.stderr.write(`gsd: warning — invalid JSON in ${label} (${absPath}): ${err.message}\n`);
return null;
}
}
/**
* #2517 — Build a runtime-aware tier resolver for the install path.
*
* Probes BOTH per-project `<targetDir>/.planning/config.json` AND
* `~/.gsd/defaults.json`, with per-project keys winning over global. This
* matches `loadConfig`'s precedence and is the only way the PR's headline claim
* — "set runtime in .planning/config.json and the Codex TOML emit picks it up"
* — actually holds end-to-end (review finding #1).
*
* `targetDir` should be the consuming runtime's install root — install code
* passes `path.dirname(<runtime root>)` so `.planning/config.json` resolves
* relative to the user's project. When `targetDir` is null/undefined, only the
* global defaults are consulted.
*
* Returns null if no `runtime` is configured (preserves prior behavior — only
* model_overrides is embedded, no tier/reasoning-effort inference). Returns
* null when `model_profile` is `inherit` so the literal alias passes through
* unchanged.
*
* Returns { runtime, resolve(agentName) -> { model, reasoning_effort? } | null }
*/
function readGsdRuntimeProfileResolver(targetDir = null) {
const homeDefaults = _readGsdConfigFile(
path.join(os.homedir(), '.gsd', 'defaults.json'),
'~/.gsd/defaults.json'
);
// Per-project config probe. Resolve the project root by walking up from
// targetDir until we hit a `.planning/` directory; this covers both the
// common case (caller passes the project root) and the case where caller
// passes a nested install dir like `<root>/.codex/`.
let projectConfig = null;
if (targetDir) {
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)) {
projectConfig = _readGsdConfigFile(candidate, '.planning/config.json');
break;
}
const parent = path.dirname(probeDir);
if (parent === probeDir) break;
probeDir = parent;
}
}
// Per-project wins. Only fall back to ~/.gsd/defaults.json when the project
// didn't set the field. Field-level merge (not whole-object replace) so a
// user can keep `runtime` global while overriding only `model_profile` per
// project, and vice versa.
const merged = {
runtime:
(projectConfig && projectConfig.runtime) ||
(homeDefaults && homeDefaults.runtime) ||
null,
model_profile:
(projectConfig && projectConfig.model_profile) ||
(homeDefaults && homeDefaults.model_profile) ||
'balanced',
model_profile_overrides:
(projectConfig && projectConfig.model_profile_overrides) ||
(homeDefaults && homeDefaults.model_profile_overrides) ||
null,
};
if (!merged.runtime) return null;
const profile = String(merged.model_profile).toLowerCase();
if (profile === 'inherit') return null;
return {
runtime: merged.runtime,
resolve(agentName) {
const agentModels = GSD_MODEL_PROFILES[agentName];
if (!agentModels) return null;
const tier = agentModels[profile] || agentModels.balanced;
if (!tier) return null;
return gsdResolveTierEntry({
runtime: merged.runtime,
tier,
overrides: merged.model_profile_overrides,
});
},
};
}
// 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', 'gemini', 'codex', or 'copilot'
* @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;
if (runtime === 'opencode' || runtime === 'kilo') {
const resolveConfigPath = runtime === 'opencode'
? resolveOpencodeConfigPath
: resolveKiloConfigPath;
const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
result = (config && config.disable_ai_attribution === true) ? null : undefined;
} else if (runtime === 'gemini') {
// Gemini: check gemini settings.json for attribution config
const settings = readSettings(path.join(getGlobalConfigDir('gemini', 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 if (runtime === 'claude') {
// Claude Code
const settings = readSettings(path.join(getGlobalConfigDir('claude', 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 and Copilot currently have 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 Gemini CLI
// Gemini CLI uses snake_case built-in tool names
const claudeToGeminiTools = {
Read: 'read_file',
Write: 'write_file',
Edit: 'replace',
Bash: 'run_shell_command',
Glob: 'glob',
Grep: 'search_file_content',
WebSearch: 'google_web_search',
WebFetch: 'web_fetch',
TodoWrite: 'write_todos',
};
// Tool name mapping from Claude/GSD agents to Kimi CLI module paths.
// Kimi custom agent YAML requires fully-qualified module paths.
const claudeToKimiTools = {
Read: 'kimi_cli.tools.file:ReadFile',
ReadFile: 'kimi_cli.tools.file:ReadFile',
Write: 'kimi_cli.tools.file:WriteFile',
WriteFile: 'kimi_cli.tools.file:WriteFile',
Edit: 'kimi_cli.tools.file:StrReplaceFile',
MultiEdit: 'kimi_cli.tools.file:StrReplaceFile',
StrReplaceFile: 'kimi_cli.tools.file:StrReplaceFile',
Bash: 'kimi_cli.tools.shell:Shell',
Shell: 'kimi_cli.tools.shell:Shell',
Grep: 'kimi_cli.tools.file:Grep',
Glob: 'kimi_cli.tools.file:Glob',
Agent: 'kimi_cli.tools.agent:Agent',
Task: 'kimi_cli.tools.agent:Agent',
AskUserQuestion: 'kimi_cli.tools.ask_user:AskUserQuestion',
TodoWrite: 'kimi_cli.tools.todo:SetTodoList',
SetTodoList: 'kimi_cli.tools.todo:SetTodoList',
WebSearch: 'kimi_cli.tools.web:SearchWeb',
SearchWeb: 'kimi_cli.tools.web:SearchWeb',
WebFetch: 'kimi_cli.tools.web:FetchURL',
FetchURL: 'kimi_cli.tools.web:FetchURL',
ReadMediaFile: 'kimi_cli.tools.file:ReadMediaFile',
TaskList: 'kimi_cli.tools.background:TaskList',
TaskOutput: 'kimi_cli.tools.background:TaskOutput',
TaskStop: 'kimi_cli.tools.background:TaskStop',
};
/**
* 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 Gemini CLI format
* - Applies Claude→Gemini mapping (Read→read_file, Bash→run_shell_command, etc.)
* - Filters out MCP tools (mcp__*) — they are auto-discovered at runtime in Gemini
* - Filters out Task/Agent — agents are auto-registered as tools in Gemini
* @returns {string|null} Gemini tool name, or null if tool should be excluded
*/
function convertGeminiToolName(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 — Gemini CLI does not expose an ask_user tool;
// emitting it causes frontmatter validation errors (#3362).
// Skill/SlashCommand: exclude — Gemini CLI 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 (claudeToGeminiTools[claudeTool]) {
return claudeToGeminiTools[claudeTool];
}
// Default: lowercase
return claudeTool.toLowerCase();
}
function createKimiToolDiagnostic(reason, tool, source = null) {
const isMcp = reason === 'mcp_managed';
return {
level: 'warning',
code: isMcp ? 'kimi_mcp_tool_excluded' : 'kimi_unsupported_tool',
reason,
message: isMcp
? `MCP-managed tool '${tool}' is configured outside Kimi agent YAML.`
: `Tool '${tool}' is not supported by the Kimi tool mapper.`,
value: tool,
source,
};
}
/**
* Convert a Claude/GSD tool name to a Kimi CLI module path.
* @returns {string|null} Kimi module path, or null when excluded/unsupported.
*/
function convertKimiToolName(claudeTool) {
const tool = String(claudeTool || '').trim();
if (!tool) return null;
if (tool.startsWith('mcp__')) return null;
return claudeToKimiTools[tool] || null;
}
function mapClaudeToolsToKimiTools(claudeTools, options = {}) {
const diagnostics = [];
const tools = [];
const seen = new Set();
const source = options && Object.prototype.hasOwnProperty.call(options, 'source')
? options.source
: null;
for (const rawTool of Array.isArray(claudeTools) ? claudeTools : []) {
const tool = String(rawTool || '').trim();
if (!tool) continue;
if (tool.startsWith('mcp__')) {
diagnostics.push(createKimiToolDiagnostic('mcp_managed', tool, source));
continue;
}
const kimiTool = convertKimiToolName(tool);
if (!kimiTool) {
diagnostics.push(createKimiToolDiagnostic('unsupported_tool', tool, source));
continue;
}
if (!seen.has(kimiTool)) {
seen.add(kimiTool);
tools.push(kimiTool);
}
}
return { tools, diagnostics };
}
const claudeToKiloAgentPermissions = {
Read: 'read',
Write: 'edit',
Edit: 'edit',
Bash: 'bash',
Grep: 'grep',
Glob: 'glob',
Task: 'task',
WebFetch: 'webfetch',
WebSearch: 'websearch',
TodoWrite: 'todowrite',
AskUserQuestion: 'question',
SlashCommand: 'skill',
};
const kiloAgentPermissionOrder = [
'read',
'edit',
'bash',
'grep',
'glob',
'task',
'webfetch',
'websearch',
'skill',
'question',
'todowrite',
'list',
'codesearch',
'lsp',
];
function convertClaudeToKiloPermissionTool(claudeTool) {
return claudeToKiloAgentPermissions[claudeTool] || null;
}
function buildKiloAgentPermissionBlock(claudeTools) {
const allowedPermissions = new Set();
for (const tool of claudeTools) {
const mapped = convertClaudeToKiloPermissionTool(tool);
if (mapped) {
allowedPermissions.add(mapped);
}
}
const lines = ['permission:'];
for (const permission of kiloAgentPermissionOrder) {
lines.push(` ${permission}: ${allowedPermissions.has(permission) ? 'allow' : 'deny'}`);
}
return lines;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function replaceRelativePathReference(content, fromPath, toPath) {
const escapedPath = escapeRegExp(fromPath);
return content.replace(
new RegExp(`(^|[^A-Za-z0-9_./-])${escapedPath}`, 'g'),
(_, prefix) => `${prefix}${toPath}`,
);
}
/**
* Convert a Claude Code tool name to GitHub Copilot format.
* - Applies explicit mapping from claudeToCopilotTools
* - Handles mcp__context7__* prefix → io.github.upstash/context7/*
* - Falls back to lowercase for unknown tools
*/
function convertCopilotToolName(claudeTool) {
// mcp__context7__* wildcard → io.github.upstash/context7/*
if (claudeTool.startsWith('mcp__context7__')) {
return 'io.github.upstash/context7/' + claudeTool.slice('mcp__context7__'.length);
}
// Check explicit mapping
if (claudeToCopilotTools[claudeTool]) {
return claudeToCopilotTools[claudeTool];
}
// mcp__{tavily,ref,jina,exa,firecrawl}__* use the generic MCP passthrough like exa/firecrawl;
// add explicit Copilot registry mappings when the io.github ids are confirmed (#657 follow-up)
// Default: lowercase
return claudeTool.toLowerCase();
}
/**
* Apply Copilot-specific content conversion — CONV-06 (paths) + CONV-07 (command names).
* Path mappings depend on install mode:
* Global: ~/.claude/ → ~/.copilot/, ./.claude/ → ./.github/
* Local: ~/.claude/ → ./.github/, ./.claude/ → ./.github/
* Applied to ALL Copilot content (skills, agents, engine files).
* @param {string} content - Source content to convert
* @param {boolean} [isGlobal=false] - Whether this is a global install
*/
function convertClaudeToCopilotContent(content, isGlobal = false) {
let c = content;
// CONV-06: Path replacement — most specific first to avoid substring matches.
// Handle both `~/.claude/foo` (trailing slash) and bare `~/.claude` forms in
// one pass via a capture group, matching the approach used by Antigravity,
// OpenCode, Kilo, and Codex converters (issue #2545).
if (isGlobal) {
c = c.replace(/\$HOME\/\.claude(\/|\b)/g, '$HOME/.copilot$1');
c = c.replace(/~\/\.claude(\/|\b)/g, '~/.copilot$1');
} else {
c = c.replace(/\$HOME\/\.claude\//g, '.github/');
c = c.replace(/~\/\.claude\//g, '.github/');
c = c.replace(/\$HOME\/\.claude\b/g, '.github');
c = c.replace(/~\/\.claude\b/g, '.github');
}
c = c.replace(/\.\/\.claude\//g, './.github/');
c = c.replace(/\.claude\//g, '.github/');
// CONV-07: Command name conversion (all gsd: references → gsd-)
c = c.replace(/gsd:/g, 'gsd-');
// Runtime-neutral agent name replacement (#766)
c = neutralizeAgentReferences(c, 'copilot-instructions.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 a Copilot skill (SKILL.md).
* Transforms frontmatter only — body passes through with CONV-06/07 applied.
* Skills keep original tool names (no mapping) per CONTEXT.md decision.
*/
function convertClaudeCommandToCopilotSkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
const converted = convertClaudeToCopilotContent(content, isGlobal);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const description = extractFrontmatterField(frontmatter, 'description') || '';
const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
const agent = extractFrontmatterField(frontmatter, 'agent');
// CONV-02: Extract allowed-tools YAML multiline list → comma-separated string
const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m);
let toolsLine = '';
if (toolsMatch) {
const tools = toolsMatch[1].match(/^\s+-\s+(.+)/gm);
if (tools) {
toolsLine = tools.map(t => t.replace(/^\s+-\s+/, '').trim()).join(', ');
}
}
// Reconstruct frontmatter in Copilot format
// #2876: descriptions starting with a YAML flow indicator (`[BETA] …`,
// `{ … }`, `*ref`, `&anchor`, etc.) parse as flow sequences/mappings and
// crash gh-copilot's frontmatter loader. Always quote so any leading
// character is parser-safe.
let fm = `---\nname: ${skillName}\ndescription: ${yamlQuote(description)}\n`;
if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
if (agent) fm += `agent: ${agent}\n`;
if (toolsLine) fm += `allowed-tools: ${toolsLine}\n`;
fm += '---';
return `${fm}\n${body}`;
}
/**
* Map a skill directory name (gsd-<cmd>) to the frontmatter `name:` used
* by Claude Code as the skill identity. Emits the hyphen form (gsd-<cmd>)
* so Claude Code autocomplete shows the canonical invocation form, not the
* deprecated colon form. See #2808.
*
* Historical note: this previously returned `gsd:<cmd>` (colon) because
* workflows called Skill(skill="gsd:<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 `$gsd-<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 (gsd-<cmd>) — canonical since #2808.
return skillDirName;
}
function normalizeClaudeSkillEffort(effort) {
return effort === 'xhigh' ? 'max' : effort;
}
/**
* Qwen Code skills accept an optional numeric `priority` frontmatter field.
* Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified
* #778): HIGHER values sort EARLIER in the `/skills` TUI listing (omitted ≈ 0;
* negatives sort below unset). It affects ONLY the `/skills` list order —
* slash-command completion and the `/help` view stay alphabetical.
*
* We assign descending priorities to GSD's main-loop commands so the most-used
* workflow skills surface first; utility skills are deliberately left unset
* (default 0) and sort below.
*
* NOTE: the #778 issue body proposed the INVERSE numbering (plan-phase: 10,
* utilities: 90+). The verified spec shows that would BURY the core loop below
* utilities, so we implement the spec-correct direction (core = high) instead.
* Keyed by command stem (skill dir is `gsd-<stem>`).
*/
const QWEN_SKILL_PRIORITY = Object.freeze({
'new-project': 100,
'discuss-phase': 95,
'plan-phase': 90,
'execute-phase': 85,
progress: 80,
'verify-work': 75,
phase: 70,
review: 65,
ship: 60,
config: 55,
surface: 50,
'resume-work': 45,
'pause-work': 40,
help: 35,
update: 30,
});
/**
* 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: gsd-<cmd>` (hyphen) so Skill(skill="gsd-<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 /gsd:<cmd> or gsd:<cmd> in the body to the canonical
// hyphen form (gsd-<cmd>) so installed SKILL.md bodies match the hyphen
// `name:` Claude Code (and Qwen/Hermes) register 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 || readGsdCommandNames();
const normalizedBody = transformContentToHyphen(body, names);
const description = extractFrontmatterField(frontmatter, 'description') || '';
const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
const agent = extractFrontmatterField(frontmatter, 'agent');
// #769: preserve context: and effort: from source command files so they
// are emitted into the installed SKILL.md frontmatter unchanged.
const context = extractFrontmatterField(frontmatter, 'context');
const effort = extractFrontmatterField(frontmatter, 'effort');
// 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`;
// Hermes' SKILL.md spec lists `version` as a required frontmatter field.
// Track GSD's package version so Hermes' skill_view() reports a stable
// identifier per install.
if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`;
// #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen
// so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but
// we keep their output byte-stable). skillName is the `gsd-<stem>` dir name.
if (runtime === 'qwen') {
const stem = typeof skillName === 'string' && skillName.startsWith('gsd-')
? skillName.slice(4)
: skillName;
const priority = Object.prototype.hasOwnProperty.call(QWEN_SKILL_PRIORITY, stem)
? QWEN_SKILL_PRIORITY[stem]
: undefined;
if (typeof priority === 'number') fm += `priority: ${priority}\n`;
}
if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
if (agent) fm += `agent: ${agent}\n`;
// #769: emit context: and effort: when present so the runtime can honour
// them natively (context: fork = isolated subagent window; effort: =
// token-budget tier). Fields are Claude-specific; unknown frontmatter
// fields are silently ignored by other runtimes (backward-compatible).
if (context) fm += `context: ${context}\n`;
if (effort) fm += `effort: ${normalizeClaudeSkillEffort(effort)}\n`;
if (toolsBlock) fm += toolsBlock;
fm += '---';
return `${fm}\n${normalizedBody}`;
}
function normalizeKimiSkillName(skillName) {
let text = String(skillName || '').trim().toLowerCase();
if (text.startsWith('/')) text = text.slice(1);
if (text.startsWith('$')) text = text.slice(1);
text = text.replace(/^gsd:/, 'gsd-');
if (!text.startsWith('gsd-')) text = `gsd-${text}`;
text = text.replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
return text || 'gsd-command';
}
function convertGsdCommandReferencesToKimiSkillInvocations(content, cmdNames) {
if (!Array.isArray(cmdNames) || cmdNames.length === 0) return content;
const commands = [...cmdNames].sort((a, b) => b.length - a.length).map(escapeRegExp);
const commandGroup = commands.join('|');
const colonPattern = new RegExp(`(?<![A-Za-z0-9_/:.-])/?gsd:(${commandGroup})(?=[^A-Za-z0-9_-]|$)`, 'g');
const hyphenPattern = new RegExp(`(?:/|\\$)gsd-(${commandGroup})(?=[^A-Za-z0-9_-]|$)`, 'g');
return content
.replace(colonPattern, (_, cmd) => `/skill:gsd-${cmd}`)
.replace(hyphenPattern, (_, cmd) => `/skill:gsd-${cmd}`);
}
function convertClaudeCommandToKimiSkill(content, skillName, _runtime = null, cmdNames = null) {
const { frontmatter, body } = extractFrontmatterAndBody(content);
const kimiSkillName = normalizeKimiSkillName(skillName);
const names = cmdNames || readGsdCommandNames();
const description = frontmatter
? extractFrontmatterField(frontmatter, 'description') || `Run GSD workflow ${kimiSkillName}.`
: `Run GSD workflow ${kimiSkillName}.`;
const normalizedBody = convertGsdCommandReferencesToKimiSkillInvocations(
frontmatter ? body : content,
names
);
return `---\nname: ${kimiSkillName}\ndescription: ${yamlQuote(toSingleLine(description))}\n---\nInvoke this Kimi skill with \`/skill:${kimiSkillName}\`.\n\n${normalizedBody}`;
}
const KIMI_CANONICAL_GSD_AGENT_RE = /^gsd-[a-z0-9-]+$/;
function parseKimiAgentSource(source) {
if (typeof source === 'string') {
return {
path: null,
content: source,
};
}
if (!source || typeof source !== 'object' || typeof source.content !== 'string') {
return null;
}
return {
path: typeof source.path === 'string' ? source.path : null,
content: source.content,
};
}
function parseFrontmatterTools(frontmatter) {
if (!frontmatter) return [];
const lines = frontmatter.split(/\r?\n/);
const tools = [];
let collecting = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (collecting) {
if (trimmed.startsWith('- ')) {
tools.push(trimmed.slice(2).trim());
continue;
}
collecting = false;
}
if (trimmed === 'tools:' || trimmed === 'allowed-tools:') {
collecting = true;
continue;
}
if (trimmed.startsWith('tools:') || trimmed.startsWith('allowed-tools:')) {
const value = trimmed.slice(trimmed.indexOf(':') + 1).trim();
if (value) {
for (const tool of value.split(',')) {
const name = tool.trim();
if (name) tools.push(name);
}
} else {
collecting = true;
}
}
}
return tools;
}
function addKimiAgentDiagnostic(diagnostics, code, message, value, source = null) {
diagnostics.push({
level: 'warning',
code,
message,
value,
source,
});
}
function mapKimiAgentContractTools(toolNames, diagnostics, sourceName) {
const result = mapClaudeToolsToKimiTools(toolNames, { source: sourceName });
diagnostics.push(...result.diagnostics);
return result.tools;
}
function neutralizeKimiAgentPrompt(content) {
const { frontmatter, body } = extractFrontmatterAndBody(content);
let prompt = frontmatter ? body : content;
prompt = neutralizeAgentReferences(prompt, 'AGENTS.md');
prompt = prompt.replace(/~\/\.claude\/gsd-core\b/g, 'GSD core');
prompt = prompt.replace(/\$HOME\/\.claude\/gsd-core\b/g, 'GSD core');
return prompt.replace(/^\s*\r?\n/, '');
}
function pushKimiToolsYaml(lines, indent, tools) {
const prefix = ' '.repeat(indent);
if (!Array.isArray(tools) || tools.length === 0) {
lines.push(`${prefix}tools: []`);
return;
}
lines.push(`${prefix}tools:`);
for (const tool of tools) {
lines.push(`${prefix} - ${yamlQuote(tool)}`);
}
}
function buildKimiRootAgentYaml({ description, tools, subagents }) {
const lines = [
'version: 1',
'agent:',
' name: gsd',
` description: ${yamlQuote(toSingleLine(description || 'Run GSD workflows in Kimi CLI.'))}`,
' extend: default',
' system_prompt_path: ./gsd.md',
];
pushKimiToolsYaml(lines, 2, tools);
if (subagents.length > 0) {
lines.push(' subagents:');
for (const subagent of subagents) {
lines.push(` ${subagent.name}:`);
lines.push(` path: ./subagents/${subagent.name}.yaml`);
lines.push(` description: ${yamlQuote(toSingleLine(subagent.description))}`);
}
}
return `${lines.join('\n')}\n`;
}
function buildKimiSubagentYaml({ name, description, tools }) {
const lines = [
'version: 1',
'agent:',
` name: ${name}`,
` description: ${yamlQuote(toSingleLine(description || `Run ${name}.`))}`,
` system_prompt_path: ./${name}.md`,
];
pushKimiToolsYaml(lines, 2, tools);
return `${lines.join('\n')}\n`;
}
function buildKimiAgentArtifacts({
rootAgent = '',
subagents = [],
requestedSubagents = null,
} = {}) {
const diagnostics = [];
const rootSource = parseKimiAgentSource(rootAgent) || { path: null, content: '' };
const { frontmatter: rootFrontmatter } = extractFrontmatterAndBody(rootSource.content);
const rootDescription = rootFrontmatter
? extractFrontmatterField(rootFrontmatter, 'description') || 'Run GSD workflows in Kimi CLI.'
: 'Run GSD workflows in Kimi CLI.';
const subagentSources = Array.isArray(subagents) ? subagents : [];
if (!Array.isArray(subagents)) {
addKimiAgentDiagnostic(
diagnostics,
'kimi_unsupported_subagents_input',
'Subagents input must be an array of Markdown strings or source objects.',
typeof subagents,
null
);
}
const subagentMap = new Map();
for (const source of subagentSources) {
const parsed = parseKimiAgentSource(source);
if (!parsed) {
addKimiAgentDiagnostic(
diagnostics,
'kimi_unsupported_subagent_input',
'Subagent source must be a Markdown string or an object with content.',
typeof source,
null
);
continue;
}
const { frontmatter } = extractFrontmatterAndBody(parsed.content);
const fallbackName = parsed.path ? path.basename(parsed.path, path.extname(parsed.path)) : null;
const name = frontmatter
? extractFrontmatterField(frontmatter, 'name') || fallbackName
: fallbackName;
if (!name || !KIMI_CANONICAL_GSD_AGENT_RE.test(name)) {
addKimiAgentDiagnostic(
diagnostics,
'kimi_invalid_subagent_name',
'Subagent source does not use a canonical gsd-* Kimi agent name.',
name || '(missing)',
parsed.path
);
continue;
}
const description = frontmatter
? extractFrontmatterField(frontmatter, 'description') || `Run ${name}.`
: `Run ${name}.`;
const tools = mapKimiAgentContractTools(parseFrontmatterTools(frontmatter), diagnostics, name);
subagentMap.set(name, {
name,
description,
tools,
prompt: neutralizeKimiAgentPrompt(parsed.content),
});
}
const requested = Array.isArray(requestedSubagents) && requestedSubagents.length > 0
? requestedSubagents
: [...subagentMap.keys()];
const selectedSubagents = [];
for (const requestedName of requested) {
if (subagentMap.has(requestedName)) {
selectedSubagents.push(subagentMap.get(requestedName));
continue;
}
addKimiAgentDiagnostic(
diagnostics,
'kimi_unknown_subagent',
'Requested subagent was not generated and will not be emitted in Kimi YAML.',
requestedName,
null
);
}
const rootTools = mapKimiAgentContractTools(parseFrontmatterTools(rootFrontmatter), diagnostics, 'gsd');
if (selectedSubagents.length > 0 && !rootTools.includes('kimi_cli.tools.agent:Agent')) {
rootTools.push('kimi_cli.tools.agent:Agent');
}
return {
root: {
name: 'gsd',
yamlPath: 'agents/gsd.yaml',
promptPath: 'agents/gsd.md',
yaml: buildKimiRootAgentYaml({
description: rootDescription,
tools: rootTools,
subagents: selectedSubagents,
}),
prompt: neutralizeKimiAgentPrompt(rootSource.content),
},
subagents: selectedSubagents.map((subagent) => ({
name: subagent.name,
yamlPath: `agents/subagents/${subagent.name}.yaml`,
promptPath: `agents/subagents/${subagent.name}.md`,
yaml: buildKimiSubagentYaml(subagent),
prompt: subagent.prompt,
})),
diagnostics,
};
}
/**
* Convert a Claude agent (.md) to a Copilot agent (.agent.md).
* Applies tool mapping + deduplication, formats tools as JSON array.
* CONV-04: JSON array format. CONV-05: Tool name mapping.
*/
function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
const converted = convertClaudeToCopilotContent(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') || '';
// CONV-04 + CONV-05: Map tools, deduplicate, format as JSON array
const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean);
const mappedTools = claudeTools.map(t => convertCopilotToolName(t));
const uniqueTools = [...new Set(mappedTools)];
const toolsArray = uniqueTools.length > 0
? "['" + uniqueTools.join("', '") + "']"
: '[]';
// Reconstruct frontmatter in Copilot format. Quote description (#2876)
// so a leading YAML flow indicator (`[BETA] …`, `{ … }`, etc.) doesn't
// crash the Copilot frontmatter loader.
let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\ntools: ${toolsArray}\n`;
if (color) fm += `color: ${color}\n`;
fm += '---';
return `${fm}\n${body}`;
}
/**
* Apply Antigravity-specific content conversion — path replacement + command name conversion.
* Path mappings depend on install mode:
* Global: ~/.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 = content;
if (isGlobal) {
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 gsd: references → gsd-)
c = c.replace(/gsd:/g, 'gsd-');
// 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 Gemini equivalents (reuse existing convertGeminiToolName)
const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean);
const mappedTools = claudeTools.map(t => convertGeminiToolName(t)).filter(Boolean);
// #2876: quote description for the same reason as the skill variant.
let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\ntools: ${mappedTools.join(', ')}\n`;
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 gsd: -> gsd-.
// This preserves rendered "next step" commands like "/gsd-execute-phase 17".
return content.replace(/gsd:/gi, 'gsd-');
}
function convertClaudeToCursorMarkdown(content) {
let converted = convertSlashCommandsToCursorSkillMentions(content);
// 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, '{{GSD_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"
converted = converted.replace(/\bClaude Code\b/g, '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 \`{{GSD_ARGS}}\`.
- If no arguments are present, treat \`{{GSD_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 GSD 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 GSD 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);
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
}
/**
* Convert a Claude Code command to a Cursor 1.6 slash command (#785).
*
* Cursor slash commands live in `.cursor/commands/<name>.md` and are
* plain markdown — no YAML frontmatter, no adapter header. The filename
* becomes the command name (e.g. `gsd-help.md` → `/gsd-help`).
*
* Applies the same `convertClaudeToCursorMarkdown` transforms as the skill
* converter (tool renames, brand substitution, slash-command normalisation),
* then strips the YAML frontmatter block so only the prose body remains.
*
* @param {string} content raw Claude Code command markdown (may have frontmatter)
* @param {string} _commandName the target command name (unused; present for
* API symmetry with other converters so the runtime-artifact-layout stage
* function can call it uniformly)
* @returns {string} plain markdown body, no frontmatter
*/
function convertClaudeCommandToCursorCommand(content, _commandName) {
const converted = convertClaudeToCursorMarkdown(content);
const { body } = extractFrontmatterAndBody(converted);
return 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}`;
}
// --- Windsurf converters ---
// Windsurf uses a tool set similar to Cursor.
// Config lives in .windsurf/ (local) and ~/.codeium/windsurf/ (global).
// Tool name mapping from Claude Code to Windsurf Cascade
const claudeToWindsurfTools = {
Bash: 'Shell',
Edit: 'StrReplace',
AskUserQuestion: null, // No direct equivalent — use conversational prompting
SlashCommand: null, // No equivalent — skills are auto-discovered
};
function convertSlashCommandsToWindsurfSkillMentions(content) {
// Keep leading "/" for slash commands; only normalize gsd: -> gsd-.
return content.replace(/gsd:/gi, 'gsd-');
}
function convertClaudeToWindsurfMarkdown(content) {
let converted = convertSlashCommandsToWindsurfSkillMentions(content);
// 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 Windsurf format
converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
// Replace project-level Claude conventions with Windsurf equivalents.
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.windsurf/rules`');
converted = converted.replace(/\.\/CLAUDE\.md/g, '.windsurf/rules');
converted = converted.replace(/`CLAUDE\.md`/g, '`.windsurf/rules`');
converted = converted.replace(/\bCLAUDE\.md\b/g, '.windsurf/rules');
converted = converted.replace(/\.claude\/skills\//g, '.windsurf/skills/');
converted = converted.replace(/\.\/\.claude\//g, './.windsurf/');
converted = converted.replace(/\.claude\//g, '.windsurf/');
// Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
// Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.windsurf');
converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.windsurf');
// Environment variable name rewrite
converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR');
// 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 "Windsurf"
converted = converted.replace(/\bClaude Code\b/g, 'Windsurf');
return converted;
}
function getWindsurfSkillAdapterHeader(skillName) {
return `<windsurf_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 \`{{GSD_ARGS}}\`.
- If no arguments are present, treat \`{{GSD_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 Windsurf tools when executing GSD 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 Windsurf's model options (e.g., "fast")
</windsurf_skill_adapter>`;
}
function convertClaudeCommandToWindsurfSkill(content, skillName) {
const converted = convertClaudeToWindsurfMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
let description = `Run GSD 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 = getWindsurfSkillAdapterHeader(skillName);
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
}
function convertClaudeCommandToWindsurfWorkflow(content, commandName) {
// #1615 security: commandName flows unsanitized into a markdown body that
// Windsurf loads as an LLM-readable workflow. Validate at entry to prevent
// (a) prompt injection via newlines / markdown structure in the filename,
// (b) path-component injection via .., /, \ in stem → @-reference target.
// Pattern: optional gsd- prefix + lowercase alphanumeric + dashes; rejects
// everything else. See DEFECT.PROMPT-INJECTION-SCAN-COLLISION and the
// PR #1622 security review.
if (typeof commandName !== 'string' || !/^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(commandName)) {
const preview = typeof commandName === 'string' ? JSON.stringify(commandName.slice(0, 60)) : String(commandName);
throw new Error(
`convertClaudeCommandToWindsurfWorkflow: rejected commandName ${preview}; ` +
'must match /^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ (no slashes, backslashes, spaces, dots, trailing dash, or control chars — prevents prompt injection and path-component injection into the workflow body)'
);
}
const converted = convertClaudeToWindsurfMarkdown(content);
const { frontmatter } = extractFrontmatterAndBody(converted);
const description = frontmatter ? extractFrontmatterField(frontmatter, 'description') : '';
const stem = commandName.startsWith('gsd-') ? commandName.slice(4) : commandName;
const workflow = `# ${commandName}\n\n${toSingleLine(description || `Run ${commandName}.`)}\n\nRead and execute the GSD command at @~/.claude/gsd-core/commands/gsd/${stem}.md end-to-end. Treat the user's message after /${commandName} as the command arguments.`;
const byteLength = Buffer.byteLength(workflow, 'utf8');
if (byteLength > 12000) {
throw new Error(`Windsurf workflow ${commandName} exceeds 12000 bytes (${byteLength}); extract references before installing`);
}
return workflow;
}
/**
* Convert Claude Code agent markdown to Windsurf agent format.
* Strips frontmatter fields Windsurf doesn't support (color, skills),
* converts tool references, and adds a role context header.
*/
function convertClaudeAgentToWindsurfAgent(content) {
let converted = convertClaudeToWindsurfMarkdown(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}`;
}
// --- Augment converters ---
// Augment uses a tool set similar to Cursor/Windsurf.
// Config lives in .augment/ (local) and ~/.augment/ (global).
const claudeToAugmentTools = {
Bash: 'launch-process',
Edit: 'str-replace-editor',
AskUserQuestion: null,
SlashCommand: null,
TodoWrite: 'add_tasks',
};
// #1675 (ADR-1508): the augment converter family below was a byte-identical
// duplicate of runtime-artifact-conversion.cjs:
// convertSlashCommandsToAugmentSkillMentions, convertClaudeToAugmentMarkdown,
// getAugmentSkillAdapterHeader, convertClaudeCommandToAugmentSkill,
// convertClaudeAgentToAugmentAgent
// Deleted here and bound from runtimeArtifactConversion above (single source).
// The DEFECT.GENERATIVE-FIX parity guard in
// tests/enh-1511-rewrite-engine-relocation.test.cjs asserts reference identity.
function convertSlashCommandsToTraeSkillMentions(content) {
return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
return `/gsd-${commandName}`;
});
}
function convertClaudeToTraeMarkdown(content) {
let converted = convertSlashCommandsToTraeSkillMentions(content);
converted = converted.replace(/\bBash\(/g, 'Shell(');
converted = converted.replace(/\bEdit\(/g, 'StrReplace(');
// Replace general-purpose subagent type with Trae's equivalent "general_purpose_task"
converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="general_purpose_task"');
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.trae/rules/`');
converted = converted.replace(/\.\/CLAUDE\.md/g, '.trae/rules/');
converted = converted.replace(/`CLAUDE\.md`/g, '`.trae/rules/`');
converted = converted.replace(/\bCLAUDE\.md\b/g, '.trae/rules/');
converted = converted.replace(/\.claude\/skills\//g, '.trae/skills/');
converted = converted.replace(/\.\/\.claude\//g, './.trae/');
converted = converted.replace(/\.claude\//g, '.trae/');
// Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
// Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.trae');
converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.trae');
// Environment variable name rewrite
converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'TRAE_CONFIG_DIR');
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
converted = converted.replace(/\bClaude Code\b/g, 'Trae');
return converted;
}
function convertClaudeCommandToTraeSkill(content, skillName) {
const converted = convertClaudeToTraeMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
let description = `Run GSD 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;
// #2876: quote so YAML flow indicators (`[BETA] …`) don't break Trae's
// frontmatter parser.
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n${body}`;
}
function convertClaudeAgentToTraeAgent(content) {
let converted = convertClaudeToTraeMarkdown(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 convertSlashCommandsToCodebuddySkillMentions(content) {
return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
return `/gsd-${commandName}`;
});
}
function convertClaudeToCodebuddyMarkdown(content) {
let converted = convertSlashCommandsToCodebuddySkillMentions(content);
// CodeBuddy uses the same tool names as Claude Code (Bash, Edit, Read, Write, etc.)
// No tool name conversion needed
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`CODEBUDDY.md`');
converted = converted.replace(/\.\/CLAUDE\.md/g, 'CODEBUDDY.md');
converted = converted.replace(/`CLAUDE\.md`/g, '`CODEBUDDY.md`');
converted = converted.replace(/\bCLAUDE\.md\b/g, 'CODEBUDDY.md');
converted = converted.replace(/\.claude\/skills\//g, '.codebuddy/skills/');
converted = converted.replace(/\.\/\.claude\//g, './.codebuddy/');
converted = converted.replace(/\.claude\//g, '.codebuddy/');
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
converted = converted.replace(/\bClaude Code\b/g, 'CodeBuddy');
return converted;
}
function convertClaudeCommandToCodebuddySkill(content, skillName) {
const converted = convertClaudeToCodebuddyMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
let description = `Run GSD 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;
// #2876: quote so YAML flow indicators (`[BETA] …`) don't break
// CodeBuddy's frontmatter parser.
//
// #789: mark user-invocable:false so the skill is NOT shown in CodeBuddy's
// '/' menu (it defaults to true). The commands/ surface (#789) is the sole
// '/' entry point; skills remain model-invocable background knowledge,
// avoiding a duplicated /gsd-* entry per workflow.
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n${body}`;
}
/**
* Convert a Claude Code slash-command (.md) to a CodeBuddy slash-command (.md).
*
* CodeBuddy reads user-level slash commands from ~/.codebuddy/commands/<name>.md
* (https://www.codebuddy.ai/docs/cli/slash-commands). The filename determines the
* command name (gsd-help.md → /gsd-help), so the Claude-specific `name: gsd:<x>`
* frontmatter field is dropped. CodeBuddy command frontmatter supports
* `description` and `argument-hint`; both are preserved when present. The body is
* brand/path-converted via convertClaudeToCodebuddyMarkdown.
*
* @param {string} content raw Claude command markdown
* @param {string} commandName installed command name (e.g. 'gsd-help')
* @returns {string}
*/
function convertClaudeCommandToCodebuddyCommand(content, commandName) {
const converted = convertClaudeToCodebuddyMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
let description = `Run GSD workflow ${commandName}.`;
let argumentHint = '';
if (frontmatter) {
const maybeDescription = extractFrontmatterField(frontmatter, 'description');
if (maybeDescription) description = maybeDescription;
const maybeArgHint = extractFrontmatterField(frontmatter, 'argument-hint');
if (maybeArgHint) argumentHint = maybeArgHint;
}
description = toSingleLine(description);
const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
// #2876: quote values so YAML flow indicators (`[BETA] …`, `[name]`) don't
// break CodeBuddy's frontmatter parser.
const lines = ['---', `description: ${yamlQuote(shortDescription)}`];
if (argumentHint) lines.push(`argument-hint: ${yamlQuote(toSingleLine(argumentHint))}`);
lines.push('---', body.trimStart());
return lines.join('\n');
}
function convertClaudeAgentToCodebuddyAgent(content) {
let converted = convertClaudeToCodebuddyMarkdown(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}`;
}
// ── Cline converters ────────────────────────────────────────────────────────
function convertClaudeToCliineMarkdown(content) {
let converted = content;
// Cline uses the same tool names as Claude Code — no tool name conversion needed
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.clinerules`');
converted = converted.replace(/\.\/CLAUDE\.md/g, '.clinerules');
converted = converted.replace(/`CLAUDE\.md`/g, '`.clinerules`');
converted = converted.replace(/\bCLAUDE\.md\b/g, '.clinerules');
// Slash forms first (most specific — superset of bare forms)
converted = converted.replace(/\.claude\/skills\//g, '.cline/skills/');
converted = converted.replace(/\.\/\.claude\//g, './.cline/');
converted = converted.replace(/\.claude\//g, '.cline/');
// Bare forms (no trailing slash) — after slash forms to avoid double-rewrite
converted = converted.replace(/~\/\.claude\b/g, '~/.cline');
converted = converted.replace(/\$HOME\/\.claude\b/g, '$HOME/.cline');
// Environment variable name rewrite
converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'CLINE_CONFIG_DIR');
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
converted = converted.replace(/\bClaude Code\b/g, 'Cline');
return converted;
}
function convertClaudeAgentToClineAgent(content) {
let converted = convertClaudeToCliineMarkdown(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}`;
}
/**
* Convert a Claude command (.md) to a Cline skill (SKILL.md).
* Emits ONLY name + description frontmatter per the Cline skills spec
* (https://docs.cline.bot/customization/skills) — no allowed-tools,
* argument-hint, agent, or other Claude-specific fields.
* Body is hyphen-normalised then converted via convertClaudeToCliineMarkdown
* (.claude/→.cline/, "Claude Code"→"Cline", etc.).
* Cline uses Claude-Code-compatible tool names, so no adapter header is needed.
* Targets ~/.cline/skills/<name>/SKILL.md for Cline >= v3.48.0.
*/
function convertClaudeCommandToClineSkill(content, skillName, runtime = null, cmdNames = null) {
const { frontmatter, body } = extractFrontmatterAndBody(content);
if (!frontmatter) return content;
// Hyphen-normalise /gsd:<cmd> → gsd-<cmd> references in the body, then
// apply Cline-specific markdown rewrites (.claude/→.cline/, etc.).
const names = cmdNames || readGsdCommandNames();
const normalizedBody = transformContentToHyphen(body, names);
const clineBody = convertClaudeToCliineMarkdown(normalizedBody);
// Extract description; fall back to a generic string if absent.
let description = extractFrontmatterField(frontmatter, 'description');
if (!description) description = `Run GSD workflow ${skillName}.`;
description = toSingleLine(description);
// Cline documented max is 1024 code points (not UTF-16 code units).
// Use Array.from to iterate by code point so that multibyte characters
// (e.g. emoji, astral-plane chars) are never split, which would produce
// lone surrogates and corrupt the YAML output.
const cp = Array.from(description);
const shortDescription = cp.length > 1024
? cp.slice(0, 1021).join('') + '...'
: description;
const fm = `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---`;
return `${fm}\n${clineBody}`;
}
// ── End Cline converters ─────────────────────────────────────────────────────
function convertSlashCommandsToCodexSkillMentions(content) {
// Colon-style /gsd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below).
let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => {
return `$gsd-${String(commandName).toLowerCase()}`;
});
// Convert hyphen-style command references (workflow output) to Codex $ prefix.
// A real /gsd-<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. `/gsd-execute-phase`.
// 2. Right boundary: the command token is NOT followed by a path separator
// `/` (a path continues: `/gsd-core/bin/...`; a command does not). The
// `(?![a-z0-9/-])` also blocks regex backtracking to a shorter command.
// This converts backtick-wrapped MENTIONS (`/gsd-foo`) while leaving backtick-
// wrapped PATHS (`/gsd-core/workflows/update.md`) untouched (#712).
converted = converted.replace(/(?<=^|[\s`"'([])\/gsd-([a-z0-9-]+)(?![a-z0-9/-])/gi, (_, commandName) => {
return `$gsd-${String(commandName).toLowerCase()}`;
});
return converted;
}
const CODEX_GSD_TOOLS_INVOCATION = 'node "$HOME/.codex/gsd-core/bin/gsd-tools.cjs"';
function rewriteBareGsdToolsCommandsForCodex(content) {
return content
.replace(/(^[ \t]*)gsd-tools(?=\s)/gm, `$1${CODEX_GSD_TOOLS_INVOCATION}`)
.replace(/(\$\(\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`)
.replace(/(`\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`)
.replace(/((?:&&|\|\||[;|])\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`);
}
function convertClaudeToCodexMarkdown(content) {
let converted = convertSlashCommandsToCodexSkillMentions(content);
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_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 `gsd-tools` binary on PATH. Keep resolver probes such as
// `command -v gsd-tools` intact; rewrite only command invocations.
converted = rewriteBareGsdToolsCommandsForCodex(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 \`{{GSD_ARGS}}\`.
- If no arguments are present, treat \`{{GSD_ARGS}}\` as empty.
## B. AskUserQuestion → request_user_input Mapping
GSD 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 \`{{GSD_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
GSD workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collaboration tools:
**Schema detection (required first step):** Codex exposes two \`spawn_agent\` schemas:
- **agent_type-capable schema** (e.g. \`multi_agent_v2\`): \`spawn_agent\` accepts \`agent_type\`, \`message\`, \`reasoning_effort\`, \`fork_context\`, etc. — typed GSD agent dispatch is available.
- **Generic schema** (\`multi_agent_v1\`): \`spawn_agent\` accepts only \`message\`, \`items\`, \`fork_context\` — there is **no \`agent_type\` field**. Typed GSD agent dispatch is unavailable in this session.
Before spawning, inspect the \`spawn_agent\` tool's visible parameter schema (via \`tool_search\` or the tool list) to determine which form is active.
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="...")\` → omit. \`spawn_agent\` has no inline \`model\` parameter;
GSD embeds the resolved per-agent model directly into each agent's \`.toml\`
at install time so \`model_overrides\` from \`.planning/config.json\` and
\`~/.gsd/defaults.json\` are honored automatically by Codex's agent router.
- Resolved \`reasoning_effort="low|medium|high|xhigh"\` (\`xhigh\` is a GSD/Codex tier, not a generic runtime enum) → pass \`reasoning_effort\`
to \`spawn_agent\` when the runtime/tool supports it. Omit missing, empty,
inherited, or unsupported values; do not invent one-off effort literals in
workflow prose.
- \`fork_context: false\` by default — GSD agents load their own context via \`<files_to_read>\` blocks
- \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct Codex mapping.
Codex \`spawn_agent\` does not create or bind a git worktree automatically.
Workflows that require this isolation must fail closed or use an explicit
manual worktree protocol before spawning (#3360).
Generic-agent workaround (multi_agent_v1 schema — NO agent_type field):
When only the generic \`multi_agent_v1\` schema is available, typed GSD agent dispatch
(\`gsd-planner\`, \`gsd-executor\`, etc.) is NOT possible. This is a known Codex limitation
(openai/codex#15250). **This workaround is NOT equivalent to typed gsd-planner/gsd-executor
execution** — GSD 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 → \`wait(ids)\` for all to complete
Result parsing:
- Look for structured markers in agent output: \`CHECKPOINT\`, \`PLAN COMPLETE\`, \`SUMMARY\`, etc.
- \`close_agent(id)\` after collecting results from each agent
</codex_skill_adapter>`;
}
function convertClaudeCommandToCodexSkill(content, skillName) {
const converted = convertClaudeToCodexMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
let description = `Run GSD 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}`;
}
/**
* 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 readGsdRuntimeProfileResolver
* @param {object|null} effortCfg — #443: merged effort config from readGsdEffectiveEffortConfig
*/
function generateCodexAgentToml(agentName, agentContent, modelOverrides = null, runtimeResolver = null, effortCfg = null, sandboxTier = 'codex-agent-sandbox') {
const sandboxMode = CODEX_AGENT_SANDBOX[agentName] || 'read-only';
const { frontmatter, body } = extractFrontmatterAndBody(agentContent);
const frontmatterText = frontmatter || '';
const resolvedName = extractFrontmatterField(frontmatterText, 'name') || agentName;
const resolvedDescription = toSingleLine(
extractFrontmatterField(frontmatterText, 'description') || `GSD 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 ~/.gsd/defaults.json so that
// model_overrides is respected on Codex (which uses static TOML, not inline
// Task() model parameters). See #2256.
// Precedence: per-agent model_overrides > runtime-aware tier resolution (#2517).
const modelOverride = modelOverrides?.[resolvedName] || modelOverrides?.[agentName];
let hasPinnedModel = false;
if (modelOverride) {
lines.push(`model = ${JSON.stringify(modelOverride)}`);
hasPinnedModel = true;
} else if (runtimeResolver) {
// #2517 — runtime-aware tier resolution. Embeds Codex-native model + reasoning_effort
// from RUNTIME_PROFILE_MAP / model_profile_overrides for the configured tier.
const entry = runtimeResolver.resolve(resolvedName) || runtimeResolver.resolve(agentName);
if (entry?.model) {
lines.push(`model = ${JSON.stringify(entry.model)}`);
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. Codex does not support 'max' → clamped to 'xhigh' by
// gsdRenderEffortForRuntime('codex', ...).
// #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 GSD. Keep those knobs coupled unless GSD also pins the model.
if (hasPinnedModel) {
const _universalEffortCodex = resolveInstallTimeEffort(effortCfg, resolvedName !== agentName ? resolvedName : agentName);
const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
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 } = _getGsdEffortCatalog();
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 GSD-managed Codex skill dirs.
*
* Prior to #1326, GSD's Codex install path wrote an agents/openai.yaml file
* alongside each gsd-* SKILL.md. Recent Codex builds index BOTH SKILL.md and
* the sidecar, causing each GSD 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 "gsd-".
* - Skips user-owned dirs (gsd-dev-preferences) — their agents/ content is
* never modified, mirroring the same USER_OWNED_SKILL_DIRS guard used by
* installOpencodeFamilySkills.
* - For each managed gsd-* 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-gsd-* 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 GSD's install path.
const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) 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.
}
}
}
/**
* Remove legacy Windsurf skill artifacts from .devin/skills/gsd- directories.
*
* Pre-#1615 Windsurf installs wrote skills under .devin/ (Devin Desktop
* preferred dir, #1085). #1615 moved Windsurf to .windsurf/workflows/.
* Old .devin/skills/gsd- dirs linger on disk indefinitely and confuse
* users who see two GSD trees.
*
* Preserves user-owned content:
* - non-gsd-* dirs under .devin/skills/ (user-authored skills)
* - gsd-dev-preferences/ (user-owned per #2973)
* - any files (not dirs) under .devin/skills/
*
* @param {string} workspaceDir - workspace root (process.cwd() for local installs)
* @returns {number} count of removed legacy gsd-* skill directories
*/
function cleanupWindsurfLegacyDevinSkills(workspaceDir) {
const legacySkillsDir = path.join(workspaceDir, '.devin', 'skills');
if (!fs.existsSync(legacySkillsDir)) return 0;
// Mirror the user-owned list from cleanupCodexSkillMetadataSidecars (#2973).
const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
let removed = 0;
for (const entry of fs.readdirSync(legacySkillsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) continue;
if (_userOwnedSkillDirs.has(entry.name)) continue;
const dirToRemove = path.join(legacySkillsDir, entry.name);
try {
// Symlink guard: if the gsd-* dir is itself a symlink pointing outside
// the .devin tree, deleting through it could escape the tree. Skip.
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 the install.
}
}
// If .devin/skills/ is now empty, prune it. If .devin/ itself is then empty,
// prune that too — leaves the workspace clean for the new .windsurf/ layout.
// Never remove non-empty containers (user may have other Devin content).
try {
if (fs.existsSync(legacySkillsDir) && fs.readdirSync(legacySkillsDir).length === 0) {
fs.rmdirSync(legacySkillsDir);
const devinDir = path.join(workspaceDir, '.devin');
if (fs.existsSync(devinDir) && fs.readdirSync(devinDir).length === 0) {
fs.rmdirSync(devinDir);
}
}
} catch (_err) {
// best-effort container cleanup
}
return removed;
}
/**
* Generate the GSD config block for Codex config.toml.
* @param {Array<{name: string, description: string}>} agents
*/
function generateCodexConfigBlock(agents, targetDir) {
// Use absolute paths when targetDir is provided — Codex ≥0.116 requires
// AbsolutePathBuf for config_file and cannot resolve relative paths.
const agentsPrefix = targetDir
? path.join(targetDir, 'agents').replace(/\\/g, '/')
: 'agents';
const lines = [
GSD_CODEX_MARKER,
'',
];
for (const { name, description } of agents) {
// #2727 — Codex 0.124.0 requires [agents.<name>] struct format, not [[agents]] sequence.
// [[agents]] (introduced in #2645) is rejected by codex-cli 0.124.0 with
// "invalid type: sequence, expected struct AgentsToml in `agents`".
lines.push(`[agents.${name}]`);
lines.push(`description = ${JSON.stringify(description)}`);
lines.push(`config_file = "${agentsPrefix}/${name}.toml"`);
lines.push('');
}
return lines.join('\n');
}
/**
* Strip any managed GSD agent sections from a TOML string.
*
* Used by the uninstall path (`stripGsdFromCodexConfig`). Removes only what GSD
* owns; user-authored `[agents.<name>]` and `[[agents]]` entries are preserved
* so uninstall returns the file to its pre-GSD shape.
*
* Handles BOTH shapes so reinstall self-heals configs from all GSD versions:
* - Current (#2727): `[agents.gsd-*]` struct tables (Codex 0.120.0+).
* - Legacy (#2645): `[[agents]]` array-of-tables whose `name = "gsd-*"`.
*
* A section runs from its header to the next `[` header or EOF.
*/
function stripCodexGsdAgentSections(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.gsd-<name>]` struct tables (#2727, Codex 0.120.0+).
if (!section.array && /^agents\.gsd-/.test(section.path)) {
return true;
}
// Legacy `[[agents]]` array-of-tables (#2645) — only strip blocks whose
// `name = "gsd-..."`, 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 && /^gsd-/.test(nameMatch[1]));
}
return false;
});
return removeContentRanges(
content,
sections.map(({ start, end }) => ({ start, end })),
);
}
/**
* Strip GSD sections from Codex config.toml content.
* Returns cleaned content, or null if file would be empty.
*/
function stripGsdFromCodexConfig(content) {
const eol = detectLineEnding(content);
const markerIndex = content.indexOf(GSD_CODEX_MARKER);
const codexHooksOwnership = getManagedCodexHooksOwnership(content);
if (markerIndex !== -1) {
// Has GSD marker — remove everything from marker to EOF
let before = content.substring(0, markerIndex);
before = stripCodexHooksFeatureAssignments(before, codexHooksOwnership);
// Also strip GSD-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 (!before) return null;
return before + eol;
}
// No marker but may have GSD-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, '');
// Remove [agents.gsd-*] sections (from header to next section or EOF)
cleaned = stripCodexGsdAgentSections(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 (!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(GSD_CODEX_MARKER);
if (markerIndex === -1) {
return null;
}
const afterMarker = content.slice(markerIndex + GSD_CODEX_MARKER.length);
const match = afterMarker.match(/^\r?\n# GSD codex_hooks ownership: (section|root_dotted)\r?\n/);
return match ? match[1] : null;
}
function setManagedCodexHooksOwnership(content, ownership) {
const markerIndex = content.indexOf(GSD_CODEX_MARKER);
if (markerIndex === -1) {
return content;
}
const eol = detectLineEnding(content);
const markerEnd = markerIndex + GSD_CODEX_MARKER.length;
const afterMarker = content.slice(markerEnd);
const normalizedAfterMarker = afterMarker.replace(
/^\r?\n# GSD 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 +
`${GSD_CODEX_HOOKS_OWNERSHIP_PREFIX}${ownership}${eol}` +
remainder;
}
function isLegacyGsdAgentsSection(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 stripLeakedGsdCodexSections(content) {
// Defensive precedence (#2760): we own the `agents` namespace under our
// managed `gsd-*` 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 GSD 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.gsd-<name>] map tables (pre-#2645).
if (!section.array && section.path.startsWith('agents.gsd-')) return true;
// ANY bare [agents] single-bracket table — invalid in current Codex
// schema, always purged at install time (#2760). Previously gated
// on `isLegacyGsdAgentsSection`, 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 = "gsd-..."` 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 GSD-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 = "...gsd-(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 `# GSD 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 GSD-managed hook section is present.
*/
function stripStaleGsdHookBlocks(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 GSD-managed if any structural `command` key inside its
// body parses to a string whose basename matches `gsd-(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 GSD marker comment `# GSD 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() === '# GSD 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 GSD managed marker (if present) so
// the migrated user hooks stay in the "user" portion of the file and are not
// swept away when stripGsdFromCodexConfig 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(GSD_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 GSD-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 GSD 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/GSD 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).
*/
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') {
return {
ok: false,
reason: 'bare [agents] table is invalid in current Codex schema (expected [agents.<name>] struct form)',
};
}
// 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 GSD config block into an existing or new config.toml.
* Three cases: new file, existing with GSD marker, existing without marker.
*
* All writes go through atomicWriteFileSync so a mid-write failure leaves
* the original config.toml untouched (#2760 fix 4).
*/
function mergeCodexConfig(configPath, gsdBlock) {
// Case 1: No config.toml — create fresh
if (!fs.existsSync(configPath)) {
atomicWriteFileSync(configPath, gsdBlock + '\n');
return;
}
const existing = fs.readFileSync(configPath, 'utf8');
const eol = detectLineEnding(existing);
const normalizedGsdBlock = gsdBlock.replace(/\r?\n/g, eol);
const markerIndex = existing.indexOf(GSD_CODEX_MARKER);
// Case 2: Has GSD marker — truncate and re-append
if (markerIndex !== -1) {
let before = existing.substring(0, markerIndex).trimEnd();
if (before) {
// Strip any GSD-managed sections that leaked above the marker from previous installs
before = stripLeakedGsdCodexSections(before).trimEnd();
atomicWriteFileSync(configPath, before + eol + eol + normalizedGsdBlock + eol);
} else {
atomicWriteFileSync(configPath, normalizedGsdBlock + eol);
}
return;
}
// Case 3: No marker — append GSD block
let content = stripLeakedGsdCodexSections(existing).trimEnd();
if (content) {
content = content + eol + eol + normalizedGsdBlock + eol;
} else {
content = normalizedGsdBlock + eol;
}
atomicWriteFileSync(configPath, content);
}
/**
* Repair config.toml files corrupted by pre-#1346 GSD 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';
});
}
/**
* Merge GSD instructions into copilot-instructions.md.
* Three cases: new file, existing with markers, existing without markers.
* @param {string} filePath - Full path to copilot-instructions.md
* @param {string} gsdContent - Template content (without markers)
*/
function mergeCopilotInstructions(filePath, gsdContent) {
const gsdBlock = GSD_COPILOT_INSTRUCTIONS_MARKER + '\n' +
gsdContent.trim() + '\n' +
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER;
// Case 1: No file — create fresh
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, gsdBlock + '\n');
return;
}
const existing = fs.readFileSync(filePath, 'utf8');
const openIndex = existing.indexOf(GSD_COPILOT_INSTRUCTIONS_MARKER);
const closeIndex = existing.indexOf(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER);
// Case 2: Has GSD markers — replace between markers
if (openIndex !== -1 && closeIndex !== -1) {
const before = existing.substring(0, openIndex).trimEnd();
const after = existing.substring(closeIndex + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER.length).trimStart();
let newContent = '';
if (before) newContent += before + '\n\n';
newContent += gsdBlock;
if (after) newContent += '\n\n' + after;
newContent += '\n';
fs.writeFileSync(filePath, newContent);
return;
}
// Case 3: No markers — append at end
const content = existing.trimEnd() + '\n\n' + gsdBlock + '\n';
fs.writeFileSync(filePath, content);
}
/**
* Strip GSD section from copilot-instructions.md content.
* Returns cleaned content, or null if file should be deleted (was GSD-only).
* @param {string} content - File content
* @returns {string|null} - Cleaned content or null if empty
*/
function stripGsdFromCopilotInstructions(content) {
const openIndex = content.indexOf(GSD_COPILOT_INSTRUCTIONS_MARKER);
const closeIndex = content.indexOf(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER);
if (openIndex !== -1 && closeIndex !== -1) {
const before = content.substring(0, openIndex).trimEnd();
const after = content.substring(closeIndex + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER.length).trimStart();
const cleaned = (before + (before && after ? '\n\n' : '') + after).trim();
if (!cleaned) return null;
return cleaned + '\n';
}
// No markers found — nothing to strip
return content;
}
// ── Cline directory-form rules + hooks + AGENTS.md (issue #787) ────────────────
//
// Cline v3.36 added a hooks system and a `.clinerules/` directory form. Because
// `.clinerules` cannot be both a file AND a directory, emitting hooks under
// `.clinerules/hooks/` requires migrating the rules content into the directory
// form (`.clinerules/gsd.md`). Sources adjudicated:
// - https://cline.bot/blog/cline-v3-36-hooks
// - https://docs.cline.bot/customization/cline-rules
const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
/**
* The GSD instruction body shared by the Cline directory-form rules file and
* the cross-tool AGENTS.md block. Self-contained — references only the gsd-core
* engine layout, not the (separate) #782 Cline skills directory.
*/
function buildClineRulesBody() {
return hooksSurface.buildClineRulesBody();
}
/** AGENTS.md body for the cross-tool global instruction target (`~/.agents/AGENTS.md`). */
function buildClineAgentsMdBody() {
return hooksSurface.buildClineAgentsMdBody();
}
/**
* The Cline PreToolUse hook script (issue #787).
*
* Cline invokes hooks as executable scripts named exactly after the event with
* no extension, passing the operation context as JSON on stdin and reading a
* JSON decision from stdout ({ cancel, errorMessage, contextModification }).
*
* This hook is a self-standing planning-artifact guard: it cancels write-class
* tool calls that target `.planning/` (GSD-owned artifacts), and otherwise
* allows the operation. It FAILS OPEN — any parse/IO error allows the call so a
* hook bug can never wedge the user. No dependency on the #782 skills work.
*/
function buildClinePreToolUseHook() {
return hooksSurface.buildClinePreToolUseHook();
}
/**
* Merge the GSD AGENTS.md block into an existing file (or create it), preserving
* any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent.
*/
function mergeGsdAgentsMd(filePath, gsdContent) {
return hooksSurface.mergeGsdAgentsMd(filePath, gsdContent);
}
/**
* Strip the GSD block from AGENTS.md content. Returns null if the file became
* empty (was GSD-only), the unchanged content if no markers were found, or the
* cleaned content otherwise.
*/
function stripGsdFromAgentsMd(content) {
const openIndex = content.indexOf(GSD_AGENTS_MD_MARKER);
const closeIndex = content.indexOf(GSD_AGENTS_MD_CLOSE_MARKER);
if (openIndex !== -1 && closeIndex !== -1) {
const before = content.substring(0, openIndex).trimEnd();
const after = content.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
const cleaned = (before + (before && after ? '\n\n' : '') + after).trim();
if (!cleaned) return null;
return cleaned + '\n';
}
return content;
}
/**
* Write the full Cline runtime artifact set (directory-form rules + PreToolUse
* hook) into targetDir, migrating a legacy single-file `.clinerules` if present.
* For global installs, also merge the cross-tool ~/.agents/AGENTS.md target.
*
* Returns the list of manifest-relative paths written under targetDir (so the
* caller can hash-track them).
*/
function writeClineArtifacts(targetDir, isGlobalInstall) {
return hooksSurface.writeClineArtifacts(targetDir, isGlobalInstall);
}
// ── Cursor hooks.json reconciler (issue #777) ────────────────────────────────
//
// Cursor v2.4+ supports a hooks.json lifecycle hook system. GSD registers two
// managed command hooks:
// sessionStart → gsd-cursor-session-start.js (context injection)
// postToolUse → gsd-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
//
// GSD entries are identified by a top-level `"gsd-managed": true` field on
// each hook entry. Non-GSD 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
/**
* Build a managed Cursor hook entry for a given hook script path.
*
* @param {string} scriptPath - Absolute path to the hook script
* @returns {object} Cursor hook entry object
*/
function buildCursorHookEntry(scriptPath) {
return hooksSurface.buildCursorHookEntry(scriptPath);
}
/**
* Return true if a Cursor hook entry is GSD-managed.
* Detection: presence of the GSD_CURSOR_HOOK_MARKER sentinel field.
*
* @param {object} entry - A hooks array element from hooks.json
* @returns {boolean}
*/
function isManagedCursorHookEntry(entry) {
return hooksSurface.isManagedCursorHookEntry(entry);
}
/**
* Reconcile the GSD-managed entries in a Cursor hooks.json file.
*
* Supports both known hooks.json shapes:
* 1) { "version": 1, "hooks": { "sessionStart": [...], "postToolUse": [...] } }
* 2) { "sessionStart": [...], "postToolUse": [...] } (no wrapper object)
*
* Managed entries (those with GSD_CURSOR_HOOK_MARKER) are removed then
* re-added if managedEntries is non-null/non-empty. User-owned entries are
* preserved. File is written atomically only when content changes.
*
* @param {string} hooksJsonPath - Absolute path to the hooks.json file
* @param {{ sessionStart?: object|null, postToolUse?: object|null }|null} managedEntries
* Map from event name to the new hook entry to register (or null to remove).
* Pass null for the whole param to remove all managed entries.
* @returns {{ changed: boolean, wrote: boolean, path: string }}
*/
function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
return hooksSurface.reconcileCursorHooksJson(hooksJsonPath, managedEntries);
}
/**
* #777 — Write GSD-managed Cursor lifecycle hooks into <targetDir>/hooks.json.
*
* Both managed hook scripts (gsd-cursor-session-start.js, gsd-cursor-post-tool.js)
* are copied from the GSD 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 GSD 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 GSD-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);
}
/**
* #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object.
*
* Returns the verbatim JSON shape Copilot CLI expects:
* { version: 1, hooks: { sessionStart: [ <hook entry> ] } }
*
* The sessionStart entry is a `command` hook whose `bash`/`powershell` bodies
* run inline (no external script file), so the config can never reference a
* hook script that the installer did not also install — it is self-contained
* by construction. The command is advisory-only (always exits 0) and orients
* the agent toward the project's GSD planning state at session start.
*
* @returns {object} Copilot hooks-configuration object
*/
function buildCopilotHookConfig() {
return hooksSurface.buildCopilotHookConfig();
}
/**
* #786 — Write the GSD-managed Copilot lifecycle hook config under the runtime
* config dir (`<targetDir>/hooks/gsd-session.json`). For local installs
* targetDir is `.github` (→ `.github/hooks/`); for global installs it is
* `~/.copilot` (→ `~/.copilot/hooks/`) — both are valid Copilot hook locations.
*
* The managed file is fully owned by GSD, so it is overwritten wholesale on
* every install (idempotent). User-authored sibling `*.json` hook files in the
* same directory are untouched.
*
* @param {string} targetDir - The Copilot config dir
* @returns {string} The path the hook config was written to
*/
function writeCopilotHookConfig(targetDir) {
return hooksSurface.writeCopilotHookConfig(targetDir);
}
/**
* Generate config.toml and per-agent .toml files for Codex.
* Reads agent .md files from source, extracts metadata, writes .toml configs.
*/
function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-sandbox') {
const configPath = path.join(targetDir, 'config.toml');
const agentsTomlDir = path.join(targetDir, 'agents');
fs.mkdirSync(agentsTomlDir, { recursive: true });
const agentEntries = fs.readdirSync(agentsSrc).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
const agents = [];
// Compute the Codex GSD install path (absolute, so subagents with empty $HOME work — #820)
const codexGsdPath = `${path.resolve(targetDir, 'gsd-core').replace(/\\/g, '/')}/`;
for (const file of agentEntries) {
let content = fs.readFileSync(path.join(agentsSrc, file), 'utf8');
// Replace full .claude/gsd-core prefix so path resolves to the Codex
// GSD install before generic .claude → .codex conversion rewrites it.
content = content.replace(/~\/\.claude\/gsd-core\//g, codexGsdPath);
content = content.replace(/\$HOME\/\.claude\/gsd-core\//g, codexGsdPath);
// Route TOML emit through the same full Claude→Codex conversion pipeline
// used on the `.md` emit path (#2639). Covers: slash-command rewrites,
// $ARGUMENTS → {{GSD_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);
const name = extractFrontmatterField(frontmatter, 'name') || file.replace('.md', '');
const description = extractFrontmatterField(frontmatter, 'description') || '';
agents.push({ name, description: toSingleLine(description) });
// Pass model overrides from both per-project `.planning/config.json` and
// `~/.gsd/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 gsd-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 = readGsdEffectiveModelOverrides(targetDir);
// Pass `targetDir` so per-project .planning/config.json wins over global
// ~/.gsd/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 = readGsdRuntimeProfileResolver(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 = readGsdEffectiveEffortConfig(targetDir);
const tomlContent = generateCodexAgentToml(name, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier);
fs.writeFileSync(path.join(agentsTomlDir, `${name}.toml`), tomlContent);
}
const gsdBlock = generateCodexConfigBlock(agents, targetDir);
mergeCodexConfig(configPath, gsdBlock);
return agents.length;
}
/**
* Strip HTML <sub> tags for Gemini CLI output
* Terminals don't support subscript — Gemini renders these as raw HTML.
* Converts <sub>text</sub> to italic *(text)* for readable terminal output.
*/
/**
* 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 stripSubTags(content) {
return content.replace(/<sub>(.*?)<\/sub>/g, '*($1)*');
}
/**
* Convert Claude Code agent frontmatter to Gemini CLI format
* Gemini agents use .md files with YAML frontmatter, same as Claude,
* but with different field names and formats:
* - tools: must be a YAML array (not comma-separated string)
* - tool names: must use Gemini built-in names (read_file, not Read)
* - color: must be removed (causes validation error)
* - skills: must be removed (causes validation error)
* - mcp__* tools: must be excluded (auto-discovered at runtime)
*/
let _gsdCommandRoster = null;
let _gsdCommandRosterWarned = false;
/**
* Get the list of known GSD commands from the source directory.
* Caches the result after the first scan. Emits a one-shot warning if the
* source directory cannot be located — an empty roster silently neutralises
* every Gemini slash-command conversion, which is the bug this code exists
* to prevent. The warning is gated on GSD_TEST_MODE to keep test output clean.
* @returns {Set<string>} Set of command names (without .md extension)
*/
function getGsdCommandRoster() {
if (_gsdCommandRoster) return _gsdCommandRoster;
const baseDir = (typeof __dirname !== 'undefined') ? __dirname : process.cwd();
const gsdSrc = path.join(baseDir, '..', 'commands', 'gsd');
if (fs.existsSync(gsdSrc)) {
_gsdCommandRoster = new Set(
fs.readdirSync(gsdSrc)
.filter(f => f.endsWith('.md'))
.map(f => f.replace('.md', ''))
);
} else {
_gsdCommandRoster = new Set();
if (!_gsdCommandRosterWarned && !process.env.GSD_TEST_MODE) {
_gsdCommandRosterWarned = true;
console.warn(
`WARNING: GSD command roster not found at ${gsdSrc}. ` +
`Gemini /gsd- → /gsd: conversion will be a no-op. ` +
`This usually means the package was installed without commands/gsd/.`
);
}
}
return _gsdCommandRoster;
}
// Test-only: reset the cached roster. Exported via GSD_TEST_MODE bundle below.
function _resetGsdCommandRoster() {
_gsdCommandRoster = null;
_gsdCommandRosterWarned = false;
}
function convertSlashCommandsToGeminiMentions(content) {
const commands = getGsdCommandRoster();
// Defense in depth: regex boundary AND roster lookup must both agree.
//
// - Lookbehind `(?<![A-Za-z0-9./])` rejects URLs (`example.com/gsd-…`),
// sub-paths (`bin/gsd-…`), and root-relative file paths preceded by a
// path char. Without it the roster alone is insufficient: a URL like
// `https://example.com/gsd-plan-phase` ends in a known command name and
// would convert incorrectly.
// - `(?!\/)` rejects sub-path continuation (`/gsd-foo/bar`).
// - `(?!\.[a-z])` rejects file extensions (`.cjs`, `.md`) but PERMITS
// sentence-ending punctuation like `/gsd-help.` because `.` at end of
// string or before whitespace is not followed by a lowercase letter.
// - Roster lookup ensures only real commands convert — agent names like
// `gsd-planner` (no leading slash anyway) and unknown tokens pass through.
//
// GSD commands are always lowercase, so no case-insensitive flag.
return content.replace(/(?<![A-Za-z0-9./])\/gsd-([a-z0-9-]+)(?!\/)(?!\.[a-z])/g, (match, commandName) => {
return commands.has(commandName) ? `/gsd:${commandName}` : match;
});
}
function convertClaudeToGeminiMarkdown(content, { isCommand = false, commandName = null } = {}) {
// Apply Gemini-specific slash command namespacing
let converted = convertSlashCommandsToGeminiMentions(content);
// Gemini CLI does not expose Claude's AskUserQuestion tool. Convert body
// references to runtime-neutral wording so converted agents do not instruct
// Gemini to call a nonexistent tool (#3362).
converted = converted.replace(/\b(?:AskUserQuestion|ask_user)\b/g, 'conversational prompting');
// Strip HTML subscript tags — terminals can't render them. Done before
// TOML conversion so the prompt body of a command file is also clean.
converted = stripSubTags(converted);
if (isCommand) {
// Convert to Gemini TOML format (threads the command name so per-command
// enrichment — e.g. the #778 live-state injection — can target a command).
converted = convertClaudeToGeminiToml(converted, { commandName });
}
return converted;
}
function convertClaudeToGeminiAgent(content) {
if (!content.startsWith('---')) return content;
const endIndex = content.indexOf('---', 3);
if (endIndex === -1) return content;
const frontmatter = content.substring(3, endIndex).trim();
const body = content.substring(endIndex + 3);
const lines = frontmatter.split('\n');
const newLines = [];
let inAllowedTools = false;
let inSkippedArrayField = false;
const tools = [];
for (const line of lines) {
const trimmed = line.trim();
if (inSkippedArrayField) {
if (!trimmed || trimmed.startsWith('- ')) {
continue;
}
inSkippedArrayField = false;
}
// Convert allowed-tools YAML array to tools list
if (trimmed.startsWith('allowed-tools:')) {
inAllowedTools = true;
continue;
}
// Handle inline tools: field (comma-separated string)
if (trimmed.startsWith('tools:')) {
const toolsValue = trimmed.substring(6).trim();
if (toolsValue) {
const parsed = toolsValue.split(',').map(t => t.trim()).filter(t => t);
for (const t of parsed) {
const mapped = convertGeminiToolName(t);
if (mapped) tools.push(mapped);
}
} else {
// tools: with no value means YAML array follows
inAllowedTools = true;
}
continue;
}
// Strip color field (not supported by Gemini CLI, causes validation error)
if (trimmed.startsWith('color:')) continue;
// Strip skills field (not supported by Gemini CLI, causes validation error)
if (trimmed.startsWith('skills:')) {
inSkippedArrayField = true;
continue;
}
// Collect allowed-tools/tools array items
if (inAllowedTools) {
if (trimmed.startsWith('- ')) {
const mapped = convertGeminiToolName(trimmed.substring(2).trim());
if (mapped) tools.push(mapped);
continue;
} else if (trimmed && !trimmed.startsWith('-')) {
inAllowedTools = false;
}
}
if (!inAllowedTools) {
newLines.push(line);
}
}
// Add tools as YAML array (Gemini requires array format)
if (tools.length > 0) {
newLines.push('tools:');
for (const tool of tools) {
newLines.push(` - ${tool}`);
}
}
const newFrontmatter = newLines.join('\n').trim();
// Escape ${VAR} patterns in agent body for Gemini CLI compatibility.
// Gemini's templateString() treats all ${word} patterns as template variables
// and throws "Template validation failed: Missing required input parameters"
// when they can't be resolved. GSD agents use ${PHASE}, ${PLAN}, etc. as
// shell variables in bash code blocks — convert to $VAR (no braces) which
// is equivalent bash and invisible to Gemini's /\$\{(\w+)\}/g regex.
const escapedBody = body.replace(/\$\{(\w+)\}/g, '$$$1');
// Runtime-neutral agent name replacement (#766)
const neutralBody = neutralizeAgentReferences(escapedBody, 'GEMINI.md');
// Apply Gemini-specific transformations (slash commands + sub-tag stripping)
const geminiBody = convertClaudeToGeminiMarkdown(neutralBody);
return `---\n${newFrontmatter}\n---${geminiBody}`;
}
function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOverride = null } = {}) {
// Replace tool name references in content (applies to all files)
let convertedContent = content;
convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill');
convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite');
// Replace /gsd-command colon variant with /gsd-command for opencode (flat command structure)
convertedContent = convertedContent.replace(/\/gsd:/g, '/gsd-');
// 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 ~/.gsd/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}`;
}
// Kilo CLI — same conversion logic as OpenCode, different config paths.
function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
// Replace tool name references in content (applies to all files)
let convertedContent = content;
convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill');
convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite');
// Replace /gsd-command colon variant with /gsd-command for Kilo (flat command structure)
convertedContent = convertedContent.replace(/\/gsd:/g, '/gsd-');
// Replace ~/.claude and $HOME/.claude with Kilo's config location
convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/kilo');
convertedContent = convertedContent.replace(/\$HOME\/\.claude\b/g, '$HOME/.config/kilo');
convertedContent = convertedContent.replace(/\.\/\.claude\//g, './.kilo/');
// Normalize both Claude skill directory variants to Kilo's canonical skills dir.
convertedContent = replaceRelativePathReference(convertedContent, '.claude/skills/', '.kilo/skills/');
convertedContent = replaceRelativePathReference(convertedContent, '.agents/skills/', '.kilo/skills/');
convertedContent = replaceRelativePathReference(convertedContent, '.claude/agents/', '.kilo/agents/');
// Replace general-purpose subagent type with Kilo'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 inAgentTools = false;
let inSkippedArray = false;
const allowedTools = [];
const agentTools = [];
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;
}
if (isAgent && inAgentTools) {
if (trimmed.startsWith('- ')) {
agentTools.push(trimmed.substring(2).trim());
continue;
}
if (trimmed && !trimmed.startsWith('-')) {
inAgentTools = false;
}
}
// Detect inline tools: field (comma-separated string)
if (trimmed.startsWith('tools:')) {
if (isAgent) {
const toolsValue = trimmed.substring(6).trim();
if (toolsValue) {
const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t);
agentTools.push(...tools);
} else {
inAgentTools = 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 (Kilo uses filename for command name)
// For agents: keep name: (required by Kilo agents)
if (!isAgent && trimmed.startsWith('name:')) {
continue;
}
// Strip model: field — Kilo doesn't support Claude Code model aliases
// like 'haiku', 'sonnet', 'opus', or 'inherit'. Omitting lets Kilo use
// its configured default model.
if (trimmed.startsWith('model:')) {
continue;
}
// Convert color names to hex for Kilo (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('- ')) {
const tool = trimmed.substring(2).trim();
if (isAgent) {
agentTools.push(tool);
} else {
allowedTools.push(tool);
}
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 Kilo agent fields
if (isAgent) {
newLines.push('mode: subagent');
newLines.push(...buildKiloAgentPermissionBlock(agentTools));
}
// 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}`;
}
/**
* Shared SKILL.md writer for the OpenCode-family runtimes (OpenCode + Kilo),
* which share a config schema (Kilo derives from OpenCode). OpenCode discovers
* skills as `skills/<name>/SKILL.md` and Kilo follows the same layout
* (https://opencode.ai/docs/skills, https://kilo.ai/docs/customize/skills).
*
* The skill body reuses the runtime's command-frontmatter converter for tool,
* path, and `/gsd:`→`/gsd-` body rewrites, then rebuilds a minimal skill
* frontmatter: only `name` (lowercase-hyphen, must match the containing
* directory) and `description` (1–1024 chars) are emitted, per the OpenCode
* skill spec. The command's `tools:`/`permission:` block is intentionally
* dropped — OpenCode skills are loaded on-demand via the native skill tool and
* inherit the calling agent's permissions.
*
* @param {string} content - Claude command markdown (with YAML frontmatter)
* @param {string} skillName - Skill directory name (e.g. gsd-help)
* @param {(content: string) => string} frontmatterConverter - runtime command converter
* @returns {string} SKILL.md content
*/
function convertClaudeCommandToOpencodeFamilySkill(content, skillName, frontmatterConverter) {
const converted = frontmatterConverter(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
let description = `Run GSD workflow ${skillName}.`;
if (frontmatter) {
const maybeDescription = extractFrontmatterField(frontmatter, 'description');
if (maybeDescription) {
description = maybeDescription;
}
}
description = toSingleLine(description);
// OpenCode skill descriptions must be 1–1024 characters.
if (description.length > 1024) {
description = `${description.slice(0, 1021)}...`;
}
// `name` must be lowercase alphanumeric with single-hyphen separators and
// match the containing directory name (the staged dir is `${skillName}/`).
const name = yamlIdentifier(skillName);
return `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n---\n\n${body.trimStart()}`;
}
/**
* Convert a Claude command (.md) to an OpenCode skill (SKILL.md).
* Thin wrapper over the shared OpenCode-family writer.
*/
function convertClaudeCommandToOpencodeSkill(content, skillName) {
return convertClaudeCommandToOpencodeFamilySkill(
content,
skillName,
(c) => convertClaudeToOpencodeFrontmatter(c),
);
}
/**
* Convert a Claude command (.md) to a Kilo skill (SKILL.md).
* Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema).
*/
function convertClaudeCommandToKiloSkill(content, skillName) {
return convertClaudeCommandToOpencodeFamilySkill(
content,
skillName,
(c) => convertClaudeToKiloFrontmatter(c),
);
}
/**
* Convert Claude Code markdown command to Gemini TOML format
* @param {string} content - Markdown file content with YAML frontmatter
* @returns {string} - TOML content
*/
function convertClaudeToGeminiToml(content, { commandName = null } = {}) {
// #778 (c) — Gemini {{args}} interpolation. Claude's $ARGUMENTS placeholder
// maps to Gemini's {{args}} so inline argument references interpolate into the
// command body instead of being emitted as a dead literal. Applied before
// frontmatter parsing so every return path benefits (a command's frontmatter
// never contains $ARGUMENTS, so this is body-only in practice). Gemini injects
// {{args}} as typed outside shell blocks; we never place it inside a !{...}
// block, so there is no shell-escaping/injection interaction.
content = content.replace(/\$ARGUMENTS\b/g, '{{args}}');
// Check if content has frontmatter
if (!content.startsWith('---')) {
return `prompt = ${JSON.stringify(content)}\n`;
}
const endIndex = content.indexOf('---', 3);
if (endIndex === -1) {
return `prompt = ${JSON.stringify(content)}\n`;
}
const frontmatter = content.substring(3, endIndex).trim();
let body = content.substring(endIndex + 3).trim();
// #778 (c) — Gemini !{...} dynamic-output injection for the situational
// `progress` command (GSD's status/dashboard surface). Inject the live
// .planning/STATE.md so the model sees current project state without relying
// on session memory.
//
// SECURITY: the shell command is a FIXED `cat` with NO interpolated user
// input — no {{args}} appears inside the block — so there is no
// shell-injection vector. Gemini still shows its standard per-invocation
// confirmation dialog (verified behavior). `2>/dev/null` keeps an
// uninitialized project (missing STATE.md) from injecting stderr noise.
// Braces inside the block are balanced (none present), per Gemini's parser
// requirement. The append happens AFTER the {{args}} mapping above so the
// injected block can never accidentally carry interpolated arguments.
if (commandName === 'progress') {
body += '\n\n## Live project state\n'
+ 'Current contents of `.planning/STATE.md` '
+ '(empty if the project is not yet initialized):\n\n'
+ '!{cat .planning/STATE.md 2>/dev/null}\n';
}
// Extract description from frontmatter
let description = '';
const lines = frontmatter.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('description:')) {
description = trimmed.substring(12).trim();
break;
}
}
// Construct TOML
let toml = '';
if (description) {
toml += `description = ${JSON.stringify(description)}\n`;
}
toml += `prompt = ${JSON.stringify(body)}\n`;
return toml;
}
/**
* Copy commands to a flat structure for OpenCode
* OpenCode expects: command/gsd-help.md (invoked as /gsd-help)
* Source structure: commands/gsd/help.md
*
* @param {string} srcDir - Source directory (e.g., commands/gsd/)
* @param {string} destDir - Destination directory (e.g., command/)
* @param {string} prefix - Prefix for filenames (e.g., 'gsd')
* @param {string} pathPrefix - Path prefix for file references
* @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo')
*/
/**
* Apply OpenCode-family (`opencode`/`kilo`) `@file` path-prefix rewrites to a
* RAW Claude command/skill body, BEFORE the frontmatter converter runs.
*
* This is the single source of truth shared by copyFlattenedCommands (commands)
* and installOpencodeFamilySkills (skills) so the two surfaces produce identical
* path references. Applying pathPrefix pre-conversion (rather than rewriting an
* already-converted body) is what avoids the converter's hardcoded default
* config dir leaking into --local / --config-dir installs, and the
* prefix-overlap double-rewrite hazard for custom dirs like `kilo-alt`. (#784)
*
* @param {string} content - raw Claude command markdown
* @param {string} runtime - 'opencode' or 'kilo'
* @param {string} pathPrefix - trailing-slash install-target prefix
* @returns {string}
*/
function applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix) {
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
content = content.replace(/~\/\.opencode\//g, pathPrefix);
content = content.replace(/~\/\.kilo\//g, pathPrefix);
return content;
}
function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
if (!fs.existsSync(srcDir)) {
return;
}
// Remove old gsd-*.md files before copying new ones
if (fs.existsSync(destDir)) {
for (const file of fs.readdirSync(destDir)) {
if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) {
fs.unlinkSync(path.join(destDir, file));
}
}
} else {
fs.mkdirSync(destDir, { recursive: true });
}
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
if (entry.isDirectory()) {
// Recurse into subdirectories, adding to prefix
// e.g., commands/gsd/debug/start.md -> command/gsd-debug-start.md
copyFlattenedCommands(srcPath, destDir, `${prefix}-${entry.name}`, pathPrefix, runtime);
} else if (entry.name.endsWith('.md')) {
// Flatten: help.md -> gsd-help.md
const baseName = entry.name.replace('.md', '');
const destName = `${prefix}-${baseName}.md`;
const destPath = path.join(destDir, destName);
let content = fs.readFileSync(srcPath, 'utf8');
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
content = processAttribution(content, getCommitAttribution(runtime));
content = runtime === 'kilo'
? convertClaudeToKiloFrontmatter(content)
: convertClaudeToOpencodeFrontmatter(content);
fs.writeFileSync(destPath, content);
}
}
}
function listCodexSkillNames(skillsDir, prefix = 'gsd-') {
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. 'gsd')
* @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 Windsurf skills — one folder per skill with SKILL.md.
* Mirrors copyCommandsAsCursorSkills but uses Windsurf converters.
*/
/**
* Copy Claude commands as CodeBuddy skills — one folder per skill with SKILL.md.
* CodeBuddy uses the same tool names as Claude Code, but has its own config directory structure.
*/
/**
* Copy Claude commands as Copilot skills — one folder per skill with SKILL.md.
* Applies CONV-01 (structure), CONV-02 (allowed-tools), CONV-06 (paths), CONV-07 (command names).
*/
/**
* 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/gsd/xxx.md.
* Supports runtime='claude'|'qwen'|'hermes'; 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. 'gsd')
* @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)
*/
/**
* Write the Hermes "gsd" category DESCRIPTION.md.
* Hermes' skill loader reads DESCRIPTION.md at the top of each skill category
* directory and surfaces it in the system prompt so the model knows when to
* reach for that category. Per spec in #2841 we collapse all 86 GSD commands
* under a single "gsd" category to keep system-prompt overhead bounded.
*/
function writeHermesCategoryDescription(categoryDir) {
fs.mkdirSync(categoryDir, { recursive: true });
const body = [
'---',
'name: gsd',
`version: ${pkg.version}`,
'description: GSD Core — Git. Ship. Done. Disciplined planning, execution, and shipping workflows. Use any gsd-* skill in this category to drive a project through new-project → discuss-phase → plan-phase → execute-phase → ship.',
'---',
'',
'# GSD Core',
'',
'GSD is a structured development workflow. Skills in this category cover',
'project initialization, phase planning, execution, code review, and shipping.',
'',
'Invoke any `gsd-*` skill in this category to drive the corresponding step.',
'',
].join('\n');
fs.writeFileSync(path.join(categoryDir, 'DESCRIPTION.md'), body);
}
/**
* Recursively install GSD commands as Antigravity skills.
* Each command becomes a skill-name/ folder containing SKILL.md.
* Mirrors copyCommandsAsCopilotSkills but uses Antigravity converters.
* @param {string} srcDir - Source commands directory
* @param {string} skillsDir - Target skills directory
* @param {string} prefix - Skill name prefix (e.g. 'gsd')
* @param {boolean} isGlobal - Whether this is a global install
*/
/**
* Single source of truth for user-owned artifacts inside gsd-core/.
*
* These files are created/refreshed by user-facing workflows (e.g.
* /gsd-profile-user) and must be preserved across reinstalls. Critically, they
* MUST be excluded from gsd-file-manifest.json — otherwise saveLocalPatches()
* will compare a refreshed file against a stale manifest hash and emit a
* spurious "locally modified GSD file" warning (bug #2771).
*
* Invariant: a file is either distribution (manifest-tracked, diff'd against
* manifest) or user artifact (preserved across installs, never diff'd). Never
* both. Both preserveUserArtifacts call sites and writeManifest must agree on
* this list, which is why it lives here as a single constant.
*
* Paths are relative to the gsd-core/ directory.
*/
const USER_OWNED_ARTIFACTS = ['USER-PROFILE.md'];
/**
* Save user-generated files from destDir to an in-memory map before a wipe.
*
* @param {string} destDir - Directory that is about to be wiped
* @param {string[]} fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve
* @returns {Map<string, string>} Map of fileName → file content (only entries that existed)
*/
function preserveUserArtifacts(destDir, fileNames) {
const saved = new Map();
for (const name of fileNames) {
const fullPath = path.join(destDir, name);
if (fs.existsSync(fullPath)) {
try {
saved.set(name, fs.readFileSync(fullPath, 'utf8'));
} catch { /* skip unreadable files */ }
}
}
return saved;
}
/**
* Restore user-generated files saved by preserveUserArtifacts after a wipe.
*
* @param {string} destDir - Directory that was wiped and recreated
* @param {Map<string, string>} saved - Map returned by preserveUserArtifacts
*/
function restoreUserArtifacts(destDir, saved) {
for (const [name, content] of saved) {
const fullPath = path.join(destDir, name);
try {
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
} catch { /* skip unwritable paths */ }
}
}
/**
* Migrate a legacy dev-preferences.md (saved from commands/gsd/) into the
* runtime-aware SKILL.md location used by the writer after #2973.
*
* For runtimes with a nested skills layout (e.g. Hermes: skills/gsd/<stem>/),
* the target is <configDir>/skills/gsd/dev-preferences/SKILL.md.
* For runtimes with a flat skills layout (prefix='gsd-'), the target is
* <configDir>/skills/gsd-dev-preferences/SKILL.md.
*
* Skips silently if no legacy file was preserved, or if a SKILL.md already
* exists at the new location (don't clobber user-customized skill content
* — they may have edited the new file directly). Returns true on actual
* migration so callers can log a one-line confirmation.
*
* @param {string} targetDir - Resolved runtime config directory (e.g. ~/.claude)
* @param {Map<string, string>} saved - Map returned by preserveUserArtifacts
* @param {string} [runtime] - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude')
* @param {'global'|'local'} [scope] - install scope
* @returns {boolean} - true if a file was migrated, false otherwise
*/
function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = 'global') {
if (!saved || !saved.has('dev-preferences.md')) return false;
let skillDir;
if (runtime) {
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
if (!skillsKindEntry) return false; // runtime has no skills layout at this scope (e.g. cline local)
const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
skillDir = path.join(assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath), stemName);
} else {
// Legacy fallback for callers that have not yet been updated to pass runtime
skillDir = path.join(assertDestWithinConfigHome(targetDir, 'skills'), 'gsd-dev-preferences');
}
const skillFile = path.join(skillDir, 'SKILL.md');
if (fs.existsSync(skillFile)) return false;
// Symlink-escape guard: reject if any path component between targetDir and
// skillDir is a symlink that would redirect writes outside the config root.
if (hasExistingSymlinkBetween(path.resolve(targetDir), skillDir)) {
throw new Error(
`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink escaping the install root "${targetDir}" — refusing to write`,
);
}
try {
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(skillFile, saved.get('dev-preferences.md'), 'utf8');
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Phase 2 — Layout-driven install/uninstall orchestrators
// ---------------------------------------------------------------------------
/**
* Apply per-runtime content rewrites in place across every SKILL.md inside a
* staged directory. Reproduces the rewrite scaffolding that the old
* copyCommandsAs<Runtime>Skills functions applied between read-content and
* converter-call. Applied AFTER stage (which already called the converter);
* rewrites target stable path patterns the converter doesn't touch.
*
* For Qwen/Hermes, branding rewrites (.claude/ → .qwen/ / .hermes/) run
* AFTER the slash-form path replacements but they only catch bare `.claude/`
* patterns (skill-body relative refs) that the slash forms didn't consume.
* This mirrors the exact ordering in the legacy copyCommandsAsClaudeSkills body.
*
* @param {string} stagedDir
* @param {string} runtime
* @param {string} pathPrefix e.g. "~/.codex/" — trailing-slash string
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
*/
// applyRuntimeContentRewritesInPlace: walk loop is now owned by
// runtimeArtifactConversion.applyRuntimeContentRewritesInPlace (ADR-1508 / #1511 Phase 2).
// The const binding above (~line 629) delegates here. Call sites in installRuntimeArtifacts
// pass attribution as the 5th arg (getCommitAttribution(runtime)) per the new contract.
/**
* Apply per-runtime content rewrites to flat .md files in a staged commands dir.
* Used for runtimes that have a commandsKind in their layout and need content rewrites
* (e.g. augment — replaces ~/.claude/ paths and applies branding conversions).
*
* IMPORTANT: `stageSkillsForProfile()` returns the original source directory unchanged
* on a full/default profile (skills === '*'). This function MUST NOT mutate that source
* directory. It always copies to a temp dir first, rewrites there, and returns the new
* path so the caller installs from the temp copy, not the source.
*
* @param {string} stagedDir directory of staged flat .md command files (may be source dir)
* @param {string} runtime
* @param {string} pathPrefix
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
* @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup)
*/
// applyRuntimeContentRewritesForCommandsInPlace: copy+rewrite loop is now owned by
// runtimeArtifactConversion.applyRuntimeContentRewritesForCommandsInPlace (ADR-1508 / #1511 Phase 2).
// The const binding above (~line 630) delegates here. Call sites in installRuntimeArtifacts
// pass attribution as the 5th arg (getCommitAttribution(runtime)) per the new contract.
/**
* Apply the per-runtime rewrite table to a single content string.
* Extracted so it can be unit-tested independently of the filesystem walk.
*
* @param {string} content
* @param {string} runtime
* @param {string} pathPrefix trailing-slash string
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
* @returns {string}
*/
// _applyRuntimeRewrites: single implementation lives in runtimeArtifactConversion
// (ADR-1508 / #1511 Phase 2). Bound here so install.js call sites and exports are
// reference-identical to the conversion module (consistent with the walkers above).
// All call sites are below this line → no TDZ hazard.
const _applyRuntimeRewrites = runtimeArtifactConversion._applyRuntimeRewrites;
const _stampNonClaudeRuntimeDefaults = runtimeArtifactConversion._stampNonClaudeRuntimeDefaults;
/**
* Copy a staged directory's contents into destDir.
* Additive — does not prune (surface.cjs handles pruning).
*
* For skills kind: each child of stagedDir is a `${prefix}${stem}/` dir; copy
* the whole dir into destDir.
* For commands/agents kind: iterate .md files and write them into destDir.
* - commands: write as `${prefix}${stem}.md` unless destSubpath already
* encodes the GSD namespace as its last segment (e.g. `commands/gsd`), in
* which case write as `${stem}.md` (directory IS the namespace).
* - agents: write as-is (files already carry their own `gsd-` prefix).
* For kimi-agents kind: recursively copy generated YAML/prompt files.
*/
function _copyStaged(stagedDir, destDir, kind, configDir) {
// Defense-in-depth: verify destDir is within the install root even if the
// upstream assertDestWithinConfigHome check was somehow bypassed. This guards
// the actual write site against any future call-site drift.
if (configDir !== undefined) {
const resolvedDest = path.resolve(destDir);
const resolvedRoot = path.resolve(configDir);
if (resolvedDest === resolvedRoot || !resolvedDest.startsWith(resolvedRoot + path.sep)) {
throw new Error(
`_copyStaged: destDir "${destDir}" must be strictly inside the install root "${configDir}", not the root itself — refusing to write`,
);
}
// Symlink-escape guard: reject if any path component between configDir and
// destDir is a symlink that would redirect writes outside configDir.
if (hasExistingSymlinkBetween(resolvedRoot, resolvedDest)) {
throw new Error(
`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${configDir}" — refusing to write`,
);
}
}
if (!fs.existsSync(stagedDir)) return;
fs.mkdirSync(destDir, { recursive: true });
if (kind.kind === 'skills') {
// Each child of stagedDir is a prefixed skill directory: gsd-help/, etc.
for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const src = path.join(stagedDir, entry.name);
const dest = path.join(destDir, entry.name);
fs.cpSync(src, dest, { recursive: true });
}
return;
}
if (kind.kind === 'kimi-agents') {
fs.cpSync(stagedDir, destDir, { recursive: true });
return;
}
// commands or agents
const entries = fs.readdirSync(stagedDir, { withFileTypes: true });
// For commands: apply prefix unless the destSubpath's last segment already
// represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd').
const destLast = path.basename(kind.destSubpath);
const prefixStem = kind.prefix ? kind.prefix.replace(/-$/, '') : '';
const namespacedByDir = kind.kind === 'commands' && destLast === prefixStem;
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3); // strip .md
let destName;
if (kind.kind === 'agents') {
// Agent files already carry the gsd- prefix in the source dir
destName = entry.name;
} else if (namespacedByDir) {
// Directory is the namespace; don't double-prefix the filename
destName = entry.name;
} else {
// Flat commands directory (e.g. command/ for opencode/kilo)
destName = `${kind.prefix}${stem}.md`;
}
fs.copyFileSync(path.join(stagedDir, entry.name), path.join(destDir, destName));
}
}
/**
* Remove GSD-prefixed entries from destDir matching kind.prefix.
* For the prefix='' case: the destSubpath IS the namespace — remove the entire
* destDir. (No current runtime uses prefix='' after #947 reversed Hermes; kept
* as a defensive guard for future runtimes.)
*/
function _removeGsdEntries(destDir, kind) {
if (!fs.existsSync(destDir)) return;
if (kind.kind === 'kimi-agents') {
for (const fileName of ['gsd.yaml', 'gsd.md']) {
fs.rmSync(path.join(destDir, fileName), { force: true });
}
const subagentsDir = path.join(destDir, 'subagents');
if (fs.existsSync(subagentsDir)) {
for (const entry of fs.readdirSync(subagentsDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
if (!entry.name.startsWith('gsd-')) continue;
if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue;
fs.rmSync(path.join(subagentsDir, entry.name), { force: true });
}
}
return;
}
if (kind.prefix === '') {
// Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd)
// The directory itself is the GSD namespace, so remove it entirely.
fs.rmSync(destDir, { recursive: true, force: true });
return;
}
for (const entry of fs.readdirSync(destDir, { withFileTypes: true })) {
if (!entry.name.startsWith(kind.prefix)) continue;
fs.rmSync(path.join(destDir, entry.name), { recursive: true, force: true });
}
}
/**
* Run legacy install migrations that must execute BEFORE the layout-driven
* copy so stale artifacts are cleaned up before new ones are written.
*
* - Claude/Qwen/Hermes: migrate legacy commands/gsd/dev-preferences.md →
* skills/gsd-dev-preferences/SKILL.md if the old file is present.
* Also removes the legacy commands/gsd/ directory.
* - Hermes: remove flat skills/gsd-STAR directories (pre-2841 layout) before
* writing the new nested skills/gsd/ layout.
*
* @param {string} runtime
* @param {string} configDir resolved runtime config directory
* @param {'global'|'local'} [scope]
*/
function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
// Claude / Qwen / Hermes: clean up legacy commands/gsd/ and preserve dev-preferences
// for migration. The actual migration call is deferred to after all layout cleanup so
// that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly
// created skills/gsd-dev-preferences/ skill dir.
let savedLegacyArtifacts = null;
if (runtime === 'claude' || runtime === 'qwen' || runtime === 'hermes') {
if (fs.existsSync(legacyCommandsGsd)) {
savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
fs.rmSync(legacyCommandsGsd, { recursive: true });
}
}
// Hermes: remove pre-#2841 flat skills/gsd-*/ entries that lived alongside
// the new skills/gsd/ nested layout.
if (runtime === 'hermes') {
const flatSkillsDir = path.join(configDir, 'skills');
if (fs.existsSync(flatSkillsDir)) {
for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true });
}
}
}
// Hermes: bare-stem skills/gsd/<stem>/ cleanup is deferred to AFTER the
// layout-driven install loop in installRuntimeArtifacts, where the exact set
// of staged gsd-<stem>/ dirs is known. Removing here (before staging) would
// require readGsdCommandNames() which misses skills like 'dev-preferences'
// that are not in the commands directory. See _removeHermesBareStemDirs().
}
// Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973).
// Done after all layout cleanup so Hermes flat-dir removal does not delete the
// newly created skill dir. No-op if skill file already exists.
if (savedLegacyArtifacts) {
migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
}
}
/**
* Run legacy uninstall cleanup that must execute BEFORE the layout-driven
* removal so old-format entries are also cleaned up.
*
* - Claude global/Qwen: remove legacy commands/gsd/ directory if present.
* For Claude LOCAL, commands/gsd/ is the current primary location (not
* legacy), so we skip removal here and let _removeGsdEntries handle it
* with gsd- prefix filtering (preserving user files like dev-preferences.md).
* - Hermes: remove pre-2841 flat skills/gsd-STAR entries.
*
* @param {string} runtime
* @param {string} configDir resolved runtime config directory
* @param {'global'|'local'} [scope]
*/
function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
// commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs.
// Prior to #1367 fix, Claude-local used commands/gsd/<cmd>.md (colon-namespaced).
// After #1367, Claude-local uses flat commands/gsd-<cmd>.md. The inline uninstall
// block (1c) handles removal of flat files; this function handles the legacy
// commands/gsd/ directory for all Claude scopes (global was already included,
// local is now added since that layout is also legacy post-#1367).
// #2973 / Codex review (bd1f06c9): preserve user-owned dev-preferences.md
// before destructive wipe. Migration to skills/gsd-dev-preferences/SKILL.md
// is deferred and returned so the caller can apply it AFTER layout-driven
// removal — this prevents the layout's gsd-* prefix removal from wiping the
// freshly created skill dir (same pattern as _runLegacyInstallMigrations).
let savedLegacyArtifacts = null;
// commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global.
// Claude local is intentionally excluded: the inline uninstall block (1c) handles
// commands/gsd/ for claude local, preserving dev-preferences.md by restoring it
// to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
// (which would redirect to skills/) conflicts with the test contract for local installs.
const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global');
if (isLegacyCommandsGsd) {
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
if (fs.existsSync(legacyCommandsGsd)) {
savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
fs.rmSync(legacyCommandsGsd, { recursive: true });
}
}
// Hermes: pre-#2841 flat skills/gsd-*/ entries
if (runtime === 'hermes') {
const flatSkillsDir = path.join(configDir, 'skills');
if (fs.existsSync(flatSkillsDir)) {
for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true });
}
}
}
// Hermes: pre-#947 bare-stem skills/gsd/<stem>/ entries (dirs that do NOT
// start with 'gsd-') — the #3664 layout used prefix='' so GSD-owned skills
// had bare names (e.g. skills/gsd/help/). These are stale on uninstall.
const nestedGsdDirForUninstall = path.join(configDir, 'skills', 'gsd');
if (fs.existsSync(nestedGsdDirForUninstall)) {
for (const entry of fs.readdirSync(nestedGsdDirForUninstall, { withFileTypes: true })) {
if (entry.isDirectory() && !entry.name.startsWith('gsd-')) {
fs.rmSync(path.join(nestedGsdDirForUninstall, entry.name), { recursive: true });
}
}
}
}
// Return saved artifacts so the caller can migrate after layout-driven removal.
return savedLegacyArtifacts;
}
/**
* Layout-driven install orchestrator.
* Runs legacy migrations first, then uses resolveRuntimeArtifactLayout to
* determine what artifact kinds to write and where.
*
* @param {string} runtime canonical runtime ID
* @param {string} configDir resolved runtime config directory
* @param {'global'|'local'} scope
* @param {Object} resolvedProfile from resolveProfile() / resolveEffectiveProfile()
*/
/**
* Deep-snapshot a directory tree into a Map<relPath, Buffer>.
* Returns an empty Map if the directory doesn't exist.
* @param {string} dir
* @returns {Map<string, Buffer>}
*/
function _snapshotDir(dir) {
const files = new Map();
if (!fs.existsSync(dir)) return files;
const walk = (relPath, absPath) => {
for (const e of fs.readdirSync(absPath, { withFileTypes: true })) {
const childRel = relPath ? path.join(relPath, e.name) : e.name;
const childAbs = path.join(absPath, e.name);
if (e.isDirectory()) walk(childRel, childAbs);
else if (e.isFile()) files.set(childRel, fs.readFileSync(childAbs));
}
};
walk('', dir);
return files;
}
/**
* Restore a directory tree from a Map<relPath, Buffer> produced by _snapshotDir.
* @param {string} dir
* @param {Map<string, Buffer>} snapshot
*/
function _restoreDir(dir, snapshot) {
for (const [relPath, buf] of snapshot) {
const absPath = path.join(dir, relPath);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, buf);
}
}
/**
* After the layout-driven install loop writes new gsd-<stem>/ dirs to
* skills/gsd/, remove any pre-existing bare-stem dirs (skills/gsd/<stem>/)
* that correspond to the newly installed gsd-<stem> entries.
*
* The removal set is derived from the ACTUAL installed skill dirs (every
* entry starting with 'gsd-' that is a directory), so it covers ALL shipped
* GSD skills — including 'dev-preferences' and future additions — without
* relying on readGsdCommandNames() which only enumerates the commands source
* tree and can miss skills that ship outside that directory.
*
* Safety: a bare dir is ONLY removed when a corresponding gsd-<stem>/ dir was
* installed this run. A user-owned dir 'skills/gsd/my-workflow/' that has no
* matching 'skills/gsd/gsd-my-workflow/' is never touched.
*
* @param {string} nestedGsdDir absolute path to skills/gsd/ category dir
*/
function _removeHermesBareStemDirs(nestedGsdDir) {
if (!fs.existsSync(nestedGsdDir)) return;
const entries = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
// Collect the set of stems that were installed as gsd-<stem>/ this run.
const installedStems = new Set();
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
installedStems.add(entry.name.slice('gsd-'.length)); // e.g. 'quick', 'dev-preferences'
}
}
// Remove any bare <stem>/ dir for which gsd-<stem>/ was just installed.
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) {
fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true });
}
}
}
function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
// Legacy cleanup before layout-driven writes
_runLegacyInstallMigrations(runtime, configDir, scope);
const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope);
const planResult = createRuntimeArtifactInstallPlan({
layout,
resolvedProfile,
homedir: () => os.homedir(),
platform: process.platform,
resolveAttribution: getCommitAttribution,
});
const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
try {
if (!planResult.ok) {
throw new Error(planResult.message);
}
const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
for (const item of planResult.plan.items) {
const kind = kindsByName.get(item.kind);
if (!kind) throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
const dest = item.destDir;
// Symlink-escape guard: reject before mkdir if dest (or any component
// between configDir and dest) is a symlink pointing outside configDir.
// mkdirSync follows symlinks, so this must run BEFORE the mkdir call.
if (hasExistingSymlinkBetween(path.resolve(configDir), dest)) {
throw new Error(
`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${configDir}" — refusing to create`,
);
}
fs.mkdirSync(dest, { recursive: true });
if (kind.kind === 'skills' && fs.existsSync(dest)) {
// Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
// then restore after. This preserves user dirs across a wipe-and-replace
// install (#2973 / #3664).
//
// All runtimes (incl. Hermes after #947) use prefix='gsd-'.
// _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
// untouched. Preserve the explicit user-owned GSD-prefixed skill
// gsd-dev-preferences, which GSD does not reinstall from source but must
// survive the prune (#2973).
const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
{
// Preserve explicitly user-owned GSD-prefixed skill dirs.
// gsd-dev-preferences is the sole user-customisable skill in this category.
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
for (const dirName of USER_OWNED_SKILL_DIRS) {
const skillDir = path.join(dest, dirName);
if (!fs.existsSync(skillDir)) continue;
const snap = _snapshotDir(skillDir);
if (snap.size > 0) toPreserve.set(dirName, snap);
}
}
_removeGsdEntries(dest, kind);
_copyStaged(item.sourceDir, dest, kind, configDir);
// Restore user-owned dirs after the prune+copy
for (const [dirName, snap] of toPreserve) {
_restoreDir(path.join(dest, dirName), snap);
}
} else {
// For non-skills kinds (commands, agents): no user content to preserve;
// just prune stale gsd-* entries and copy new ones.
_removeGsdEntries(dest, kind);
_copyStaged(item.sourceDir, dest, kind, configDir);
}
}
} finally {
for (const dir of cleanupDirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}
// Hermes: after the install loop has written all gsd-<stem>/ dirs to
// skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
// correspond to the newly installed gsd-<stem> entries. This is the robust
// replacement for the readGsdCommandNames()-based pre-install cleanup that
// missed skills like 'dev-preferences' (#947 adversarial review).
//
// We run this AFTER the install loop so the installed set is authoritative:
// every gsd-<stem>/ present now was written this run (or was there before
// with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
// are untouched.
if (runtime === 'hermes') {
const nestedGsdDirForCleanup = path.join(configDir, 'skills', 'gsd');
_removeHermesBareStemDirs(nestedGsdDirForCleanup);
}
}
/**
* Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo).
*
* These runtimes do NOT go through installRuntimeArtifacts (their commands use a
* bespoke flattened-command writer), so this writes ONLY the skills kind
* alongside their existing command/ + agents/ surfaces. Uninstall is already
* layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the
* skills/ dir is cleaned up automatically once the layout declares it.
*
* `rawCommandsDir` MUST be the SAME staged command directory the flattened
* command writer consumes (the caller passes its `_stageSkills()` output) so the
* command/ and skills/ surfaces always cover the identical, profile-resolved set
* — including the `--minimal`/`--core-only` alias path, which stages differently
* from a plain `--profile=core`.
*
* Mirrors copyFlattenedCommands exactly per file — pathPrefix rewrite →
* attribution → command→skill conversion — guaranteeing command/ and skills/
* bodies match byte-for-byte for global, --local, and --config-dir installs.
* We deliberately do NOT use skillsKindEntry.stage(): that converts before any
* pathPrefix is known, so its bodies would carry the converter's hardcoded
* default config dir. (#784)
*
* @param {string} runtime - 'opencode' or 'kilo'
* @param {string} targetDir - resolved runtime config directory
* @param {string} rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
* @param {string} pathPrefix - computed config-path prefix for body rewrites
* @returns {number} number of gsd-* skill directories written
*/
function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix) {
const layout = resolveRuntimeArtifactLayout(runtime, targetDir);
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
if (!skillsKindEntry) return 0;
const rawDir = rawCommandsDir;
if (!rawDir || !fs.existsSync(rawDir)) return 0;
const converter = runtime === 'kilo'
? convertClaudeCommandToKiloSkill
: convertClaudeCommandToOpencodeSkill;
const dest = assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
// Symlink-escape guard: reject if any path component between targetDir and
// dest is a symlink that would redirect writes outside the config root.
if (hasExistingSymlinkBetween(path.resolve(targetDir), dest)) {
throw new Error(
`installOpencodeFamilySkills: destDir "${dest}" contains a symlink escaping the install root "${targetDir}" — refusing to write`,
);
}
fs.mkdirSync(dest, { recursive: true });
// Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
// gsd-dev-preferences is generated by the user (via generate-dev-preferences)
// and lives at <configDir>/skills/gsd-dev-preferences — _removeGsdEntries
// would otherwise wipe it. Mirrors the preservation in installRuntimeArtifacts
// (#2973).
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
for (const dirName of USER_OWNED_SKILL_DIRS) {
const skillDir = path.join(dest, dirName);
if (!fs.existsSync(skillDir)) continue;
const snap = _snapshotDir(skillDir);
if (snap.size > 0) toPreserve.set(dirName, snap);
}
_removeGsdEntries(dest, skillsKindEntry);
let count = 0;
for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3);
const skillName = `${skillsKindEntry.prefix}${stem}`;
let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8');
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
content = processAttribution(content, getCommitAttribution(runtime));
content = converter(content, skillName);
const skillDir = path.join(dest, skillName);
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
count++;
}
// Restore user-owned dirs after the prune+copy.
for (const [dirName, snap] of toPreserve) {
_restoreDir(path.join(dest, dirName), snap);
}
return count;
}
/**
* Layout-driven uninstall orchestrator.
* Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to
* determine which GSD-owned entries to remove.
*
* @param {string} runtime canonical runtime ID
* @param {string} configDir resolved runtime config directory
* @param {'global'|'local'} scope
*/
function uninstallRuntimeArtifacts(runtime, configDir, scope) {
// Legacy cleanup before layout-driven removal (scope-aware to avoid
// removing Claude local commands/gsd/ which is the primary install dir).
// Returns saved user artifacts so we can migrate AFTER layout removal
// (the layout's gsd-* prefix pass would wipe a skill dir created here).
const savedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope);
const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope);
const plan = createRuntimeArtifactUninstallPlan(layout);
const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
for (const item of plan.items) {
const kind = kindsByName.get(item.kind);
if (!kind) {
throw new Error(`Runtime artifact uninstall plan referenced unknown kind: ${item.kind}`);
}
_removeGsdEntries(item.destDir, kind);
}
// Hermes: after removing gsd-* skill dirs from skills/gsd/, also remove
// the GSD-managed DESCRIPTION.md and then the category dir itself if it
// contains no user content (#947). _removeGsdEntries removed gsd-* dirs
// but left the category container and DESCRIPTION.md intact.
if (runtime === 'hermes') {
const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
if (fs.existsSync(nestedGsdDir)) {
// Remove GSD-owned DESCRIPTION.md (written by writeHermesCategoryDescription)
fs.rmSync(path.join(nestedGsdDir, 'DESCRIPTION.md'), { force: true });
// Remove the category dir if empty (no user content remaining)
const remaining = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
if (remaining.length === 0) {
fs.rmSync(nestedGsdDir, { recursive: true, force: true });
}
}
}
// #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the
// runtime-aware SKILL.md location after all layout-driven removal is
// complete. Do NOT restore to commands/gsd/ — the user is uninstalling.
if (savedLegacyArtifacts) {
migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
}
}
/**
* 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', 'gemini', '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) {
const isOpencode = runtime === 'opencode';
const isKilo = runtime === 'kilo';
const isGemini = runtime === 'gemini';
const isCodex = runtime === 'codex';
const isCopilot = runtime === 'copilot';
const isAntigravity = runtime === 'antigravity';
const isCursor = runtime === 'cursor';
const isWindsurf = runtime === 'windsurf';
const isAugment = runtime === 'augment';
const isTrae = runtime === 'trae';
const isQwen = runtime === 'qwen';
const isHermes = runtime === 'hermes';
const isCline = runtime === 'cline';
const dirName = getDirName(runtime);
// 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);
if (entry.isDirectory()) {
copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal);
} else if (entry.name.endsWith('.md')) {
// Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths
// Skip generic replacement for Copilot — convertClaudeToCopilotContent handles all paths
let content = fs.readFileSync(srcPath, 'utf8');
if (!isCopilot && !isAntigravity) {
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}/`);
content = content.replace(/~\/\.claude\b/g, pathPrefix.replace(/\/$/, ''));
content = content.replace(/\$HOME\/\.claude\b/g, pathPrefix.replace(/\/$/, ''));
content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
content = content.replace(/~\/\.qwen\//g, pathPrefix);
content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix);
content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`);
content = content.replace(/~\/\.hermes\//g, pathPrefix);
content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix);
content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`);
}
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 gsd-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 (runtime !== 'claude') {
content = _stampNonClaudeRuntimeDefaults(content, runtime);
}
// #3683 — normalize /gsd:<cmd> → /gsd-<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 runtimes (Gemini).
content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames());
// Convert frontmatter for opencode compatibility
if (isOpencode || isKilo) {
content = isKilo
? convertClaudeToKiloFrontmatter(content)
: convertClaudeToOpencodeFrontmatter(content);
fs.writeFileSync(destPath, content);
} else if (isGemini) {
// Apply Gemini-specific Markdown transformations (slash commands, TOML).
// #778: thread the command name (file stem) so per-command TOML
// enrichment (live-state injection) can target a specific command.
const geminiCommandName = isCommand ? entry.name.replace(/\.md$/, '') : null;
const processed = convertClaudeToGeminiMarkdown(content, { isCommand, commandName: geminiCommandName });
const finalPath = isCommand ? destPath.replace(/\.md$/, '.toml') : destPath;
fs.writeFileSync(finalPath, processed);
} else if (isCodex) {
content = convertClaudeToCodexMarkdown(content);
fs.writeFileSync(destPath, content);
} else if (isCopilot) {
content = convertClaudeToCopilotContent(content, isGlobal);
content = processAttribution(content, getCommitAttribution(runtime));
fs.writeFileSync(destPath, content);
} else if (isAntigravity) {
content = convertClaudeToAntigravityContent(content, isGlobal);
content = processAttribution(content, getCommitAttribution(runtime));
fs.writeFileSync(destPath, content);
} else if (isCursor) {
content = convertClaudeToCursorMarkdown(content);
fs.writeFileSync(destPath, content);
} else if (isWindsurf) {
content = convertClaudeToWindsurfMarkdown(content);
fs.writeFileSync(destPath, content);
} else if (isTrae) {
content = convertClaudeToTraeMarkdown(content);
fs.writeFileSync(destPath, content);
} else if (isCline) {
content = convertClaudeToCliineMarkdown(content);
fs.writeFileSync(destPath, content);
} else if (isQwen) {
content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
content = content.replace(/\.claude\//g, '.qwen/');
fs.writeFileSync(destPath, content);
} else if (isHermes) {
content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
content = content.replace(/\.claude\//g, '.hermes/');
fs.writeFileSync(destPath, content);
} else {
fs.writeFileSync(destPath, content);
}
} else if (isCopilot && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
// Copilot: also transform .cjs/.js files for CONV-06 and CONV-07
let content = fs.readFileSync(srcPath, 'utf8');
content = convertClaudeToCopilotContent(content, isGlobal);
fs.writeFileSync(destPath, content);
} else if (isAntigravity && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
// Antigravity: also transform .cjs/.js files for path/command conversions
let content = fs.readFileSync(srcPath, 'utf8');
content = convertClaudeToAntigravityContent(content, isGlobal);
fs.writeFileSync(destPath, content);
} else if (isCursor && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
// For Cursor, also convert Claude references in JS/CJS utility scripts
let jsContent = fs.readFileSync(srcPath, 'utf8');
jsContent = jsContent.replace(/gsd:/gi, 'gsd-');
jsContent = jsContent.replace(/\.claude\/skills\//g, '.cursor/skills/');
jsContent = jsContent.replace(/CLAUDE\.md/g, '.cursor/rules/');
jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cursor');
fs.writeFileSync(destPath, jsContent);
} else if (isWindsurf && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
// For Windsurf/Devin, also convert Claude references in JS/CJS utility scripts.
// Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
let jsContent = fs.readFileSync(srcPath, 'utf8');
jsContent = jsContent.replace(/gsd:/gi, 'gsd-');
jsContent = jsContent.replace(/\.claude\/skills\//g, '.devin/skills/');
jsContent = jsContent.replace(/CLAUDE\.md/g, '.devin/rules');
jsContent = jsContent.replace(/\bClaude Code\b/g, 'Windsurf');
fs.writeFileSync(destPath, jsContent);
} else if (isTrae && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
let jsContent = fs.readFileSync(srcPath, 'utf8');
jsContent = jsContent.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
return `/gsd-${commandName}`;
});
jsContent = jsContent.replace(/\.claude\/skills\//g, '.trae/skills/');
jsContent = jsContent.replace(/CLAUDE\.md/g, '.trae/rules/');
jsContent = jsContent.replace(/\bClaude Code\b/g, 'Trae');
fs.writeFileSync(destPath, jsContent);
} else if (isCline && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
let jsContent = fs.readFileSync(srcPath, 'utf8');
jsContent = jsContent.replace(/\.claude\/skills\//g, '.cline/skills/');
jsContent = jsContent.replace(/CLAUDE\.md/g, '.clinerules');
jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cline');
fs.writeFileSync(destPath, jsContent);
} else if (isQwen && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
let jsContent = fs.readFileSync(srcPath, 'utf8');
jsContent = jsContent.replace(/\.claude\/skills\//g, '.qwen/skills/');
jsContent = jsContent.replace(/\.claude\//g, '.qwen/');
jsContent = jsContent.replace(/CLAUDE\.md/g, 'QWEN.md');
jsContent = jsContent.replace(/\bClaude Code\b/g, 'Qwen Code');
fs.writeFileSync(destPath, jsContent);
} else if (isHermes && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
let jsContent = fs.readFileSync(srcPath, 'utf8');
jsContent = jsContent.replace(/\.claude\/skills\//g, '.hermes/skills/');
jsContent = jsContent.replace(/\.claude\//g, '.hermes/');
jsContent = jsContent.replace(/CLAUDE\.md/g, 'HERMES.md');
jsContent = jsContent.replace(/\bClaude Code\b/g, 'Hermes Agent');
fs.writeFileSync(destPath, jsContent);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Clean up orphaned hook registrations from settings.json
*/
function cleanupOrphanedHooks(settings) {
const orphanedHookPatterns = [
'gsd-notify.sh', // Removed in v1.6.x
'hooks/statusline.js', // Renamed to gsd-statusline.js in v1.9.0
'gsd-intel-index.js', // Removed in v1.9.2
'gsd-intel-session.js', // Removed in v1.9.2
'gsd-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 GSD statusline.js path
// Only match the specific old GSD 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$1gsd-statusline.js'
);
console.log(` ${green}✓${reset} Updated statusline path (hooks/statusline.js → hooks/gsd-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;
}
/**
* GSD 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).
*/
const GSD_UNINSTALL_HOOKS = [
'gsd-statusline.js',
'gsd-check-update.js',
'gsd-check-update.cmd',
'gsd-config-reload.js',
'gsd-context-monitor.js',
'gsd-cursor-session-start.js',
'gsd-cursor-post-tool.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',
'gsd-update-banner.js',
'gsd-workflow-guard.js',
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh',
'gsd-graphify-update.sh',
];
/**
* Uninstall GSD from the specified directory for a specific runtime
* Removes only GSD-specific files/directories, preserves user content
* @param {boolean} isGlobal - Whether to uninstall from global or local
* @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex', 'copilot')
*/
function uninstall(isGlobal, runtime = 'claude') {
const isOpencode = runtime === 'opencode';
const isKilo = runtime === 'kilo';
const isGemini = runtime === 'gemini';
const isCodex = runtime === 'codex';
const isCopilot = runtime === 'copilot';
const isAntigravity = runtime === 'antigravity';
const isCursor = runtime === 'cursor';
const isWindsurf = runtime === 'windsurf';
const isAugment = runtime === 'augment';
const isTrae = runtime === 'trae';
const isQwen = runtime === 'qwen';
const isHermes = runtime === 'hermes';
const isCodebuddy = runtime === 'codebuddy';
const dirName = getDirName(runtime);
// Get the target directory based on runtime and install type. Cline local
// installs write to the project root (.clinerules/ lives at the root, not in
// a .cline/ subdir), mirroring the install() path resolution (#787).
const targetDir = isGlobal
? getGlobalConfigDir(runtime, explicitConfigDir)
: runtime === 'cline'
? process.cwd()
: path.join(process.cwd(), dirName);
const locationLabel = isGlobal
? targetDir.replace(os.homedir(), '~')
: targetDir.replace(process.cwd(), '.');
let runtimeLabel = 'Claude Code';
if (runtime === 'opencode') runtimeLabel = 'OpenCode';
if (runtime === 'gemini') runtimeLabel = 'Gemini';
if (runtime === 'kilo') runtimeLabel = 'Kilo';
if (runtime === 'codex') runtimeLabel = 'Codex';
if (runtime === 'copilot') runtimeLabel = 'Copilot';
if (runtime === 'antigravity') runtimeLabel = 'Antigravity';
if (runtime === 'cursor') runtimeLabel = 'Cursor';
if (runtime === 'windsurf') runtimeLabel = 'Windsurf';
if (runtime === 'augment') runtimeLabel = 'Augment';
if (runtime === 'trae') runtimeLabel = 'Trae';
if (runtime === 'qwen') runtimeLabel = 'Qwen Code';
if (runtime === 'hermes') runtimeLabel = 'Hermes Agent';
if (runtime === 'kimi') runtimeLabel = 'Kimi CLI';
if (runtime === 'codebuddy') runtimeLabel = 'CodeBuddy';
console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
// #786: AGENTS.md lives at the repo root (outside targetDir) for local Copilot
// installs, so its cleanup must run even when .github (targetDir) was already
// removed — i.e. BEFORE the "target directory missing" early-return below.
if (isCopilot && !isGlobal) {
const agentsMdPath = path.join(process.cwd(), 'AGENTS.md');
if (fs.existsSync(agentsMdPath)) {
const content = fs.readFileSync(agentsMdPath, 'utf8');
const cleaned = stripGsdFromCopilotInstructions(content);
if (cleaned === null) {
fs.unlinkSync(agentsMdPath);
console.log(` ${green}✓${reset} Removed AGENTS.md (was GSD-only)`);
} else if (cleaned !== content) {
fs.writeFileSync(agentsMdPath, cleaned);
console.log(` ${green}✓${reset} Cleaned GSD section from AGENTS.md`);
}
}
}
// 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;
// Remove profile marker so a clean reinstall defaults to full surface.
try {
fs.unlinkSync(path.join(targetDir, '.gsd-profile'));
removedCount++;
} catch {}
// 1. Remove GSD commands/skills (layout-driven)
const scope = isGlobal ? 'global' : 'local';
uninstallRuntimeArtifacts(runtime, targetDir, scope);
removedCount++;
// 1a. Non-layout Codex side-effects: agent .toml files, config.toml sections, hooks.json
if (isCodex) {
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('gsd-') && 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 GSD sections from config.toml
const codexConfigPath = path.join(targetDir, 'config.toml');
if (fs.existsSync(codexConfigPath)) {
const content = fs.readFileSync(codexConfigPath, 'utf8');
const cleaned = stripGsdFromCodexConfig(content);
if (cleaned === null) {
fs.unlinkSync(codexConfigPath);
removedCount++;
console.log(` ${green}✓${reset} Removed config.toml (was GSD-only)`);
} else if (cleaned !== content) {
fs.writeFileSync(codexConfigPath, cleaned);
removedCount++;
console.log(` ${green}✓${reset} Cleaned GSD 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: remove new Codex hook event registrations added by this enhancement.
for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
const eventCleanup = removeCodexHooksJsonEvent(targetDir, eventName);
if (eventCleanup.changed) {
removedCount++;
console.log(` ${green}✓${reset} Removed managed Codex ${eventName} hook from hooks.json`);
}
}
}
// 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup
if (isCopilot) {
const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
if (fs.existsSync(instructionsPath)) {
const content = fs.readFileSync(instructionsPath, 'utf8');
const cleaned = stripGsdFromCopilotInstructions(content);
if (cleaned === null) {
fs.unlinkSync(instructionsPath);
removedCount++;
console.log(` ${green}✓${reset} Removed copilot-instructions.md (was GSD-only)`);
} else if (cleaned !== content) {
fs.writeFileSync(instructionsPath, cleaned);
removedCount++;
console.log(` ${green}✓${reset} Cleaned GSD section from copilot-instructions.md`);
}
}
// #786: remove the GSD-managed Copilot lifecycle hook config and prune the
// hooks dir if we left it empty.
const hookPath = path.join(targetDir, 'hooks', GSD_COPILOT_HOOK_FILE);
if (fs.existsSync(hookPath)) {
fs.unlinkSync(hookPath);
removedCount++;
console.log(` ${green}✓${reset} Removed Copilot lifecycle hook (${GSD_COPILOT_HOOK_FILE})`);
try {
const hooksDir = path.join(targetDir, 'hooks');
if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) {
fs.rmdirSync(hooksDir);
}
} catch { /* non-fatal: leave a non-empty/locked hooks dir in place */ }
}
// Note: AGENTS.md (repo root) is cleaned earlier, before the targetDir
// existence early-return, since it lives outside targetDir (#786).
}
// 1b-cline. Non-layout Cline side-effects (issue #787): remove the
// directory-form rules + PreToolUse hook, and strip the GSD block from the
// global cross-tool ~/.agents/AGENTS.md target.
if (runtime === 'cline') {
const clinerulesDir = path.join(targetDir, '.clinerules');
for (const rel of ['gsd.md', path.join('hooks', 'PreToolUse')]) {
const p = path.join(clinerulesDir, rel);
try {
if (fs.existsSync(p)) {
fs.unlinkSync(p);
removedCount++;
}
} catch { /* best-effort */ }
}
// Also remove a legacy single-file .clinerules left by pre-#787 installs.
try {
if (fs.existsSync(clinerulesDir) && fs.statSync(clinerulesDir).isFile()) {
fs.unlinkSync(clinerulesDir);
removedCount++;
}
} catch { /* best-effort */ }
// Prune now-empty GSD-created directories (leave any user-added rule files).
for (const dir of [path.join(clinerulesDir, 'hooks'), clinerulesDir]) {
try {
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0) {
fs.rmdirSync(dir);
}
} catch { /* best-effort */ }
}
if (isGlobal) {
const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md');
try {
if (fs.existsSync(agentsPath)) {
const content = fs.readFileSync(agentsPath, 'utf8');
const cleaned = stripGsdFromAgentsMd(content);
if (cleaned === null) {
fs.unlinkSync(agentsPath);
removedCount++;
console.log(` ${green}✓${reset} Removed ~/.agents/AGENTS.md (was GSD-only)`);
} else if (cleaned !== content) {
fs.writeFileSync(agentsPath, cleaned);
removedCount++;
console.log(` ${green}✓${reset} Cleaned GSD section from ~/.agents/AGENTS.md`);
}
}
} catch { /* best-effort */ }
}
}
// 1b-cursor. Non-layout Cursor side-effects (issue #777): remove GSD-managed
// hook entries from hooks.json and clean up the managed hook scripts.
if (isCursor) {
const hooksJsonCleanup = removeCursorHooksJson(targetDir);
if (hooksJsonCleanup.changed) {
removedCount++;
console.log(` ${green}✓${reset} Removed GSD-managed Cursor hooks from hooks.json`);
}
// Remove the managed hook scripts (session-start + post-tool).
const hooksDir = path.join(targetDir, 'hooks');
for (const script of [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT]) {
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 gsd-*.md commands from commands/ (current layout,
// #1367 fix). Also remove legacy commands/gsd/ subdirectory from prior installs.
if (!isGlobal && runtime === 'claude') {
const commandsDir = path.join(targetDir, 'commands');
// Remove flat gsd-*.md files (current layout after #1367 fix)
if (fs.existsSync(commandsDir)) {
let removed = 0;
for (const f of fs.readdirSync(commandsDir)) {
if (f.startsWith('gsd-') && f.endsWith('.md')) {
fs.rmSync(path.join(commandsDir, f), { force: true });
removed++;
}
}
if (removed > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${removed} flat gsd-*.md commands from commands/`);
}
}
// Remove legacy commands/gsd/ subdirectory if it still exists (pre-#1367 layout).
// Preserve user-owned dev-preferences.md if present (#1423 parity).
const legacyGsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
if (fs.existsSync(legacyGsdCommandsDir)) {
const legacyDevPrefsPath = path.join(legacyGsdCommandsDir, 'dev-preferences.md');
const savedDevPrefs = fs.existsSync(legacyDevPrefsPath) ? fs.readFileSync(legacyDevPrefsPath, 'utf-8') : null;
fs.rmSync(legacyGsdCommandsDir, { recursive: true });
removedCount++;
console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
if (savedDevPrefs) {
try {
fs.mkdirSync(legacyGsdCommandsDir, { recursive: true });
fs.writeFileSync(legacyDevPrefsPath, savedDevPrefs);
console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`);
} catch (err) {
console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
}
}
}
}
// 1d. Gemini: remove commands/gsd/ with dev-preferences.md preservation.
// The layout removes gsd-*.toml files but not the directory itself.
// Preserve user files before removing the directory.
if (isGemini) {
const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
if (fs.existsSync(gsdCommandsDir)) {
const devPrefsPath = path.join(gsdCommandsDir, 'dev-preferences.md');
const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null;
fs.rmSync(gsdCommandsDir, { recursive: true });
removedCount++;
console.log(` ${green}✓${reset} Removed commands/gsd/`);
if (preservedDevPrefs) {
try {
fs.mkdirSync(gsdCommandsDir, { recursive: true });
fs.writeFileSync(devPrefsPath, preservedDevPrefs);
console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`);
} catch (err) {
console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
}
}
}
}
// 1d. Qwen/Hermes: migrate dev-preferences.md from legacy commands/gsd/ location
// during uninstall. _runLegacyUninstallCleanup (called by uninstallRuntimeArtifacts)
// removes the directory; we must preserve/restore user artifacts before that path.
// This block runs AFTER uninstallRuntimeArtifacts, so we check if the directory
// was already removed and skip if so (idempotent).
if (isQwen || isHermes) {
// dev-preferences may have survived in skills/ as SKILL.md — nothing to do for
// that case. If a stale commands/gsd/ still exists (e.g. legacy was not removed),
// attempt migration. In practice _runLegacyUninstallCleanup removes it first,
// so this is a best-effort guard.
const legacyDir = path.join(targetDir, 'commands', 'gsd');
if (fs.existsSync(legacyDir)) {
const savedLegacyArtifacts = preserveUserArtifacts(legacyDir, ['dev-preferences.md']);
fs.rmSync(legacyDir, { recursive: true });
removedCount++;
console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
const _uninstallScope = isGlobal ? 'global' : 'local';
if (migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts, runtime, _uninstallScope)) {
// Compute the actual path written so the log line is accurate per-runtime
const _layout = resolveRuntimeArtifactLayout(runtime, targetDir, _uninstallScope);
const _sk = _layout.kinds.find((k) => k.kind === 'skills');
const _stem = _sk && _sk.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
const _skillRelPath = _sk ? `${_sk.destSubpath}/${_stem}/SKILL.md` : 'skills/gsd-dev-preferences/SKILL.md';
console.log(` ${green}✓${reset} Migrated dev-preferences.md → ${_skillRelPath} (#2973)`);
} else {
// Migration failed or already exists — restore to legacy location so user content is not lost
restoreUserArtifacts(legacyDir, savedLegacyArtifacts);
}
}
}
// 2. Remove gsd-core directory
const gsdDir = path.join(targetDir, 'gsd-core');
if (fs.existsSync(gsdDir)) {
// Preserve user-generated files before wipe (#1423)
const userProfilePath = path.join(gsdDir, 'USER-PROFILE.md');
const preservedProfile = fs.existsSync(userProfilePath) ? fs.readFileSync(userProfilePath, 'utf-8') : null;
fs.rmSync(gsdDir, { recursive: true });
removedCount++;
console.log(` ${green}✓${reset} Removed gsd-core/`);
// Restore user-generated files
if (preservedProfile) {
try {
fs.mkdirSync(gsdDir, { recursive: true });
fs.writeFileSync(userProfilePath, preservedProfile);
console.log(` ${green}✓${reset} Preserved gsd-core/USER-PROFILE.md`);
} catch (err) {
console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`);
}
}
}
// 3. Remove GSD agents (gsd-*.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('gsd-') && file.endsWith('.md')) {
fs.unlinkSync(path.join(agentsDir, file));
agentCount++;
}
}
if (agentCount > 0) {
removedCount++;
console.log(` ${green}✓${reset} Removed ${agentCount} GSD agents`);
}
}
// 4. Remove GSD hooks
const hooksDir = path.join(targetDir, 'hooks');
if (fs.existsSync(hooksDir)) {
let hookCount = 0;
for (const hook of GSD_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} GSD hooks`);
}
// Remove only the GSD-managed files from hooks/lib/ (git-cmd.js + gsd-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 GSD_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)`);
}
}
}
// 4a. Remove scripts/changeset/ and scripts/lib/ (#935)
// GSD-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 GSD files, attempt to rmdir — if the directory is still
// non-empty (user has custom helpers) it stays; otherwise it goes cleanly.
const GSD_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 GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs'];
const changesetUninstallDir = path.join(targetDir, 'scripts', 'changeset');
if (fs.existsSync(changesetUninstallDir)) {
let removedChangeset = 0;
for (const file of GSD_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/ GSD files`);
}
}
const scriptsLibUninstallDir = path.join(targetDir, 'scripts', 'lib');
if (fs.existsSync(scriptsLibUninstallDir)) {
let removedScriptsLib = 0;
for (const file of GSD_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/ GSD 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 */ }
// 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 GSD package.json (CommonJS mode marker)
const pkgJsonPath = path.join(targetDir, 'package.json');
if (fs.existsSync(pkgJsonPath)) {
try {
const content = fs.readFileSync(pkgJsonPath, 'utf8').trim();
// Only remove if it's our minimal CommonJS marker
if (content === '{"type":"commonjs"}') {
fs.unlinkSync(pkgJsonPath);
removedCount++;
console.log(` ${green}✓${reset} Removed GSD package.json`);
}
} catch (e) {
// Ignore read errors
}
}
// 6. Clean up settings.json (remove GSD 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 GSD statusline if it references our hook
if (settings.statusLine && settings.statusLine.command &&
settings.statusLine.command.includes('gsd-statusline')) {
delete settings.statusLine;
settingsModified = true;
console.log(` ${green}✓${reset} Removed GSD statusline from settings`);
}
// Remove GSD hooks from settings — per-hook granularity to preserve
// user hooks that share an entry with a GSD hook (#1755 followup).
// Includes the 3 Qwen-only events added in #788 (SubagentStop, Stop,
// PreCompact, also registered for Claude in #770), the 3 Gemini-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 GSD 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 GSD hooks from settings`);
}
// Clean up empty hooks object
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
delete settings.hooks;
}
// #768 — Remove GSD-owned Claude permissions from settings.json.
// Applies only to Claude uninstalls. Filter only the exact GSD-owned entries
// to preserve any user-added allow/deny entries.
// Uses a local flag to avoid the shared `settingsModified` producing a false
// "Removed GSD permissions" message when only hooks/statusline changed.
if (runtime === 'claude' && settings.permissions) {
let permissionsModified = false;
if (Array.isArray(settings.permissions.allow)) {
const before = settings.permissions.allow.length;
settings.permissions.allow = settings.permissions.allow.filter(
(e) => !GSD_CLAUDE_ALLOW_PERMISSIONS.includes(e)
);
if (settings.permissions.allow.length !== before) {
permissionsModified = true;
}
}
if (Array.isArray(settings.permissions.deny)) {
const before = settings.permissions.deny.length;
settings.permissions.deny = settings.permissions.deny.filter(
(e) => !GSD_CLAUDE_DENY_PERMISSIONS.includes(e)
);
if (settings.permissions.deny.length !== before) {
permissionsModified = true;
}
}
if (permissionsModified) {
settingsModified = true;
console.log(` ${green}✓${reset} Removed GSD permissions from settings.json`);
}
}
if (settingsModified) {
writeSettings(settingsPath, settings);
removedCount++;
}
}
// 6. For OpenCode, clean up permissions from opencode.json or opencode.jsonc
if (isOpencode) {
const configPath = resolveOpencodeConfigPath(targetDir);
if (fs.existsSync(configPath)) {
try {
const config = parseJsonc(fs.readFileSync(configPath, 'utf8'));
let modified = false;
// Remove GSD 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('gsd-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 GSD permissions from ${path.basename(configPath)}`);
}
} catch (e) {
// Ignore JSON parse errors
}
}
}
// 7. For Kilo, clean up permissions from kilo.json or kilo.jsonc
if (isKilo) {
const configPath = resolveKiloConfigPath(targetDir);
if (fs.existsSync(configPath)) {
try {
const config = parseJsonc(fs.readFileSync(configPath, 'utf8'));
let modified = false;
// Remove GSD 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('gsd-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 GSD permissions from ${path.basename(configPath)}`);
}
} 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 GSD files found to remove.`);
}
console.log(`
${green}Done!${reset} GSD 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 GSD reference docs
* This prevents permission prompts when GSD accesses the gsd-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 GSD 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 gsdPath = opencodeConfigDir === defaultConfigDir
? '~/.config/opencode/gsd-core/*'
: `${opencodeConfigDir.replace(/\\/g, '/')}/gsd-core/*`;
let modified = false;
// Configure read permission
if (!config.permission.read || typeof config.permission.read !== 'object') {
config.permission.read = {};
}
if (config.permission.read[gsdPath] !== 'allow') {
config.permission.read[gsdPath] = 'allow';
modified = true;
}
// Configure external_directory permission (the safety guard for paths outside project)
if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
config.permission.external_directory = {};
}
if (config.permission.external_directory[gsdPath] !== 'allow') {
config.permission.external_directory[gsdPath] = 'allow';
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 GSD docs`);
}
/**
* Configure Kilo permissions to allow reading GSD reference docs
* This prevents permission prompts when GSD accesses the gsd-core directory
* @param {boolean} isGlobal - Whether this is a global or local install
* @param {string|null} configDir - Resolved config directory when already known
*/
function configureKiloPermissions(isGlobal = true, configDir = null) {
// For local installs, use ./.kilo/
// For global installs, use ~/.config/kilo/
const kiloConfigDir = configDir || (isGlobal
? getGlobalConfigDir('kilo', explicitConfigDir)
: path.join(process.cwd(), '.kilo'));
// Ensure config directory exists
fs.mkdirSync(kiloConfigDir, { recursive: true });
const configPath = resolveKiloConfigPath(kiloConfigDir);
// 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;
}
}
// Ensure permission structure exists
if (!config.permission || typeof config.permission !== 'object') {
config.permission = {};
}
// Build the GSD 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', 'kilo');
const gsdPath = kiloConfigDir === defaultConfigDir
? '~/.config/kilo/gsd-core/*'
: `${kiloConfigDir.replace(/\\/g, '/')}/gsd-core/*`;
let modified = false;
// Configure read permission
if (!config.permission.read || typeof config.permission.read !== 'object') {
config.permission.read = {};
}
if (config.permission.read[gsdPath] !== 'allow') {
config.permission.read[gsdPath] = 'allow';
modified = true;
}
// Configure external_directory permission (the safety guard for paths outside project)
if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
config.permission.external_directory = {};
}
if (config.permission.external_directory[gsdPath] !== 'allow') {
config.permission.external_directory[gsdPath] = 'allow';
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 GSD docs`);
}
/**
* 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', 'gemini', 'codex')
*/
// ──────────────────────────────────────────────────────
// Local Patch Persistence
// ──────────────────────────────────────────────────────
const PATCHES_DIR_NAME = 'gsd-local-patches';
const MANIFEST_NAME = 'gsd-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)) {
return null;
}
return { relPath: normalized, fullPath };
}
function hasExistingSymlinkBetween(root, fullPath) {
const resolvedRoot = path.resolve(root);
const resolvedFullPath = path.resolve(fullPath);
if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + path.sep)) {
return true;
}
let cursor = resolvedRoot;
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) {
return true;
}
const relative = path.relative(resolvedRoot, resolvedFullPath);
for (const segment of relative.split(path.sep)) {
if (!segment) continue;
cursor = path.join(cursor, segment);
if (!fs.existsSync(cursor)) return false;
if (fs.lstatSync(cursor).isSymbolicLink()) return true;
}
return false;
}
/**
* Write file manifest after installation for future modification detection
*/
function writeManifest(configDir, runtime = 'claude', options = {}) {
const isOpencode = runtime === 'opencode';
const isKilo = runtime === 'kilo';
const isGemini = runtime === 'gemini';
const isCodex = runtime === 'codex';
const isCopilot = runtime === 'copilot';
const isAntigravity = runtime === 'antigravity';
const isCursor = runtime === 'cursor';
const isWindsurf = runtime === 'windsurf';
const isTrae = runtime === 'trae';
const isCline = runtime === 'cline';
const isKimi = runtime === 'kimi';
const isHermes = runtime === 'hermes';
const gsdDir = path.join(configDir, 'gsd-core');
// #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
// commandsDir points to the old location for Gemini (which still uses commands/gsd/).
// Claude local uses flatCommandsDir instead for manifest recording.
const commandsDir = path.join(configDir, 'commands', 'gsd');
const flatCommandsDir = path.join(configDir, 'commands');
const opencodeCommandDir = path.join(configDir, 'command');
// Hermes nests GSD skills under skills/gsd/ as a single category (#2841).
// All other runtimes that use the Codex-style skills layout use a flat skills/ root.
const codexSkillsDir = isHermes
? path.join(configDir, 'skills', 'gsd')
: path.join(configDir, 'skills');
const codexSkillsManifestPrefix = isHermes ? 'skills/gsd/' : 'skills/';
const agentsDir = path.join(configDir, 'agents');
const manifest = {
version: pkg.version,
timestamp: new Date().toISOString(),
mode: options.mode === 'minimal' ? 'minimal' : 'full',
files: {},
};
const gsdHashes = generateManifest(gsdDir);
for (const [rel, hash] of Object.entries(gsdHashes)) {
// Skip user-owned artifacts (e.g. USER-PROFILE.md). They are preserved
// across reinstalls by preserveUserArtifacts 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['gsd-core/' + rel] = hash;
}
// Record commands surface for runtimes that emit it:
// Gemini: commands/gsd/<cmd>.toml (nested, colon-namespaced)
// Claude local (#1367 fix): flat gsd-<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.
if (isGemini && fs.existsSync(commandsDir)) {
const cmdHashes = generateManifest(commandsDir);
for (const [rel, hash] of Object.entries(cmdHashes)) {
manifest.files['commands/gsd/' + rel] = hash;
}
}
// Claude local (#1367): flat gsd-*.md files at commands/ level.
// Only claude local writes gsd-*.md here; global installs don't emit commands,
// so this branch is a no-op for global (no matching files to find).
if (runtime === 'claude' && fs.existsSync(flatCommandsDir)) {
for (const file of fs.readdirSync(flatCommandsDir)) {
if (file.startsWith('gsd-') && file.endsWith('.md')) {
manifest.files['commands/' + file] = fileHash(path.join(flatCommandsDir, file));
}
}
}
if ((isOpencode || isKilo) && fs.existsSync(opencodeCommandDir)) {
for (const file of fs.readdirSync(opencodeCommandDir)) {
if (file.startsWith('gsd-') && file.endsWith('.md')) {
manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file));
}
}
}
if ((isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || isTrae || (!isOpencode && !isGemini)) && fs.existsSync(codexSkillsDir)) {
// All runtimes (including Hermes post-#947) use the canonical 'gsd-' prefix.
const skillListPrefix = 'gsd-';
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[`${codexSkillsManifestPrefix}${skillName}/${rel}`] = hash;
}
}
// For Hermes, also hash the category DESCRIPTION.md so reinstall detects drift.
if (isHermes) {
const descPath = path.join(codexSkillsDir, 'DESCRIPTION.md');
if (fs.existsSync(descPath)) {
manifest.files['skills/gsd/DESCRIPTION.md'] = fileHash(descPath);
}
}
}
if (isKimi && fs.existsSync(agentsDir)) {
const agentHashes = generateManifest(agentsDir);
for (const [rel, hash] of Object.entries(agentHashes)) {
const isRootAgent = rel === 'gsd.yaml' || rel === 'gsd.md';
const isSubagent = /^subagents\/gsd-[^/]+\.(yaml|md)$/.test(rel);
if (isRootAgent || isSubagent) {
manifest.files['agents/' + rel] = hash;
}
}
} else if (fs.existsSync(agentsDir)) {
for (const file of fs.readdirSync(agentsDir)) {
if (file.startsWith('gsd-') && (file.endsWith('.md') || file.endsWith('.toml'))) {
manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file));
}
}
}
// Track Cline directory-form artifacts in the manifest (issue #787): the
// rules file and the PreToolUse hook. (~/.agents/AGENTS.md is tracked via its
// marker block, not the per-configDir manifest, since it lives outside it.)
if (isCline) {
for (const rel of ['.clinerules/gsd.md', '.clinerules/hooks/PreToolUse']) {
const dest = path.join(configDir, rel);
if (fs.existsSync(dest)) {
manifest.files[rel] = fileHash(dest);
}
}
}
// Track hook files so saveLocalPatches() can detect user modifications
// Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline)
if (!isCodex && !isCopilot && !isCline && !isKimi) {
const hooksDir = path.join(configDir, 'hooks');
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('gsd-') && (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 /gsd-update. See #941.
for (const hook of INSTALLED_HOOK_FILES) {
const hookPath = path.join(hooksDir, hook);
if (fs.existsSync(hookPath)) {
manifest.files['hooks/' + hook] = fileHash(hookPath);
}
}
// Track hooks/lib/ helpers so saveLocalPatches() can back up user edits
// to git-cmd.js (validate-commit classifier) and gsd-graphify-rebuild.sh.
const hooksLibDir = path.join(hooksDir, 'lib');
if (fs.existsSync(hooksLibDir)) {
for (const file of fs.readdirSync(hooksLibDir)) {
if (GSD_HOOK_LIB_FILES.includes(file)) {
manifest.files['hooks/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);
}
fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
return manifest;
}
/**
* Populate gsd-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
* /gsd-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: gsd-core/, commands/gsd/, 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(), 'gsd-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);
}
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;
}
/**
* Detect user-modified GSD files by comparing against install manifest.
* Backs up modified files to gsd-local-patches/ for reapply after update.
* Also saves pristine copies (from manifest) to gsd-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 gsd-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[`gsd-core/${artifact}`];
}
}
const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
const pristineDir = path.join(configDir, 'gsd-pristine');
const modified = [];
const pristineHashes = {};
for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
const safeRef = resolveInstallRelativePath(configDir, relPath);
if (!safeRef) continue;
const { relPath: safeRelPath, fullPath } = safeRef;
if (!fs.existsSync(fullPath)) continue;
const currentHash = fileHash(fullPath);
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(fullPath, 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 /gsd-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 GSD file(s) — backed up to ' + PATCHES_DIR_NAME + '/');
for (const f of modified) {
console.log(' ' + dim + f + reset);
}
// #2998 / #3407: maintain gsd-pristine/ as the diff baseline for the
// reapply-patches verifier (#2972).
//
// #3407 root-cause fix: the prior approach (#3004 CR) wiped gsd-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: `gsd-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(gsd-pristine/X) === originalHash → correct; keep it
// - gsd-pristine/X exists but hash mismatch → stale from a previous
// buggy run (#3407); remove so verifier falls back cleanly
// - gsd-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();
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);
}
}
// File absent from gsd-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(), 'gsd-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(`gsd-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;
// `removed` = stale entries that were deleted and NOT subsequently regenerated.
// Entries that were stale-deleted but then successfully regenerated are counted
// only in `regenerated` — the counts are non-overlapping.
const removed = [...stalePaths].filter(p => !regeneratedPaths.has(p)).length;
if (preserved > 0) {
console.log(' ' + green + '✓' + reset + ' Preserved ' + cyan + 'gsd-pristine/' + reset + ' (' + preserved + ' file(s)) for three-way merge');
}
if (regenerated > 0) {
console.log(' ' + green + '✓' + reset + ' Regenerated ' + cyan + 'gsd-pristine/' + reset + ' (' + regenerated + ' file(s)) via hash-validated new-release source');
}
if (removed > 0) {
console.log(' ' + yellow + 'i' + reset + ' Removed ' + removed + ' stale gsd-pristine/ snapshot(s); regenerated ' + regenerated + ' of those — falls back to over-broad verify heuristic for the rest');
}
}
}
return modified;
}
/**
* After install, report backed-up patches for user to reapply.
*/
function reportLocalPatches(configDir, runtime = 'claude') {
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 = (runtime === 'opencode' || runtime === 'kilo' || runtime === 'copilot')
? '/gsd-update --reapply'
: runtime === 'gemini'
? '/gsd:update --reapply'
: runtime === 'codex'
? '$gsd-update --reapply'
: runtime === 'cursor'
? 'gsd-update --reapply (mention the skill name)'
: runtime === 'kimi'
? '/skill:gsd-update --reapply'
: '/gsd-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 = 'claude', options = {}) {
const isOpencode = runtime === 'opencode';
const isGemini = runtime === 'gemini';
const isKilo = runtime === 'kilo';
const isKimi = runtime === 'kimi';
const isCodex = runtime === 'codex';
const isCopilot = runtime === 'copilot';
const isAntigravity = runtime === 'antigravity';
const isCursor = runtime === 'cursor';
const isWindsurf = runtime === 'windsurf';
const isAugment = runtime === 'augment';
const isTrae = runtime === 'trae';
const isQwen = runtime === 'qwen';
const isHermes = runtime === 'hermes';
const isCodebuddy = runtime === 'codebuddy';
const isCline = runtime === 'cline';
const plan = resolveInstallPlan(runtime);
const dirName = getDirName(runtime);
const src = path.join(__dirname, '..');
if (isKimi && !isGlobal) {
console.log(` ${yellow}⚠${reset} Kimi local install is deferred for Phase 2.`);
console.log(` No .kimi-code/skills or .agents/skills project artifacts were written.`);
console.log(` Project-level Kimi install semantics remain deferred.`);
return {
runtime,
skipped: true,
reason: 'kimi_local_deferred',
configDir: null,
settingsPath: null,
settings: null,
statuslineCommand: null,
updateBannerCommand: null,
rollbackInstallerMigrations: () => {},
};
}
// Reusable helper to copy hooks/lib/ (git-cmd.js + gsd-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 GSD_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(/\{\{GSD_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.
// Cline local installs write to the project root (like Claude Code) — .clinerules
// lives at the root, not inside a .cline/ subdirectory.
// #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/ gsd artifacts are recognized but not
// auto-removed on reinstall (dual-read fallback per issue #791 spec).
const targetDir = isGlobal
? getGlobalConfigDir(runtime, explicitConfigDir)
: isCline
? process.cwd()
: path.join(process.cwd(), dirName);
const locationLabel = isGlobal
? targetDir.replace(os.homedir(), '~')
: targetDir.replace(process.cwd(), '.');
// Path prefix for file references in markdown content (e.g. gsd-tools.cjs).
// Replaces $HOME/.claude/ or ~/.claude/ so the result is <pathPrefix>gsd-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).
// gsd 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. Registry IS
// consulted so tier:core capability skills are included when registered.
const _commandsDir = path.join(src, 'commands', 'gsd');
const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir);
const _resolvedProfile = resolveProfile({
modes: [_activeProfileName],
manifest: _skillsManifest,
registry: _capabilityRegistry,
});
// Unified staging function: all profiles use stageSkillsForProfile with the
// registry-aware _resolvedProfile (ADR-857 phase 4c cutover).
function _stageSkills(commandsGsdDir) {
return stageSkillsForProfile(commandsGsdDir, _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,
isWindowsHost,
resolvedTarget,
homeDir,
});
let runtimeLabel = 'Claude Code';
if (isOpencode) runtimeLabel = 'OpenCode';
if (isGemini) runtimeLabel = 'Gemini';
if (isKilo) runtimeLabel = 'Kilo';
if (isCodex) runtimeLabel = 'Codex';
if (isCopilot) runtimeLabel = 'Copilot';
if (isAntigravity) runtimeLabel = 'Antigravity';
if (isCursor) runtimeLabel = 'Cursor';
if (isWindsurf) runtimeLabel = 'Windsurf';
if (isAugment) runtimeLabel = 'Augment';
if (isTrae) runtimeLabel = 'Trae';
if (isQwen) runtimeLabel = 'Qwen Code';
if (isHermes) runtimeLabel = 'Hermes Agent';
if (isKimi) runtimeLabel = 'Kimi';
if (isCodebuddy) runtimeLabel = 'CodeBuddy';
if (isCline) runtimeLabel = 'Cline';
console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
// Track installation failures
const failures = [];
let installerMigrationResult = null;
const rollbackInstallerMigrations = () => {
if (!installerMigrationResult || typeof installerMigrationResult.rollback !== 'function') return;
const rollback = installerMigrationResult.rollback;
installerMigrationResult = null;
rollback();
};
// Save any locally modified GSD files before they get wiped.
// The pristine context lets saveLocalPatches populate gsd-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 GSD 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-GSD state. Non-Codex runtimes skip this block.
//
// Snapshot contents:
// codexPreInstallSkillNames — Set of gsd-* skill dir names that existed
// codexPreInstallSkillContents — Map<skillName, Map<relPath, Buffer>> of
// the full file tree of each pre-existing gsd-* skill dir, so that
// overwritten dirs can be fully restored on rollback (not just removed).
// codexPreInstallAgentFiles — Set of gsd-*.{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 gsd-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 gsd-* 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 gsd-* agent file.
const codexPreInstallAgentContents = new Map();
let codexPreInstallVersionBytes = null;
if (isCodex && !isMinimalMode(_effectiveInstallMode)) {
const _preSkillsDir = path.join(targetDir, 'skills');
if (fs.existsSync(_preSkillsDir)) {
for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
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('gsd-') && (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, 'gsd-core', 'VERSION');
if (fs.existsSync(_preVersionPath)) {
try { codexPreInstallVersionBytes = fs.readFileSync(_preVersionPath); } 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 four surfaces that can be mutated before
// config.toml is touched: skills/, agents/, gsd-core/VERSION, 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, which is not yet touched at this point in the pipeline.
const _codexPreConfigRollback = !isCodex || isMinimalMode(_effectiveInstallMode) ? null : () => {
rollbackInstallerMigrations();
// skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
const _earlySkillsDir = path.join(targetDir, 'skills');
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/gsd-* — 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('gsd-') && !codexPreInstallSkillNames.has(entry.name)) {
try { fs.rmSync(path.join(_earlySkillsDir, entry.name), { recursive: true, force: true }); }
catch (_) { /* best-effort */ }
}
}
} catch (_) { /* best-effort */ }
}
// agents/gsd-* — 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/gsd-* — 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('gsd-') && (file.endsWith('.md') || file.endsWith('.toml')) && !codexPreInstallAgentFiles.has(file)) {
try { fs.unlinkSync(path.join(_earlyAgentsDir, file)); } catch (_) { /* best-effort */ }
}
}
} catch (_) { /* best-effort */ }
}
// gsd-core/VERSION
const _earlyVersionPath = path.join(targetDir, 'gsd-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 */ }
}
// 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, 'gsd-core', 'VERSION'));
try {
installerMigrationResult = runInstallerMigrations({
configDir: targetDir,
runtime,
scope: isGlobal ? 'global' : 'local',
migrations: options.installerMigrations,
baselineScan: true,
});
// #3541: non-interactive runs (typical /gsd-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 GSD 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/gsd-*` bundled hooks left
// behind by a previous version), there is no actual "user choice" to
// make — the file is a known GSD-managed artifact and the installer is
// about to write the fresh bundled version. Gating the resolver on
// `!isTTY` made `npx @opengsd/gsd-core@latest --codex` hard-abort with
// 12 blocked bundled hooks. The env-override branch (operator-supplied
// GSD_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/Kilo/Gemini/
// 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:
// Hermes: writeHermesCategoryDescription (not a layout kind)
// Cline global: skills emitted via layout; .clinerules still written below (#782)
// Cline local: no skills (only .clinerules) — falls through to cline-rules surface
// Gemini: conflict-detection logic (not expressible in layout)
// OpenCode/Kilo: copyFlattenedCommands (frontmatter conversion not in commandsKind)
// 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, including Qwen/Hermes.
// Cline global: emit skills to ~/.cline/skills/ (Cline >= v3.48.0 — #782).
const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf ||
isAugment || isTrae || isCodebuddy || isQwen || isHermes ||
isKimi ||
(runtime === 'claude' && isGlobal) ||
(isCline && isGlobal);
if (_isSkillsRuntime) {
// Layout-driven install for skills-based runtimes (full and minimal modes)
const scope = isGlobal ? 'global' : 'local';
installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile);
// #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
// gsd-* 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 GSD 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 (isCodex) {
cleanupCodexSkillMetadataSidecars(path.join(targetDir, 'skills'));
}
// #1629 Finding B: Windsurf local only — remove legacy .devin/skills/gsd-*
// dirs from pre-#1615 installs. #1615 moved Windsurf to .windsurf/workflows/
// but never cleaned up the old .devin/skills/ layout (#1085). User-owned
// content is preserved (non-gsd- dirs, gsd-dev-preferences, symlinks).
if (isWindsurf && !isGlobal) {
const removedCount = cleanupWindsurfLegacyDevinSkills(process.cwd());
if (removedCount > 0) {
console.log(` ${green}✓${reset} Removed ${removedCount} legacy .devin/skills/gsd-* dir(s) (pre-#1615 Windsurf layout)`);
}
}
// Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
if (isHermes) {
writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd'));
}
// Verify installed artifacts and report
if (isHermes) {
const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd');
if (fs.existsSync(hermesSkillsDir)) {
// Hermes layout uses prefix: 'gsd-' (#947) — skill dirs have gsd-<stem> names
const count = fs.readdirSync(hermesSkillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
if (count > 0) {
console.log(` ${green}✓${reset} Installed ${count} skills to skills/gsd/`);
} else {
failures.push('skills/gsd/*');
}
} else {
failures.push('skills/gsd/*');
}
} else if (isKimi) {
const skillsDir = path.join(targetDir, 'skills');
const rootAgentPath = path.join(targetDir, 'agents', 'gsd.yaml');
if (fs.existsSync(skillsDir)) {
const count = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
if (count > 0) {
console.log(` ${green}✓${reset} Installed ${count} Kimi skills to skills/`);
} else {
failures.push('skills/gsd-*');
}
} else {
failures.push('skills/gsd-*');
}
if (fs.existsSync(rootAgentPath)) {
console.log(` ${green}✓${reset} Generated Kimi root agent: ${rootAgentPath}`);
console.log(` Launch with: kimi --agent-file ${rootAgentPath}`);
} else {
failures.push('agents/gsd.yaml');
}
} else if (isWindsurf) {
if (isGlobal) {
console.log(` ${green}✓${reset} Windsurf global install skipped workflow artifacts (workspace-only)`);
} else {
const workflowsDir = path.join(targetDir, 'workflows');
if (fs.existsSync(workflowsDir)) {
const workflowCount = fs.readdirSync(workflowsDir)
.filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
if (workflowCount > 0) {
console.log(` ${green}✓${reset} Installed ${workflowCount} workflows to workflows/`);
} else {
failures.push('workflows/gsd-*');
}
} else {
failures.push('workflows/gsd-*');
}
}
} else {
const skillsDir = path.join(targetDir, 'skills');
if (fs.existsSync(skillsDir)) {
const count = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
if (count > 0) {
console.log(` ${green}✓${reset} Installed ${count} skills to skills/`);
} else {
failures.push('skills/gsd-*');
}
} else {
failures.push('skills/gsd-*');
}
// Augment: also verify commands/ (emitted alongside skills/)
if (isAugment) {
const commandsDir = path.join(targetDir, 'commands');
if (fs.existsSync(commandsDir)) {
const cmdCount = fs.readdirSync(commandsDir)
.filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
if (cmdCount > 0) {
console.log(` ${green}✓${reset} Installed ${cmdCount} commands to commands/`);
} else {
failures.push('commands/gsd-*');
}
} else {
failures.push('commands/gsd-*');
}
}
// Cursor only: also report the commands/ output (#785 — Cursor 1.6 slash commands)
if (isCursor) {
const commandsDir = path.join(targetDir, 'commands');
if (fs.existsSync(commandsDir)) {
const cmdCount = fs.readdirSync(commandsDir)
.filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
if (cmdCount > 0) {
console.log(` ${green}✓${reset} Installed ${cmdCount} slash commands to commands/`);
} else {
failures.push('commands/gsd-*');
}
} else {
failures.push('commands/gsd-*');
}
}
// CodeBuddy only: also report the commands/ output (#789 — slash commands)
if (isCodebuddy) {
const commandsDir = path.join(targetDir, 'commands');
if (fs.existsSync(commandsDir)) {
const cmdCount = fs.readdirSync(commandsDir)
.filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
if (cmdCount > 0) {
console.log(` ${green}✓${reset} Installed ${cmdCount} slash commands to commands/`);
} else {
failures.push('commands/gsd-*');
}
} else {
failures.push('commands/gsd-*');
}
}
}
} else if (isOpencode || isKilo) {
// OpenCode/Kilo: flat structure in command/ directory
const commandDir = path.join(targetDir, 'command');
fs.mkdirSync(commandDir, { recursive: true });
// Copy commands/gsd/*.md as command/gsd-*.md (flatten structure)
const gsdSrc = _stageSkills(_commandsDir);
copyFlattenedCommands(gsdSrc, commandDir, 'gsd', pathPrefix, runtime);
if (verifyInstalled(commandDir, 'command/gsd-*')) {
const count = fs.readdirSync(commandDir).filter(f => f.startsWith('gsd-')).length;
console.log(` ${green}✓${reset} Installed ${count} commands to command/`);
} else {
failures.push('command/gsd-*');
}
// Also emit OpenCode-family skills (skills/<name>/SKILL.md). OpenCode and
// Kilo support native, on-demand skills in addition to flat commands — see
// resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from
// the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784)
const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix);
if (_skillCount > 0) {
console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`);
} else {
failures.push('skills/gsd-*');
}
} else if (isCline) {
// Cline local install: rules-based only — commands are embedded in .clinerules (generated below).
// No skills/commands directory needed for local installs.
// Global installs are handled above by _isSkillsRuntime (#782).
console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`);
} else if (isGemini) {
// #3037: when running --local --gemini and a GSD-managed user-scope
// command directory already exists at ~/.gemini/commands/gsd/, skip
// the local copy. Gemini conflict-detects by command name across
// scopes and renames every overlapping /gsd:* command to
// /workspace.gsd:* and /user.gsd:*, breaking the documented namespace.
// The user-scope install already provides the same commands, so the
// local copy adds zero value at the cost of namespace conflicts.
//
// CR #3041 (Major): the detection must be specific to PACKAGE-MANAGED
// GSD content, not just "directory is non-empty". A user who hand-
// dropped a single override (e.g. ~/.gemini/commands/gsd/my-override
// .toml) would otherwise be unable to run a local install at all.
// Detection rule: at least 3 of the canonical GSD command files
// ('help.toml', 'progress.toml', 'new-project.toml') must be present.
// These three ship in every GSD Gemini install (minimal mode included
// — they're in the core skill set per #2790's consolidation), and 3-of-
// 3 with that specific basename set is structurally impossible to
// produce by accident.
const homeGeminiGsd = path.join(os.homedir(), '.gemini', 'commands', 'gsd');
const GSD_MANAGED_CANARIES = ['help.toml', 'progress.toml', 'new-project.toml'];
const userScopeHasGsd =
!isGlobal &&
path.resolve(targetDir) !== path.resolve(path.join(os.homedir(), '.gemini')) &&
fs.existsSync(homeGeminiGsd) &&
GSD_MANAGED_CANARIES.every((f) =>
fs.existsSync(path.join(homeGeminiGsd, f))
);
if (userScopeHasGsd) {
console.log(
` ${yellow}⚠${reset} Skipping commands/gsd/ for local install — GSD is already installed at user scope (${homeGeminiGsd}).`
);
console.log(
` Gemini conflict-detects across scopes and would rename every /gsd:* command to /workspace.gsd:* and /user.gsd:*.`
);
console.log(
` The user-scope install already provides /gsd:* commands in this project; no local copy is needed.`
);
} else {
const commandsDir = path.join(targetDir, 'commands');
fs.mkdirSync(commandsDir, { recursive: true });
const gsdSrc = _stageSkills(_commandsDir);
const gsdDest = path.join(commandsDir, 'gsd');
copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal);
if (verifyInstalled(gsdDest, 'commands/gsd')) {
console.log(` ${green}✓${reset} Installed commands/gsd`);
} else {
failures.push('commands/gsd');
}
}
} else {
// Claude Code local: flat gsd-<cmd>.md layout — Claude Code registers
// commands from .claude/commands/ using the filename stem as the command
// name, so gsd-<cmd>.md produces the /gsd-<cmd> hyphen form used everywhere
// in the framework. The old commands/gsd/<cmd>.md subdirectory layout caused
// Claude Code to namespace commands as /gsd:<cmd> (colon form). (#1367)
const commandsDir = path.join(targetDir, 'commands');
fs.mkdirSync(commandsDir, { recursive: true });
const gsdSrc = _stageSkills(_commandsDir);
const cmdNames = readGsdCommandNames();
// Remove stale gsd-*.md files before writing new ones (clean install)
if (fs.existsSync(commandsDir)) {
for (const f of fs.readdirSync(commandsDir)) {
if (f.startsWith('gsd-') && f.endsWith('.md')) {
fs.unlinkSync(path.join(commandsDir, f));
}
}
}
// Write each command as gsd-<stem>.md (flat, hyphen-prefixed)
let cmdCount = 0;
if (fs.existsSync(gsdSrc)) {
for (const entry of fs.readdirSync(gsdSrc, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3);
let content = fs.readFileSync(path.join(gsdSrc, entry.name), 'utf8');
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal, getCommitAttribution(runtime));
content = normalizeAgentBodyForRuntime(content, runtime, cmdNames);
fs.writeFileSync(path.join(commandsDir, `gsd-${stem}.md`), content);
cmdCount++;
}
}
if (cmdCount > 0) {
console.log(` ${green}✓${reset} Installed ${cmdCount} commands to commands/ (gsd-<cmd>.md flat form)`);
} else {
failures.push('commands/gsd-*');
}
// Legacy cleanup: remove old commands/gsd/ subdirectory from prior installs
// that used the namespaced layout (wrote bare-name files under commands/gsd/).
const legacyGsdDir = path.join(commandsDir, 'gsd');
if (fs.existsSync(legacyGsdDir)) {
// Preserve user-owned dev-preferences.md before wiping
const devPrefsPath = path.join(legacyGsdDir, 'dev-preferences.md');
const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null;
fs.rmSync(legacyGsdDir, { recursive: true });
console.log(` ${green}✓${reset} Removed legacy commands/gsd/ (migrated to flat gsd-<cmd>.md layout)`);
if (preservedDevPrefs) {
// Migrate dev-preferences to the new flat form
fs.writeFileSync(path.join(commandsDir, 'gsd-dev-preferences.md'), preservedDevPrefs);
console.log(` ${green}✓${reset} Migrated dev-preferences.md to commands/gsd-dev-preferences.md`);
}
}
// Clean up any stale skills/ from a previous local install
const staleSkillsDir = path.join(targetDir, 'skills');
if (fs.existsSync(staleSkillsDir)) {
const staleGsd = fs.readdirSync(staleSkillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
for (const e of staleGsd) {
fs.rmSync(path.join(staleSkillsDir, e.name), { recursive: true });
}
if (staleGsd.length > 0) {
console.log(` ${green}✓${reset} Removed ${staleGsd.length} stale GSD skill(s) from skills/`);
}
}
}
// Copy gsd-core skill with path replacement
// Preserve user-generated files before the wipe-and-copy so they survive re-install
const skillSrc = path.join(src, 'gsd-core');
const skillDest = path.join(targetDir, 'gsd-core');
const savedGsdArtifacts = preserveUserArtifacts(skillDest, USER_OWNED_ARTIFACTS);
copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal);
restoreUserArtifacts(skillDest, savedGsdArtifacts);
if (verifyInstalled(skillDest, 'gsd-core')) {
console.log(` ${green}✓${reset} Installed workflow assets`);
} else {
failures.push('gsd-core');
}
// #1629 critical fix: Windsurf workflow wrappers (convertClaudeCommandToWindsurfWorkflow)
// delegate to command bodies at <targetDir>/gsd-core/commands/gsd/${stem}.md via a
// hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites
// to the install target. The source gsd-core/ dir does NOT ship with commands/ —
// the canonical command source lives at the package root (commands/gsd/). Without
// this copy, every /gsd-* workflow in Cascade references a missing file and the LLM
// cannot execute the command body. Surfaced by the #1629 regression test after the
// original adversarial review of #1622 missed it.
if (isWindsurf && !isGlobal) {
const commandsSrc = path.join(src, 'commands', 'gsd');
const commandsDest = path.join(skillDest, 'commands', 'gsd');
if (fs.existsSync(commandsSrc)) {
copyWithPathReplacement(commandsSrc, commandsDest, pathPrefix, runtime, true, isGlobal);
console.log(` ${green}✓${reset} Installed command bodies to gsd-core/commands/gsd/ (workflow delegation targets)`);
}
}
// Copy shared manifests into the gsd-core payload
// at the co-located path that CJS modules resolve first:
// gsd-core/bin/shared/*.json
//
// This source now lives under gsd-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, 'gsd-core', 'bin', 'shared', fileName);
const sharedDest = path.join(skillDest, 'bin', 'shared', fileName);
const displayPath = `gsd-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(`gsd-core/bin/shared/${fileName} (source missing)`);
}
}
// Copy agents to agents directory.
// Skipped under --minimal: gsd-* subagent descriptions are eagerly loaded
// into the runtime's Agent tool schema, costing ~6k tokens per turn even
// when no GSD workflow is active. See open-gsd/gsd-core#2762.
// Note: agentsSrc is declared as let before the enclosing try block so it
// is accessible by installCodexConfig() in the Codex config section below.
agentsSrc = _stageAgents(path.join(src, 'agents'));
const agentsDest = path.join(targetDir, 'agents');
// Always remove stale gsd-* agents first so re-installing with
// `--minimal` actually shrinks a previously-full install.
// For Codex this also covers per-agent `.toml` files alongside the `.md`
// sources so a full → minimal switch doesn't leave stale registrations.
if (fs.existsSync(agentsDest)) {
for (const file of fs.readdirSync(agentsDest)) {
if (
file.startsWith('gsd-') &&
(file.endsWith('.md') || (isCodex && file.endsWith('.toml')))
) {
fs.unlinkSync(path.join(agentsDest, file));
}
}
}
if (isKimi) {
console.log(` ${dim}↳${reset} Kimi custom agent YAML/prompt artifacts were installed via runtime artifact layout`);
} else if (isMinimalMode(_effectiveInstallMode)) {
// Codex registers agents in `config.toml` via `[agents.gsd-*]` sections.
// Without stripping them here, a full → minimal reinstall would leave the
// runtime advertising the old full agent surface even though the agent
// files are gone. Reuse the same helper that powers `--uninstall`.
if (isCodex) {
const codexConfigPath = path.join(targetDir, 'config.toml');
if (fs.existsSync(codexConfigPath)) {
const existing = fs.readFileSync(codexConfigPath, 'utf8');
const cleaned = stripGsdFromCodexConfig(existing);
if (cleaned === null) {
fs.unlinkSync(codexConfigPath);
} else if (cleaned !== existing) {
fs.writeFileSync(codexConfigPath, cleaned);
}
}
}
console.log(` ${dim}↳${reset} Skipping agents (minimal install — run \`gsd update\` without \`--minimal\` to add full surface)`);
} else if (fs.existsSync(agentsSrc)) {
fs.mkdirSync(agentsDest, { recursive: true });
// Copy new agents
const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true });
for (const entry of agentEntries) {
if (entry.isFile() && entry.name.endsWith('.md')) {
let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
// Replace ~/.claude/ and $HOME/.claude/ as they are the source of truth in the repo
const dirRegex = /~\/\.claude\//g;
const homeDirRegex = /\$HOME\/\.claude\//g;
const bareDirRegex = /~\/\.claude\b/g;
const bareHomeDirRegex = /\$HOME\/\.claude\b/g;
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
if (!isCopilot && !isAntigravity) {
content = content.replace(dirRegex, pathPrefix);
content = content.replace(homeDirRegex, pathPrefix);
content = content.replace(bareDirRegex, normalizedPathPrefix);
content = content.replace(bareHomeDirRegex, normalizedPathPrefix);
}
content = processAttribution(content, getCommitAttribution(runtime));
// Convert frontmatter for runtime compatibility (agents need different handling)
if (isOpencode) {
// Resolve per-agent model for OpenCode agents.
// Precedence: model_overrides[agent] > model_profile_overrides.opencode.<tier> > omit.
// model_overrides (#2256): explicit per-agent override, highest precedence.
// model_profile_overrides (#2794): tier-based runtime resolver, same parity as Codex.
const _ocAgentName = entry.name.replace(/\.md$/, '');
const _ocModelOverrides = readGsdEffectiveModelOverrides(targetDir);
let _ocModelOverride = _ocModelOverrides?.[_ocAgentName] || null;
if (!_ocModelOverride) {
// Fall back to tier-based resolution via model_profile_overrides.opencode.<tier>.
const _ocRuntimeResolver = readGsdRuntimeProfileResolver(targetDir);
if (_ocRuntimeResolver) {
const _ocEntry = _ocRuntimeResolver.resolve(_ocAgentName);
if (_ocEntry?.model) {
_ocModelOverride = _ocEntry.model;
}
}
}
content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride });
} else if (isKilo) {
content = convertClaudeToKiloFrontmatter(content, { isAgent: true });
} else if (isGemini) {
content = convertClaudeToGeminiAgent(content);
} else if (isCodex) {
content = convertClaudeAgentToCodexAgent(content);
} else if (isCopilot) {
content = convertClaudeAgentToCopilotAgent(content, isGlobal);
} else if (isAntigravity) {
content = convertClaudeAgentToAntigravityAgent(content, isGlobal);
} else if (isCursor) {
content = convertClaudeAgentToCursorAgent(content);
} else if (isWindsurf) {
content = convertClaudeAgentToWindsurfAgent(content);
} else if (isAugment) {
content = convertClaudeAgentToAugmentAgent(content);
} else if (isTrae) {
content = convertClaudeAgentToTraeAgent(content);
} else if (isCodebuddy) {
content = convertClaudeAgentToCodebuddyAgent(content);
} else if (isCline) {
content = convertClaudeAgentToClineAgent(content);
} else if (isQwen) {
content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
content = content.replace(/\.claude\//g, '.qwen/');
} else if (isHermes) {
content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
content = content.replace(/\.claude\//g, '.hermes/');
}
// #443 — Inject `effort:` into the Claude .md frontmatter ONLY.
// Gemini/OpenCode/Qwen/Hermes also produce .md files but break on
// unknown frontmatter keys (the repo bans skills:/permissionMode: for
// the same reason — see tests/agent-frontmatter.test.cjs).
// Claude Code reads per-subagent `effort:` frontmatter (anthropics/claude-code #31536).
// Injection is per-runtime at install time because the canonical source
// agents/*.md must stay Gemini-safe (no effort: key in source).
if (runtime === 'claude') {
const _effortCfg = readGsdEffectiveEffortConfig(targetDir);
const _agentName = entry.name.replace(/\.md$/, '');
const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName);
const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime('claude', _universalEffort).value;
content = injectEffortFrontmatter(content, _renderedEffort);
const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName];
if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools);
}
// #3677 — normalize retired `/gsd:<cmd>` colon refs in the agent body
// to the canonical hyphen form `/gsd-<cmd>` for hyphen-`name:`
// runtimes (claude / qwen / hermes). Self-converting runtimes and
// Gemini are skipped by the predicate — see
// shouldNormalizeHyphenNamespaceInAgentBody above. Mirrors the
// SKILL.md-body fix shipped via #3629.
content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames());
const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name;
fs.writeFileSync(path.join(agentsDest, destName), content);
}
}
if (verifyInstalled(agentsDest, 'agents')) {
console.log(` ${green}✓${reset} Installed agents`);
} else {
failures.push('agents');
}
}
// Copy CHANGELOG.md
const changelogSrc = path.join(src, 'CHANGELOG.md');
const changelogDest = path.join(targetDir, 'gsd-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, 'gsd-core', 'VERSION');
fs.writeFileSync(versionDest, pkg.version);
if (verifyFileInstalled(versionDest, 'VERSION')) {
console.log(` ${green}✓${reset} Wrote VERSION (${pkg.version})`);
} else {
failures.push('VERSION');
}
if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi) {
// Write package.json to force CommonJS mode for GSD scripts
// Prevents "require is not defined" errors when project has "type": "module"
// Node.js walks up looking for package.json - this stops inheritance from project
const pkgJsonDest = path.join(targetDir, 'package.json');
fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
console.log(` ${green}✓${reset} Wrote package.json (CommonJS mode)`);
// 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(targetDir, 'hooks');
fs.mkdirSync(hooksDest, { recursive: true });
const hookEntries = fs.readdirSync(hooksSrc);
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)}/`);
if (isQwen) {
content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
}
if (isHermes) {
content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
}
// #376: rewrite gsd: → gsd- for hyphen-namespace runtimes
if (shouldNormalizeHyphenNamespaceInAgentBody(runtime)) {
content = content.replace(/gsd:/gi, 'gsd-');
}
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
} else {
// non-.js: .sh hooks need {{GSD_VERSION}} stamped; others are copied as-is
if (entry.endsWith('.sh')) {
let content = fs.readFileSync(srcFile, 'utf8');
content = content.replace(/\{\{GSD_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/gsd-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(/\{\{GSD_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 hooks (bundled)`);
// Warn if expected community .sh hooks are missing (non-fatal)
const expectedShHooks = ['gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh', 'gsd-graphify-update.sh'];
for (const sh of expectedShHooks) {
if (!fs.existsSync(path.join(hooksDest, sh))) {
console.warn(` ${yellow}⚠${reset} Missing expected hook: ${sh}`);
}
}
} else {
failures.push('hooks');
}
}
}
// Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
// Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared hooks/lib/ helpers
// (Cursor uses standalone .js hook scripts registered via hooks.json; Codex uses
// hooks.json directly; the others skip hooks entirely), so they must not receive
// the hooks/lib/ helpers — otherwise the Codex comment downstream
// ("we deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice.
const hooksLibSrc = path.join(src, 'hooks', 'lib');
if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi && fs.existsSync(hooksLibSrc)) {
const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
fs.mkdirSync(hooksLibDest, { recursive: true });
copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
}
// Install scripts/changeset/ and scripts/lib/ into <configDir>/scripts/
// so that `node "$GSD_DIR/scripts/changeset/cli.cjs"` resolves at runtime.
//
// The changeset CLI (scripts/changeset/cli.cjs) is invoked by the update
// workflow (gsd-core/workflows/update.md) to extract changelog ranges for
// the /gsd-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
// - ../../gsd-core/bin/lib/semver-compare.cjs (already installed under gsd-core/)
// - ../../gsd-core/bin/lib/package-identity.cjs (already installed under gsd-core/)
//
// All runtimes that use the update workflow need this, so we copy unconditionally
// (same scope as gsd-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 gsd-core/bin/lib/command-roster.cjs
// at load time via require('../../../scripts/fix-slash-commands.cjs'). Without this file
// every gsd-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');
}
}
}
// Remove legacy get-shit-done-cc artifacts and stale update caches (#607).
// cleanupLegacyGsdCc 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.
try {
cleanupLegacyGsdCc({ dryRun: false });
} 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 });
console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
// Report any backed-up local patches
reportLocalPatches(targetDir, runtime);
// Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped)
if (runtime !== 'claude') {
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) {
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.
rollbackInstallerMigrations();
if (_codexPreConfigRollback) {
_codexPreConfigRollback();
}
throw _earlyInstallErr;
}
if (plan.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
// Capture pre-install snapshots before ANY GSD 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/gsd-* — restore pre-existing dirs from content snapshot; remove
// newly-created dirs (i.e. those not in the pre-install Set)
// agents/gsd-* — restore pre-existing files from content snapshot; remove
// newly-created files
// gsd-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-gsd-* 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/gsd-*
// • 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 gsd-* 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 = path.join(targetDir, 'skills');
// 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 gsd-* 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('gsd-')) 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/gsd-*.{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 gsd-* agent files (not in the pre-install snapshot).
if (fs.existsSync(_rollbackAgentsDir)) {
try {
for (const file of fs.readdirSync(_rollbackAgentsDir)) {
if (!file.startsWith('gsd-') || (!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. gsd-core/VERSION
const _rollbackVersionPath = path.join(targetDir, 'gsd-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 */ }
}
// 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)) {
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/gsd-*.toml because those files did not yet exist.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
} 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 gsd-context-monitor.js for the new SubagentStart/Stop/PostToolUse events.
// We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex
// in this change (graphify auto-update support for Codex is out of scope for #3579).
const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js', 'gsd-context-monitor.js'];
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);
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(/\{\{GSD_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 {{GSD_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 gsd-graphify-update.sh for Codex).
let content = fs.readFileSync(srcFile, 'utf8');
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
}
}
console.log(` ${green}✓${reset} Installed hooks (Codex)`);
}
// 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-GSD 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 GSD-managed hook blocks BEFORE migration so the migration
// only touches user-authored hooks, not GSD-owned stale entries. Running
// strip after migration causes Shape 1 (legacy gsd-update-check filename)
// to be converted by migration before the strip regex can match it (#2698).
//
// Historical shapes stripped, in order:
// Shape 1 — legacy gsd-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 = stripStaleGsdHookBlocks(configContent);
// Migrate legacy [hooks] map format and flat [[hooks]] AoT entries to the
// namespaced [[hooks.<EVENT>]] form after stripping GSD-managed stale blocks.
// Running migration after strip ensures only user-authored hooks are migrated
// (#2698 regression: migration before strip converts stale GSD 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);
// GSD-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', 'gsd-check-update.js');
if (!fs.existsSync(checkUpdateFile)) {
console.warn(` ${yellow}⚠${reset} Skipped Codex SessionStart hook registration — gsd-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,
});
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)`);
}
}
// ── Codex extended hook events (#772) ────────────────────────────────
// Codex CLI stabilised a full hook-event set in rust-v0.137.0. Register
// three new high-value lifecycle events — all routed through
// gsd-context-monitor.js so context-headroom warnings surface at:
// SubagentStart — subagent session open (environment / agent-name aware)
// Stop — model stop / session final-response moment
// PostToolUse — after each tool invocation (mirrors Claude baseline)
//
// Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless
// tool_name is Write|Edit (PreToolUse payload shape), so it would be a
// silent no-op for the UserPromptSubmit payload. Registration deferred
// to a follow-on issue.
//
// Guard: only register when the context-monitor file exists and the node
// runner is available — same guards as the SessionStart path above.
const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
if (codexNodeRunner && fs.existsSync(contextMonitorFile)) {
for (const codexEvent of ['SubagentStart', 'Stop', 'PostToolUse']) {
const eventWrite = ensureCodexHooksJsonEvent(targetDir, codexEvent, {
absoluteRunner: codexNodeRunner,
platform: process.platform,
});
if (eventWrite.wrote) {
console.log(` ${green}✓${reset} Configured Codex hooks (${codexEvent} via hooks.json)`);
} else if (eventWrite.changed) {
console.log(` ${green}✓${reset} Verified Codex hooks (${codexEvent} via hooks.json)`);
}
}
} else if (!codexNodeRunner) {
console.warn(` ${yellow}⚠${reset} Skipped Codex SubagentStart/Stop/PostToolUse hook registration — Node runner unavailable.`);
}
// ── 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();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (plan.installSurface === 'copilot-instructions') {
// Generate copilot-instructions.md
const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
if (fs.existsSync(templatePath)) {
const template = fs.readFileSync(templatePath, 'utf8');
mergeCopilotInstructions(instructionsPath, template);
console.log(` ${green}✓${reset} Generated copilot-instructions.md`);
// #786: also emit AGENTS.md, which Copilot CLI reads as primary
// instructions from the repository root. AGENTS.md is a repo-root concept
// (no documented user-scope home), so emit it only for local installs;
// global scope is already covered by ~/.copilot/copilot-instructions.md.
if (!isGlobal) {
const agentsMdPath = path.join(process.cwd(), 'AGENTS.md');
mergeCopilotInstructions(agentsMdPath, template);
console.log(` ${green}✓${reset} Generated AGENTS.md`);
}
}
// #786: emit a self-contained Copilot lifecycle hook (sessionStart). Copilot
// command hooks run inline bash/powershell, so this needs no separate hook
// script and cannot dangle. Repo scope → .github/hooks/, user → ~/.copilot/hooks/.
// The hook is a required install artifact, so a write failure is fatal (it
// propagates) rather than silently producing a "successful" install missing
// the feature.
writeCopilotHookConfig(targetDir);
console.log(` ${green}✓${reset} Configured Copilot lifecycle hook (sessionStart)`);
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (plan.installSurface === 'cursor-hooks-json') {
// #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse.
// Hook scripts are copied to <targetDir>/hooks/ and referenced by hooks.json.
const cursorHookResult = writeCursorHooksJson(targetDir, src, {});
if (cursorHookResult.changed) {
console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse)`);
} else {
console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`);
}
// Re-run the manifest pass so the hook scripts + hooks.json are hash-tracked.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (plan.installSurface === 'profile-marker-only') {
// Windsurf/Trae/Kimi use artifact-only surfaces — no config.toml or settings.json hooks needed.
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (plan.installSurface === 'cline-rules') {
// Cline uses the `.clinerules/` directory form (issue #787): GSD rules live
// at .clinerules/gsd.md and a PreToolUse lifecycle hook at
// .clinerules/hooks/PreToolUse. Global installs also get ~/.agents/AGENTS.md.
writeClineArtifacts(targetDir, isGlobal);
// Re-run the manifest pass: these artifacts are written *after* the earlier
// writeManifest() call, so a second pass is needed to hash-track them.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
// 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∈{gemini,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.
const isLocalClaude = (runtime === 'claude' && !isGlobal);
const settingsFileName = isLocalClaude ? 'settings.local.json' : 'settings.json';
const settingsPath = path.join(targetDir, settingsFileName);
// #338 migration: if a prior local Claude install wrote GSD-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 hasGsdHooks = 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 hasGsdStatusline = sharedRaw.statusLine && sharedRaw.statusLine.command &&
isManagedHookCommand(sharedRaw.statusLine.command, { surface: 'settings-json' });
if (hasGsdHooks || hasGsdStatusline) {
// Merge GSD entries into settings.local.json
const localRaw = readSettings(settingsPath) || {};
if (hasGsdStatusline && !localRaw.statusLine) {
localRaw.statusLine = sharedRaw.statusLine;
}
if (hasGsdHooks) {
if (!localRaw.hooks) localRaw.hooks = {};
for (const [eventName, entries] of Object.entries(sharedRaw.hooks || {})) {
if (!Array.isArray(entries)) continue;
const gsdEntries = 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 (gsdEntries.length > 0) {
if (!localRaw.hooks[eventName]) localRaw.hooks[eventName] = [];
// Only merge entries not already present in local
for (const entry of gsdEntries) {
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 GSD entries from shared settings.json
if (hasGsdStatusline) {
delete sharedRaw.statusLine;
}
if (hasGsdHooks) {
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 GSD 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();
return;
}
const settings = validateHookFields(cleanupOrphanedHooks(rawSettings));
// #3002 CR: rewrite legacy `node .../gsd-*.js` command strings carried over
// from pre-#2979 installs to use the absolute node binary path. Without this,
// existing managed hook entries stay bare-`node`-prefixed across reinstalls
// and remain broken under GUI/minimal-PATH runtimes.
const settingsRunner = resolveNodeRunner();
if (settingsRunner && rewriteLegacyManagedNodeHookCommands(settings, settingsRunner, { platform: process.platform, runtime })) {
console.log(` ${green}✓${reset} Rewrote legacy bare-node managed-hook commands to absolute path (#2979)`);
}
// Local installs anchor hook paths so they resolve regardless of cwd (#1906).
// Claude Code sets $CLAUDE_PROJECT_DIR; Gemini/Antigravity do not — and on
// Windows their own substitution logic doubles the path (#2557). Those runtimes
// run project hooks with the project dir as cwd, so bare relative paths work.
const localPrefix = projectLocalHookPrefix({ runtime, dirName });
const hookOpts = { portableHooks: hasPortableHooks, runtime };
// #2979: local-install hook commands also use the absolute node path so
// GUI/minimal-PATH runtimes can resolve them. Bare `node` fails when the
// host launches the runtime with a stripped PATH (Finder/Antigravity/etc).
const localNodeRunner = resolveNodeRunner();
const localBashRunner = resolveBashRunner({ platform: process.platform });
// If we cannot resolve an absolute node path 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
: projectShellCommandText({
runnerToken: localNodeRunner,
argTokens: [`${localPrefix}/hooks/${hookFile}`],
runtime,
platform: process.platform,
});
const localShellCmd = (hookFile) => buildLocalShellHookCommand({
localPrefix,
hookFile,
bashRunner: localBashRunner,
runtime,
platform: process.platform,
});
const statuslineCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-statusline.js', hookOpts)
: localCmd('gsd-statusline.js');
const updateCheckCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-check-update.js', hookOpts)
: localCmd('gsd-check-update.js');
const contextMonitorCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-context-monitor.js', hookOpts)
: localCmd('gsd-context-monitor.js');
const promptGuardCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-prompt-guard.js', hookOpts)
: localCmd('gsd-prompt-guard.js');
const readGuardCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-read-guard.js', hookOpts)
: localCmd('gsd-read-guard.js');
const readInjectionScannerCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-read-injection-scanner.js', hookOpts)
: localCmd('gsd-read-injection-scanner.js');
const configReloadCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-config-reload.js', hookOpts)
: localCmd('gsd-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.`);
}
// Enable experimental agents for Gemini CLI (required for custom sub-agents)
if (isGemini) {
if (!settings.experimental) {
settings.experimental = {};
}
if (!settings.experimental.enableAgents) {
settings.experimental.enableAgents = true;
console.log(` ${green}✓${reset} Enabled experimental agents`);
}
}
// Register all GSD-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,
});
// ── Gemini hooksConfig.enabled check (#776) ───────────────────────────────
// Detect `hooksConfig.enabled: false` in the already-loaded settings object
// and emit a clear warning. When this field is false the Gemini CLI silently
// disables ALL hook execution — gsd hooks are registered but will never run.
// The check is read-only (warning only; we do not mutate hooksConfig).
// Note: we use the in-memory `settings` object (already read from disk and
// cleaned up by validateHookFields/cleanupOrphanedHooks above) rather than
// re-reading settings.json, avoiding a TOCTOU window between the two reads.
if (isGemini && settings && settings.hooksConfig && settings.hooksConfig.enabled === false) {
console.warn(
` ${yellow}⚠${reset} Warning: hooksConfig.enabled is false in your Gemini settings.json.\n` +
` gsd-core hooks are registered but will NOT run until you set\n` +
` hooksConfig.enabled: true in ${path.join(targetDir, 'settings.json')}.`
);
}
// ── end hooksConfig.enabled check ────────────────────────────────────────
// 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 = isOpencode || isKilo
? null
: (isGlobal
? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts)
: localCmd('gsd-update-banner.js'));
// #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 readGsdRuntimeProfileResolver). 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,
runtime,
configDir: targetDir,
rollbackInstallerMigrations,
};
}
/**
* Apply statusline config, then print completion message
*/
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null, bannerOpts = {}) {
const isOpencode = runtime === 'opencode';
const isKilo = runtime === 'kilo';
const isCodex = runtime === 'codex';
const isCopilot = runtime === 'copilot';
const isCursor = runtime === 'cursor';
const isWindsurf = runtime === 'windsurf';
const isTrae = runtime === 'trae';
const isCline = runtime === 'cline';
const plan = resolveInstallPlan(runtime);
if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
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/kilo/codex/cursor/windsurf/trae/
// cline either lack the surface or use a different config schema.
const { shouldInstallBanner, bannerCommand } = bannerOpts;
if (shouldInstallBanner && settings && plan.writesSharedSettings && !isOpencode) {
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, 'gsd-update-banner'))
);
const bannerHookFile = configDir ? path.join(configDir, 'hooks', 'gsd-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 — gsd-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 GSD-owned entries non-destructively (preserves existing user permissions).
// Scoped to Claude only: gemini/antigravity/qwen/hermes/codebuddy also write
// settings.json but use different runtimes and do not use these permission strings.
if (runtime === '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.GSD_TEST_MODE) {
configureOpencodePermissions(isGlobal, configDir);
}
// Configure Kilo permissions
if (plan.finishPermissionWriter === 'kilo') {
configureKiloPermissions(isGlobal, configDir);
}
// For non-Claude runtimes, DEFAULT resolve_model_ids to "omit" in ~/.gsd/defaults.json
// when it is absent or falsy, so resolveModelInternal() returns '' instead of Claude
// aliases (opus/sonnet/haiku) the runtime can't resolve. An explicit `true` opt-in
// (resolveModelInternal returns full materialized model IDs) MUST be preserved —
// rewriting it to "omit" would make generated agent manifests inherit the active
// chat model instead of pinning the resolved model. See #1156 (default-to-omit
// intent) and #1569 (preserve explicit true). Guard matches the #130-class pattern
// on configureOpencodePermissions above.
if (runtime !== 'claude' && !process.env.GSD_TEST_MODE) {
const gsdDir = path.join(os.homedir(), '.gsd');
const defaultsPath = path.join(gsdDir, 'defaults.json');
try {
fs.mkdirSync(gsdDir, { recursive: true });
let defaults = {};
try { defaults = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')); } catch { /* new file */ }
// Recover a malformed (valid-JSON-but-non-object) defaults.json to a fresh object so
// the write below succeeds and the file is no longer broken. Without this, `null` /
// `[]` / a number / a string bypass the parse catch and either throw a TypeError on
// property access (swallowed by the outer try/catch, leaving the file broken) or get
// a property set that won't round-trip through JSON.stringify. (#1657)
if (defaults === null || typeof defaults !== 'object' || Array.isArray(defaults)) {
defaults = {};
}
// Three-valued domain: false/absent → aliases; true → full IDs; "omit" → ''.
// Honor ONLY an explicit canonical `true` opt-in (full model IDs) and an existing
// "omit"; default everything else — absent, falsy, OR any non-canonical value — to
// "omit", the safe non-Claude default. Allowlist-based so malformed values
// (0, "", "yes", {}, …) don't leak Claude aliases the runtime can't resolve (#1569).
const existing = defaults.resolve_model_ids;
const shouldDefaultToOmit = existing !== true && existing !== 'omit';
if (shouldDefaultToOmit) {
defaults.resolve_model_ids = 'omit';
fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
console.log(` ${green}✓${reset} Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
}
} catch (e) {
console.log(` ${yellow}⚠${reset} Could not write ~/.gsd/defaults.json: ${e.message}`);
}
}
let program = 'Claude Code';
if (runtime === 'opencode') program = 'OpenCode';
if (runtime === 'gemini') program = 'Gemini';
if (runtime === 'kilo') program = 'Kilo';
if (runtime === 'codex') program = 'Codex';
if (runtime === 'copilot') program = 'Copilot';
if (runtime === 'antigravity') program = 'Antigravity';
if (runtime === 'cursor') program = 'Cursor';
if (runtime === 'windsurf') program = 'Windsurf';
if (runtime === 'augment') program = 'Augment';
if (runtime === 'trae') program = 'Trae';
if (runtime === 'cline') program = 'Cline';
if (runtime === 'qwen') program = 'Qwen Code';
if (runtime === 'hermes') program = 'Hermes Agent';
if (runtime === 'kimi') program = 'Kimi CLI';
let command = '/gsd-new-project';
if (runtime === 'opencode') command = '/gsd-new-project';
if (runtime === 'kilo') command = '/gsd-new-project';
if (runtime === 'gemini') command = '/gsd:new-project';
if (runtime === 'codex') command = '$gsd-new-project';
if (runtime === 'copilot') command = '/gsd-new-project';
if (runtime === 'antigravity') command = '/gsd-new-project';
if (runtime === 'cursor') command = 'gsd-new-project (mention the skill name)';
if (runtime === 'windsurf') command = '/gsd-new-project';
if (runtime === 'augment') command = '/gsd-new-project';
if (runtime === 'trae') command = '/gsd-new-project';
if (runtime === 'cline') command = '/gsd-new-project';
if (runtime === 'qwen') command = '/gsd-new-project';
if (runtime === 'hermes') command = '/gsd-new-project';
if (runtime === 'kimi') command = '/skill:gsd-new-project';
// 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 (runtime === 'claude' && isGlobal) {
console.log(`
${green}Done!${reset} Restart ${program}, then in any directory either type ${cyan}${command}${reset} or ask Claude to run the ${cyan}gsd-new-project${reset} skill.
${cyan}Join the community:${reset} https://discord.gg/mYgfVNfA2r
`);
return;
}
if (runtime === 'kimi') {
const agentPath = configDir ? path.join(configDir, 'agents', 'gsd.yaml') : 'agents/gsd.yaml';
console.log(`
${green}Done!${reset} Start ${program} with ${cyan}kimi --agent-file ${agentPath}${reset}, then run ${cyan}${command}${reset}.
${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}
GSD 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 GSD 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': 'augment',
'4': 'cline',
'5': 'codebuddy',
'6': 'codex',
'7': 'copilot',
'8': 'cursor',
'9': 'gemini',
'10': 'hermes',
'11': 'kimi',
'12': 'kilo',
'13': 'opencode',
'14': 'qwen',
'15': 'trae',
'16': 'windsurf'
};
const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'hermes', 'kimi', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf'];
const ALL_RUNTIMES_OPTION = '17';
/**
* 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}) Augment ${dim}(~/.augment)${reset}
${cyan}4${reset}) Cline ${dim}(.clinerules)${reset}
${cyan}5${reset}) CodeBuddy ${dim}(~/.codebuddy)${reset}
${cyan}6${reset}) Codex ${dim}(~/.codex)${reset}
${cyan}7${reset}) Copilot ${dim}(~/.copilot)${reset}
${cyan}8${reset}) Cursor ${dim}(~/.cursor)${reset}
${cyan}9${reset}) Gemini ${dim}(~/.gemini)${reset}
${cyan}10${reset}) Hermes Agent ${dim}(~/.hermes)${reset}
${cyan}11${reset}) Kimi ${dim}(~/.config/agents, then ~/.agents if existing)${reset}
${cyan}12${reset}) Kilo ${dim}(~/.config/kilo)${reset}
${cyan}13${reset}) OpenCode ${dim}(~/.config/opencode)${reset}
${cyan}14${reset}) Qwen Code ${dim}(~/.qwen)${reset}
${cyan}15${reset}) Trae ${dim}(~/.trae)${reset}
${cyan}16${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset}
${cyan}17${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 17 ("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 : ['claude'];
}
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: GSD update banner${reset}
Without GSD's statusline, update notifications won't be visible. You can
install a SessionStart banner that surfaces a one-line message when a new
GSD 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 GSD'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.`);
console.log(` Add it with one of:`);
const projected = projectPersistentPathExportActions({
targetDir: globalBin,
platform: process.platform,
});
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',
'.kilo',
'.config/kilo',
'.codex',
'.copilot',
'.github', // copilot local form
'.agents', // antigravity local form (canonical, #791)
'.agent', // antigravity local form (legacy, backward-compat)
'.cursor',
'.devin', // windsurf local form (canonical, #1085; Devin Desktop preferred dir)
'.windsurf', // windsurf local form (legacy, backward-compat with pre-#1085 installs)
'.codeium/windsurf',
'.augment',
'.trae',
'.qwen',
'.hermes',
'.codebuddy',
'.cline',
];
/**
* 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 cleanupLegacyGsdCc({ homeDir = os.homedir(), 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.
const seen = new Set();
const configDirs = [];
for (const name of _LEGACY_SCAN_SUBDIR_NAMES) {
const candidate = path.join(homeDir, name);
if (!seen.has(candidate) && fs.existsSync(candidate)) {
seen.add(candidate);
configDirs.push(candidate);
}
}
// planLegacyCleanup scans each configDir and already includes the legacy
// shared cache (gsd-update-check.json) as a plan entry.
const plan = planLegacyCleanup(configDirs, { homeDir });
// 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).
const perPkgCacheFile = path.join(homeDir, '.cache', 'gsd', 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 GSD for all selected runtimes
*/
function installAllRuntimes(runtimes, isGlobal, isInteractive) {
const results = [];
const installerMigrations = discoverInstallerMigrations({
migrationsDir: path.join(_gsdLibDir, 'installer-migrations'),
});
const rollbackFinalizedInstallerMigrations = (error) => {
const rollbackFailures = [];
for (const result of [...results].reverse()) {
if (!result || typeof result.rollbackInstallerMigrations !== 'function') continue;
try {
result.rollbackInstallerMigrations();
} 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 = ['claude', 'gemini'];
const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
const finalize = (shouldInstallStatusline, shouldInstallBanner) => {
try {
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 }
);
}
};
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/Copilot/Cursor/Windsurf/
// Trae/Cline-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);
});
};
if (primaryStatuslineResult) {
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 !GSD_TEST_MODE, as before.
module.exports = {
// #3677 — hyphen-namespace normalization seam for agent bodies
shouldNormalizeHyphenNamespaceInAgentBody,
normalizeAgentBodyForRuntime,
yamlIdentifier,
computePathPrefix,
applyRuntimeContentRewritesInPlace,
getCodexSkillAdapterHeader,
convertClaudeCommandToCursorSkill,
convertClaudeCommandToCursorCommand,
convertClaudeAgentToCursorAgent,
convertClaudeToGeminiMarkdown,
convertSlashCommandsToGeminiMentions,
_resetGsdCommandRoster,
convertClaudeToGeminiAgent,
convertClaudeAgentToCodexAgent,
generateCodexAgentToml,
cleanupCodexSkillMetadataSidecars,
cleanupWindsurfLegacyDevinSkills,
generateCodexConfigBlock,
stripGsdFromCodexConfig,
migrateCodexHooksMapFormat,
stripStaleGsdHookBlocks,
hasUserNamespacedAotHooks,
parseTomlToObject,
validateCodexConfigSchema,
mergeCodexConfig,
installCodexConfig,
readGsdRuntimeProfileResolver,
readGsdEffectiveModelOverrides,
readGsdEffectiveEffortConfig,
resolveInstallTimeEffort,
injectEffortFrontmatter,
get _GSD_EFFORT_MANIFEST_TIER_DEFAULTS() { return _getGsdEffortCatalog().EFFORT_MANIFEST_TIER_DEFAULTS; },
get _GSD_EFFORT_MANIFEST_DEFAULT() { return _getGsdEffortCatalog().EFFORT_MANIFEST_DEFAULT; },
install,
installAllRuntimes,
uninstall,
convertSlashCommandsToCodexSkillMentions,
convertClaudeCommandToCodexSkill,
convertClaudeCommandToKimiSkill,
convertKimiToolName,
mapClaudeToolsToKimiTools,
buildKimiAgentArtifacts,
convertClaudeToOpencodeFrontmatter,
convertClaudeToKiloFrontmatter,
convertClaudeCommandToOpencodeSkill,
convertClaudeCommandToKiloSkill,
configureOpencodePermissions,
neutralizeAgentReferences,
// #768 — Claude Code permissions pre-population
mergeClaudePermissions,
GSD_CLAUDE_ALLOW_PERMISSIONS,
GSD_CLAUDE_DENY_PERMISSIONS,
GSD_CODEX_MARKER,
CODEX_AGENT_SANDBOX,
getDirName,
getGlobalDir,
getConfigDirFromHome,
resolveKiloConfigPath,
configureKiloPermissions,
claudeToCopilotTools,
convertCopilotToolName,
convertClaudeToCopilotContent,
convertClaudeCommandToCopilotSkill,
convertClaudeAgentToCopilotAgent,
GSD_COPILOT_INSTRUCTIONS_MARKER,
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
mergeCopilotInstructions,
stripGsdFromCopilotInstructions,
GSD_COPILOT_HOOK_FILE,
buildCopilotHookConfig,
writeCopilotHookConfig,
convertClaudeToAntigravityContent,
convertClaudeCommandToAntigravitySkill,
convertClaudeAgentToAntigravityAgent,
convertClaudeCommandToClaudeSkill,
skillFrontmatterName,
convertClaudeToWindsurfMarkdown,
convertClaudeCommandToWindsurfSkill,
convertClaudeCommandToWindsurfWorkflow,
convertClaudeAgentToWindsurfAgent,
convertClaudeToAugmentMarkdown,
convertClaudeCommandToAugmentSkill,
convertClaudeAgentToAugmentAgent,
convertClaudeToTraeMarkdown,
convertClaudeCommandToTraeSkill,
convertClaudeAgentToTraeAgent,
convertClaudeToCodebuddyMarkdown,
convertClaudeCommandToCodebuddySkill,
convertClaudeCommandToCodebuddyCommand,
convertClaudeAgentToCodebuddyAgent,
convertClaudeToCliineMarkdown,
convertClaudeCommandToClineSkill,
convertClaudeAgentToClineAgent,
buildClineRulesBody,
buildClineAgentsMdBody,
buildClinePreToolUseHook,
writeClineArtifacts,
mergeGsdAgentsMd,
GSD_CURSOR_SESSION_HOOK_SCRIPT,
GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
GSD_CURSOR_HOOK_MARKER,
buildCursorHookEntry,
isManagedCursorHookEntry,
reconcileCursorHooksJson,
writeCursorHooksJson,
removeCursorHooksJson,
stripGsdFromAgentsMd,
GSD_AGENTS_MD_MARKER,
GSD_AGENTS_MD_CLOSE_MARKER,
writeManifest,
saveLocalPatches,
reportLocalPatches,
validateHookFields,
preserveUserArtifacts,
restoreUserArtifacts,
migrateLegacyDevPreferencesToSkill,
populatePristineDir,
USER_OWNED_ARTIFACTS,
finishInstall,
homePathCoveredByRc,
homePathCoveredByFishConfig,
decodeFishUniversalValue,
maybeSuggestPathExport,
runtimeMap,
allRuntimes,
selectRuntimesFromArgs,
GSD_UNINSTALL_HOOKS,
parseRuntimeInput,
buildRuntimePromptText,
buildUpdateBannerPromptText,
parseUpdateBannerInput,
buildUpdateBannerHookEntry,
buildHookCommand,
normalizeNodePath,
resolveNodeRunner,
referencesHook,
applySettingsJsonHooks,
rewriteLegacyManagedNodeHookCommands,
buildCodexHookBlock,
rewriteLegacyCodexHookBlock,
buildCodexHookWindowsShimIR,
ensureCodexHooksJsonSessionStart,
ensureCodexHooksJsonEvent,
removeCodexHooksJsonEvent,
reconcileCodexHooksJsonEvent,
readGsdCommandNames,
installRuntimeArtifacts,
installOpencodeFamilySkills,
uninstallRuntimeArtifacts,
parseConfigDirFromArgs,
cleanupLegacyGsdCc,
_applyRuntimeRewrites,
// #1191 — exported so tests exercise the REAL readSettings, not a replica
readSettings,
stripJsonComments,
// Compatibility relays retained after auditing the former broad
// runtimeArtifactConversion spread (#1559).
processAttribution,
applyRuntimeContentRewritesForCommandsInPlace,
_copyStaged,
};
// Main logic — only run when not loaded as a module for testing
if (require.main === module && !process.env.GSD_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');
// cleanupLegacyGsdCc with dryRun:true is the single source of truth for
// both the legacy artifacts and the per-package cache path — no duplicate
// printing here.
const { plan } = cleanupLegacyGsdCc({ dryRun: true });
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 /gsd-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);
}
const skillsRoot = getGlobalSkillsBase(runtimeArg);
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 : ['claude'];
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(['claude'], hasGlobal, false);
} else {
// Interactive
if (!process.stdin.isTTY) {
console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code global install${reset}\n`);
installAllRuntimes(['claude'], true, false);
} else {
promptRuntime((runtimes) => {
promptLocation(runtimes);
});
}
}
} // end of !GSD_TEST_MODE main logic block