Files
msd-core/bin/install.js
2026-05-31 07:36:52 -05:00

10938 lines
447 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,
} = require('../get-shit-done/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,
readCmdNames: readGsdCommandNames,
} = require(path.join(__dirname, '..', 'scripts', 'fix-slash-commands.cjs'));
const {
resolveAntigravityGlobalDir,
} = require('../get-shit-done/bin/lib/runtime-homes.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 get-shit-done 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);
}
// Copilot instructions marker constants
const GSD_COPILOT_INSTRUCTIONS_MARKER = '<!-- GSD Configuration \u2014 managed by get-shit-done installer -->';
const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = '<!-- /GSD Configuration -->';
// 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('../get-shit-done/...')` 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, '..', 'get-shit-done', 'bin', 'lib');
const { MODEL_PROFILES: GSD_MODEL_PROFILES } = require(path.join(_gsdLibDir, 'model-profiles.cjs'));
const {
RUNTIME_PROFILE_MAP: GSD_RUNTIME_PROFILE_MAP,
resolveTierEntry: gsdResolveTierEntry,
EFFORT_SET: GSD_EFFORT_SET,
} = require(path.join(_gsdLibDir, 'core.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,
'..',
'get-shit-done',
'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,
isMinimalMode,
stageSkillsForMode,
readActiveProfile,
writeActiveProfile,
resolveEffectiveProfile,
mostRestrictiveProfile,
resolveProfile,
loadSkillsManifest,
stageSkillsForProfile,
stageAgentsForProfile,
stageSkillsForRuntimeAsSkills,
} = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
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'));
// 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 hasOpencode = args.includes('--opencode');
const hasClaude = args.includes('--claude');
const hasGemini = args.includes('--gemini');
const hasKilo = args.includes('--kilo');
const hasCodex = args.includes('--codex');
const hasCopilot = args.includes('--copilot');
const hasAntigravity = args.includes('--antigravity');
const hasCursor = args.includes('--cursor');
const hasWindsurf = args.includes('--windsurf');
const hasAugment = args.includes('--augment');
const hasTrae = args.includes('--trae');
const hasQwen = args.includes('--qwen');
const hasHermes = args.includes('--hermes');
const hasCodebuddy = args.includes('--codebuddy');
const hasCline = args.includes('--cline');
const hasBoth = args.includes('--both'); // Legacy flag, keeps working
const hasAll = args.includes('--all');
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');
// --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);
}
// Runtime selection - can be set by flags or interactive prompt
let selectedRuntimes = [];
if (hasAll) {
selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline'];
} else if (hasBoth) {
selectedRuntimes = ['claude', 'opencode'];
} else {
if (hasClaude) selectedRuntimes.push('claude');
if (hasOpencode) selectedRuntimes.push('opencode');
if (hasGemini) selectedRuntimes.push('gemini');
if (hasKilo) selectedRuntimes.push('kilo');
if (hasCodex) selectedRuntimes.push('codex');
if (hasCopilot) selectedRuntimes.push('copilot');
if (hasAntigravity) selectedRuntimes.push('antigravity');
if (hasCursor) selectedRuntimes.push('cursor');
if (hasWindsurf) selectedRuntimes.push('windsurf');
if (hasAugment) selectedRuntimes.push('augment');
if (hasTrae) selectedRuntimes.push('trae');
if (hasQwen) selectedRuntimes.push('qwen');
if (hasHermes) selectedRuntimes.push('hermes');
if (hasCodebuddy) selectedRuntimes.push('codebuddy');
if (hasCline) selectedRuntimes.push('cline');
}
// 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);
}
}
// Helper to get directory name for a runtime (used for local/project installs)
function getDirName(runtime) {
if (runtime === 'copilot') return '.github';
if (runtime === 'opencode') return '.opencode';
if (runtime === 'gemini') return '.gemini';
if (runtime === 'kilo') return '.kilo';
if (runtime === 'codex') return '.codex';
if (runtime === 'antigravity') return '.agent';
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';
return '.claude';
}
/**
* 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 "'.agent'";
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'";
return "'.claude'";
}
/**
* Get the global config directory for OpenCode
* OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
* Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
*/
function getOpencodeGlobalDir() {
// 1. Explicit OPENCODE_CONFIG_DIR env var
if (process.env.OPENCODE_CONFIG_DIR) {
return expandTilde(process.env.OPENCODE_CONFIG_DIR);
}
// 2. OPENCODE_CONFIG env var (use its directory)
if (process.env.OPENCODE_CONFIG) {
return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
}
// 3. XDG_CONFIG_HOME/opencode
if (process.env.XDG_CONFIG_HOME) {
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
}
// 4. Default: ~/.config/opencode (XDG default)
return path.join(os.homedir(), '.config', 'opencode');
}
/**
* Get the global config directory for Kilo
* Kilo follows XDG Base Directory spec and uses ~/.config/kilo/
* Priority: KILO_CONFIG_DIR > dirname(KILO_CONFIG) > XDG_CONFIG_HOME/kilo > ~/.config/kilo
*/
function getKiloGlobalDir() {
// 1. Explicit KILO_CONFIG_DIR env var
if (process.env.KILO_CONFIG_DIR) {
return expandTilde(process.env.KILO_CONFIG_DIR);
}
// 2. KILO_CONFIG env var (use its directory)
if (process.env.KILO_CONFIG) {
return path.dirname(expandTilde(process.env.KILO_CONFIG));
}
// 3. XDG_CONFIG_HOME/kilo
if (process.env.XDG_CONFIG_HOME) {
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'kilo');
}
// 4. Default: ~/.config/kilo (XDG default)
return path.join(os.homedir(), '.config', 'kilo');
}
/**
* Get the global config directory for a runtime
* @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot'
* @param {string|null} explicitDir - Explicit directory from --config-dir flag
*/
function getGlobalDir(runtime, explicitDir = null) {
if (runtime === 'opencode') {
// For OpenCode, --config-dir overrides env vars
if (explicitDir) {
return expandTilde(explicitDir);
}
return getOpencodeGlobalDir();
}
if (runtime === 'kilo') {
// For Kilo, --config-dir overrides env vars
if (explicitDir) {
return expandTilde(explicitDir);
}
return getKiloGlobalDir();
}
if (runtime === 'gemini') {
// Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.GEMINI_CONFIG_DIR) {
return expandTilde(process.env.GEMINI_CONFIG_DIR);
}
return path.join(os.homedir(), '.gemini');
}
if (runtime === 'codex') {
// Codex: --config-dir > CODEX_HOME > ~/.codex
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CODEX_HOME) {
return expandTilde(process.env.CODEX_HOME);
}
return path.join(os.homedir(), '.codex');
}
if (runtime === 'copilot') {
// Copilot: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.COPILOT_CONFIG_DIR) {
return expandTilde(process.env.COPILOT_CONFIG_DIR);
}
return path.join(os.homedir(), '.copilot');
}
if (runtime === 'antigravity') {
// Antigravity: --config-dir > ANTIGRAVITY_CONFIG_DIR > auto-detected
// ~/.gemini/{antigravity,antigravity-ide,antigravity-cli}
if (explicitDir) {
return expandTilde(explicitDir);
}
return resolveAntigravityGlobalDir();
}
if (runtime === 'cursor') {
// Cursor: --config-dir > CURSOR_CONFIG_DIR > ~/.cursor
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CURSOR_CONFIG_DIR) {
return expandTilde(process.env.CURSOR_CONFIG_DIR);
}
return path.join(os.homedir(), '.cursor');
}
if (runtime === 'windsurf') {
// Windsurf: --config-dir > WINDSURF_CONFIG_DIR > ~/.codeium/windsurf
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.WINDSURF_CONFIG_DIR) {
return expandTilde(process.env.WINDSURF_CONFIG_DIR);
}
return path.join(os.homedir(), '.codeium', 'windsurf');
}
if (runtime === 'augment') {
// Augment: --config-dir > AUGMENT_CONFIG_DIR > ~/.augment
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.AUGMENT_CONFIG_DIR) {
return expandTilde(process.env.AUGMENT_CONFIG_DIR);
}
return path.join(os.homedir(), '.augment');
}
if (runtime === 'trae') {
// Trae: --config-dir > TRAE_CONFIG_DIR > ~/.trae
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.TRAE_CONFIG_DIR) {
return expandTilde(process.env.TRAE_CONFIG_DIR);
}
return path.join(os.homedir(), '.trae');
}
if (runtime === 'qwen') {
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.QWEN_CONFIG_DIR) {
return expandTilde(process.env.QWEN_CONFIG_DIR);
}
return path.join(os.homedir(), '.qwen');
}
if (runtime === 'hermes') {
// Hermes Agent: --config-dir > HERMES_HOME > ~/.hermes
// Honors HERMES_HOME which Hermes users set for profile mode / Docker
// deploys (docs: https://hermes-agent.nousresearch.com/docs).
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.HERMES_HOME) {
return expandTilde(process.env.HERMES_HOME);
}
return path.join(os.homedir(), '.hermes');
}
if (runtime === 'codebuddy') {
// CodeBuddy: --config-dir > CODEBUDDY_CONFIG_DIR > ~/.codebuddy
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CODEBUDDY_CONFIG_DIR) {
return expandTilde(process.env.CODEBUDDY_CONFIG_DIR);
}
return path.join(os.homedir(), '.codebuddy');
}
if (runtime === 'cline') {
// Cline: --config-dir > CLINE_CONFIG_DIR > ~/.cline
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CLINE_CONFIG_DIR) {
return expandTilde(process.env.CLINE_CONFIG_DIR);
}
return path.join(os.homedir(), '.cline');
}
// Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CLAUDE_CONFIG_DIR) {
return expandTilde(process.env.CLAUDE_CONFIG_DIR);
}
return path.join(os.homedir(), '.claude');
}
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, 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}--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 — 7 main-loop skills incl. phase (~130 desc tokens)\n standard — ~13 skills incl. phase, review, config (~700)\n full — all 66 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 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 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 / COPILOT_CONFIG_DIR / 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`);
process.exit(0);
}
/**
* Expand ~ to home directory (shell doesn't expand in env vars passed to node)
*/
function expandTilde(filePath) {
if (filePath && filePath.startsWith('~/')) {
return path.join(os.homedir(), filePath.slice(2));
}
return filePath;
}
/**
* Compute the path prefix used for `@file` references in installed command/skill
* markdown. For global installs into a runtime config dir under $HOME, we
* normally substitute the home prefix with `$HOME` so paths expand correctly
* inside double-quoted shell commands. OpenCode is exempt on every platform:
* its `@file` include syntax does NOT shell-expand `$HOME`, so a literal
* `@$HOME/...` is treated as a path relative to the config command/ dir, which
* resolves to `command/$HOME/...` (file not found). For OpenCode we always emit
* the absolute resolved path. (#2376 Windows, #2831 macOS/Linux.)
*
* @param {object} args
* @param {boolean} args.isGlobal - Global runtime install vs local project
* @param {boolean} args.isOpencode - Whether the runtime is OpenCode
* @param {boolean} args.isWindowsHost - process.platform === 'win32'
* @param {string} args.resolvedTarget - Absolute target dir, forward-slashed
* @param {string} args.homeDir - User home dir, forward-slashed
* @returns {string} pathPrefix ending with '/'
*/
function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget, homeDir }) {
if (isGlobal && resolvedTarget.startsWith(homeDir) && !isOpencode) {
return '$HOME' + resolvedTarget.slice(homeDir.length) + '/';
}
return `${resolvedTarget}/`;
}
/**
* Normalize a raw `process.execPath` to a stable, upgrade-safe node binary
* path. On Homebrew installs, `process.execPath` resolves symlinks and returns
* the versioned Cellar path (e.g.
* `/usr/local/Cellar/node/25.8.1/bin/node`). Baking that path into hook
* commands causes `dyld: Library not loaded` errors after `brew upgrade node`
* because the shared libraries referenced by the Cellar binary have changed
* SOVERSION. (#3181)
*
* The stable Homebrew symlinks (`/usr/local/bin/node` for Intel,
* `/opt/homebrew/bin/node` for Apple Silicon) survive upgrades — Homebrew
* re-points them atomically. We prefer those when a Cellar path is detected.
*
* Non-Homebrew installs (NVM, system node, Windows, etc.) are returned as-is.
*/
function normalizeNodePath(execPath) {
if (!execPath) return execPath;
// Intel Homebrew: /usr/local/Cellar/node/<version>/bin/node
// or /usr/local/Cellar/node@20/<version>/bin/node
if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
return '/usr/local/bin/node';
}
// Apple Silicon Homebrew: /opt/homebrew/Cellar/node/<version>/bin/node
// or /opt/homebrew/Cellar/node@18/<version>/bin/node
if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
return '/opt/homebrew/bin/node';
}
return execPath;
}
/**
* Resolve the absolute path to the node binary running the installer.
* Used as the runner for .js hooks so they execute in GUI/minimal-PATH
* runtimes (Gemini, Antigravity, Codex CLIs launched from a Finder
* shortcut etc.) where bare `node` is not on `/usr/bin:/bin:/usr/sbin:/sbin`
* and the hook would fail with `node: command not found` (#2979).
*
* Returns a forward-slash-normalized, double-quoted path so the emitted
* command is shell-safe across POSIX and Windows. `process.execPath`
* gives the absolute path of the node binary actively running the
* installer — that is the version the user just installed under, and
* the right default runtime for hooks invoked under the same install.
*
* When `process.execPath` is a versioned Homebrew Cellar path, the stable
* Homebrew symlink is returned instead to survive `brew upgrade node` (#3181).
*/
function resolveNodeRunner() {
const execPath = typeof process.execPath === 'string' ? process.execPath : '';
if (!execPath) return null;
const stablePath = normalizeNodePath(execPath);
// JSON.stringify produces a properly escaped double-quoted shell token,
// safe for paths containing spaces or unusual characters.
return JSON.stringify(stablePath.replace(/\\/g, '/'));
}
/**
* Rewrite legacy `node .../gsd-*.js` command strings in settings.hooks to use
* the absolute Node binary path (#2979 follow-up: CR feedback on #3002).
*
* The original #2979 fix only emitted absolute paths for *newly registered*
* hooks. Pre-existing entries kept their bare `node ` prefix on reinstall,
* which left them broken under minimal-PATH GUI runtimes — exactly the
* failure mode the original fix was meant to close. This walker normalizes
* any managed-hook entry whose command starts with bare `node ` to
* `<absoluteRunner> <script>` while leaving non-managed and non-bare-node
* entries (user-authored hooks, shell scripts, etc.) untouched.
*
* Returns true if any entry was rewritten.
*/
function resolveBashRunner(opts) {
const platform = (opts && opts.platform) || process.platform;
if (platform !== 'win32') return 'bash';
const env = (opts && opts.env) || process.env;
const exists = (opts && opts.existsSync) || fs.existsSync;
const candidates = [];
if (env.GSD_BASH_PATH) candidates.push(env.GSD_BASH_PATH);
if (env.ProgramFiles) candidates.push(path.win32.join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'));
if (env['ProgramFiles(x86)']) candidates.push(path.win32.join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'));
if (env.SystemDrive) {
candidates.push(path.win32.join(env.SystemDrive, 'Program Files', 'Git', 'bin', 'bash.exe'));
candidates.push(path.win32.join(env.SystemDrive, 'Program Files (x86)', 'Git', 'bin', 'bash.exe'));
}
for (const candidate of candidates) {
if (candidate && exists(candidate)) {
return JSON.stringify(candidate.replace(/\\/g, '/'));
}
}
return null;
}
function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
if (!settings || !settings.hooks || !absoluteRunner) return false;
if (!opts) opts = {};
const platform = opts.platform || process.platform;
let changed = false;
for (const entries of Object.values(settings.hooks)) {
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (!entry || !Array.isArray(entry.hooks)) continue;
for (const h of entry.hooks) {
if (!h || typeof h.command !== 'string') continue;
let trimmed = h.command.trim();
const hadPowerShellCallOperator = platform === 'win32' && /^&\s+/.test(trimmed);
if (hadPowerShellCallOperator) {
trimmed = trimmed.replace(/^&\s+/, '').trim();
}
// Match two runner forms:
// 1. Legacy bare-node form: `node <script>` (#2979/#3002)
// 2. Cellar-path form: `"/usr/local/Cellar/node/<v>/bin/node" <script>`
// or `"/opt/homebrew/Cellar/node/<v>/bin/node" <script>` (#3181)
//
// Both patterns use the same script-token capture group so the rewrite
// is uniform. We detect the Cellar form by extracting the runner token
// and running it through normalizeNodePath.
//
// The previous shape used `trimmed.includes(<filename>)` which would
// false-positive on user-authored hooks whose path merely contained
// a managed filename as a substring (e.g.
// /home/me/scripts/wraps-gsd-check-update.js-and-more.js). #3002 CR.
const m = trimmed.match(/^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/) ||
trimmed.match(/^("([^"]+)"|'([^']+)'|(\S+))\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/);
if (!m) continue;
let runnerToken, scriptToken, scriptPath;
if (/^node\s+/.test(trimmed)) {
// bare-node form
runnerToken = 'node';
scriptToken = m[1];
scriptPath = m[2] || m[3] || m[4] || '';
} else {
// quoted/unquoted runner form — check whether runner is a Cellar path
runnerToken = m[1];
const runnerPath = (m[2] || m[3] || m[4] || '').replace(/\\/g, '/');
const stableRunner = normalizeNodePath(runnerPath);
// Process Cellar paths so they normalize to a stable symlink. On
// Windows, already-absolute runners still flow through the projection
// seam because some runtimes need additional wrapper policy while
// others must stay shell-neutral (#3362, #3413).
if (stableRunner === runnerPath && platform !== 'win32') continue;
scriptToken = m[5];
scriptPath = m[6] || m[7] || m[8] || '';
}
// Take the basename — match against MANAGED_HOOK_FILES by exact
// equality, not substring containment. Handles both forward and
// backslash separators (Windows).
if (!isManagedHookBasename(scriptPath, { surface: 'settings-json' })) continue;
const projectedCommand = projectLegacySettingsHookCommand({
absoluteRunner,
scriptPath,
scriptToken,
runtime: opts.runtime || 'generic',
platform,
});
if (!projectedCommand) continue;
// Skip only when the existing managed command already matches the
// desired runtime-aware projected shape. This preserves Gemini's
// required PowerShell prefix while still letting Claude strip stale
// prefixes on reinstall (#3413).
if (h.command === projectedCommand) continue;
h.command = projectedCommand;
changed = true;
}
}
}
return changed;
}
/**
* 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) {
const absoluteRunner = opts && opts.absoluteRunner;
if (!absoluteRunner) return null;
const eol = (opts && opts.eol) || '\n';
const platform = (opts && opts.platform) || process.platform;
const updateCheckScript = path.resolve(targetDir, 'hooks', 'gsd-check-update.js');
const commandValue = projectCodexHookTomlCommand({
absoluteRunner,
scriptPath: updateCheckScript,
platform,
});
return `${eol}# GSD Hooks${eol}` +
`[[hooks.SessionStart]]${eol}` +
`${eol}` +
`[[hooks.SessionStart.hooks]]${eol}` +
`type = "command"${eol}` +
`command = "${commandValue}"${eol}`;
}
/**
* 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) {
if (!content || !absoluteRunner) return { content, changed: false };
const platform = (opts && opts.platform) || process.platform;
let changed = false;
// Match `command = "node <scriptToken>"` lines where scriptToken is
// either an unquoted path (no spaces) or a toml-escaped quoted path.
// The whole RHS is a toml-double-quoted string; interior quotes are \".
// Examples we want to migrate:
// command = "node /Users/x/.codex/hooks/gsd-check-update.js"
// command = "node \"/Users/x/.codex/hooks/gsd-check-update.js\""
// Examples we must leave alone:
// command = "\"/usr/local/bin/node\" \"/path/to/gsd-check-update.js\"" ← already absolute
// command = "node /home/me/my-custom.js" ← user-owned filename
const updated = content.replace(
/^(command\s*=\s*")node\s+((?:\\"[^"]+\\"|\S+))("\s*)$/gm,
(full, prefix, scriptToken, suffix) => {
// Extract the underlying script path from the captured token —
// either the bare token or the decoded inner content of \"...\".
const quoted = scriptToken.match(/^\\"([\s\S]+)\\"$/);
let scriptPath = scriptToken;
if (quoted) {
try {
scriptPath = String(parseTomlValue(`"${quoted[1]}"`, 0).value);
} catch {
scriptPath = quoted[1];
}
}
if (!isManagedHookBasename(scriptPath, { surface: 'codex-toml' })) return full;
const desiredCommand = projectCodexHookTomlCommand({
absoluteRunner,
scriptPath,
platform,
});
const currentCommand = `${prefix}${scriptToken}${suffix}`.replace(/^(command\s*=\s*")|("\s*)$/g, '');
if (currentCommand === desiredCommand) return full;
changed = true;
return `${prefix}${desiredCommand}${suffix}`;
},
);
return { content: updated, changed };
}
function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
const hooksJsonPath = path.join(targetDir, 'hooks.json');
const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null;
let parsed = {};
let currentContent = null;
if (fs.existsSync(hooksJsonPath)) {
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
currentContent = raw;
if (raw.trim()) {
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(`hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
}
}
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
const usesNestedHooksObject =
parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
const hookTable = usesNestedHooksObject ? parsed.hooks : parsed;
const sessionStart = Array.isArray(hookTable.SessionStart) ? hookTable.SessionStart : [];
let removedLegacy = false;
const sanitizedSessionStart = [];
for (const entry of sessionStart) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
const originalHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
if (originalHooks.length === 0) {
sanitizedSessionStart.push(entry);
continue;
}
const keptHooks = originalHooks.filter((hook) => {
const cmd = hook && typeof hook === 'object' ? hook.command : null;
const managed = isManagedHookCommand(cmd, {
surface: 'codex-hooks-json',
includeLegacyAliases: true,
configDir: targetDir,
});
if (managed) removedLegacy = true;
return !managed;
});
if (keptHooks.length === 0) continue;
const nextEntry = { ...entry, hooks: keptHooks };
sanitizedSessionStart.push(nextEntry);
}
if (managedCommand) {
sanitizedSessionStart.push({
hooks: [
{
type: 'command',
command: managedCommand,
},
],
});
}
if (sanitizedSessionStart.length > 0) {
hookTable.SessionStart = sanitizedSessionStart;
} else {
delete hookTable.SessionStart;
}
if (usesNestedHooksObject) parsed.hooks = hookTable;
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
const changed = currentContent !== nextContent;
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
if (shouldWrite) {
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
}
return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
}
/**
* 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) {
if (!absoluteRunnerToken) return null;
// absoluteRunnerToken is JSON-quoted (e.g. '"C:/path/node.exe"'). Unwrap to
// get the raw interpreter path for the invocation record and render output.
let interpreter;
try {
interpreter = JSON.parse(absoluteRunnerToken);
} catch {
interpreter = absoluteRunnerToken;
}
// Normalise to forward slashes for cross-shell safety (same as other Windows
// hook path normalisations in this codebase).
const targetAbs = scriptAbsPath.replace(/\\/g, '/');
const scriptQuoted = JSON.stringify(targetAbs);
// .cmd shim lives alongside the .js file, replacing the extension.
const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
// The hook command written to hooks.json is just the .cmd path (double-quoted
// for spaces-in-path safety). cmd.exe executes .cmd files natively via
// CreateProcess — no runner prefix required.
const hookCommand = JSON.stringify(cmdPath.replace(/\\/g, '/'));
const runnerQuoted = JSON.stringify(interpreter);
return {
invocation: { interpreter, target: scriptAbsPath },
cmdPath,
hookCommand,
// Typed fields for IR-level assertions (CONTRIBUTING.md L558-L565).
// These describe the render semantics in a structured way so tests can
// assert on the generator contract without coupling to rendered text.
eol: { cmd: '\r\n' }, // CRLF — canonical for cmd.exe .cmd files
passthroughArgs: true, // the shim forwards all args via %*
render: {
// Use CRLF line endings for strict cmd.exe compatibility (LF-only
// .cmd files work in modern Windows but CRLF is the canonical format).
cmd: () => `@ECHO OFF\r\n@SETLOCAL\r\n@${runnerQuoted} ${scriptQuoted} %*\r\n`,
},
};
}
/**
* 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 path as the hook command to avoid the `bash.exe: cannot execute binary
* file` failure (#3426).
*
* @param {string} targetDir
* @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts
* @returns {{ changed: boolean, wrote: boolean, path: string }}
*/
function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
const platform = opts.platform || process.platform;
const absoluteRunner = opts.absoluteRunner || null;
const hooksJsonPath = path.join(targetDir, 'hooks.json');
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js');
let managedCommand;
if (platform === 'win32') {
// #3426 fix: on Windows, write a .cmd shim and use its path as the hook
// command. This avoids the MSYS bash.exe POSIX-exec failure when Codex's
// hook dispatcher tries to run node.exe through the Git Bash exec layer.
const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
try {
atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
} catch (shimWriteErr) {
// Shim write failed — do NOT fall back to the old "node.exe script.js"
// command. That form triggers the `bash.exe: cannot execute binary file`
// failure that #3426 exists to fix, so a silent fallback would silently
// restore the original bug. Instead: warn loudly and skip the registration
// for this runtime so the user sees an actionable message rather than a
// successful install that fails at hook-dispatch time.
const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
console.warn(
` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed: ${reason}. ` +
`Fix the write error (permissions? disk full?) and re-run the installer. ` +
`Do NOT use the legacy node.exe command path — it triggers the #3426 bash.exe POSIX-exec failure.`,
);
return { changed: false, wrote: false, path: hooksJsonPath };
}
managedCommand = shimIR.hookCommand;
} else {
managedCommand = projectManagedHookCommand({
absoluteRunner,
scriptPath,
runtime: 'codex',
platform,
});
}
if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand });
}
function removeCodexHooksJsonSessionStart(targetDir) {
return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand: null });
}
/**
* 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) {
if (!opts) opts = {};
const platform = opts.platform || process.platform;
const runtime = opts.runtime || 'generic';
const isShellHook = hookName.endsWith('.sh');
// #166: Claude Code executes these hook commands inside a bash context on
// Windows, so wrapping `.sh` hooks with an explicit `bash.exe` path can
// trigger `bash.exe: ... cannot execute binary file`. Emit only the quoted
// script path for Claude on Windows.
if (platform === 'win32' && runtime === 'claude' && isShellHook) {
if (opts.portableHooks) {
const portableBaseDir = projectPortableHookBaseDir({
configDir,
homeDir: os.homedir(),
});
return JSON.stringify(`${portableBaseDir}/hooks/${hookName}`);
}
return JSON.stringify(configDir.replace(/\\/g, '/') + '/hooks/' + hookName);
}
// POSIX .sh hooks run under PATH-resolved `bash`: POSIX guarantees /bin/sh
// but not /bin/bash, and distros like NixOS do not ship /bin/bash by default.
// Windows Codex launches hooks from PowerShell/cmd environments where bare
// `bash` may not be on PATH, so resolve Git Bash explicitly or return null so
// callers skip registration instead of installing a known-broken hook (#3393).
// .js hooks still need the absolute node path because GUI-launched runtimes
// start with a minimal PATH that may not include nvm/Homebrew/Volta node
// binaries (#2979).
const nodeRunner = resolveNodeRunner();
const runner = isShellHook ? resolveBashRunner(opts) : nodeRunner;
// Runner resolvers return null when the executable path is unavailable.
// Fall through with null so callers can skip registration with a warning
// instead of emitting a command that recreates the original hook failure.
if (runner === null) return null;
if (opts.portableHooks) {
const portableBaseDir = projectPortableHookBaseDir({
configDir,
homeDir: os.homedir(),
});
return projectManagedHookCommand({
absoluteRunner: runner,
scriptPath: `${portableBaseDir}/hooks/${hookName}`,
runtime: opts.runtime || 'generic',
platform,
});
}
// Default: absolute path with forward slashes (Windows-safe, fixes #2045/#2046).
const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
return projectManagedHookCommand({
absoluteRunner: runner,
scriptPath: hooksPath,
runtime,
platform,
});
}
/**
* 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');
// Try standard JSON first (fast path)
try {
return JSON.parse(raw);
} catch {
// Fall back to JSONC stripping
return JSON.parse(stripJsonComments(raw));
}
} 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}`;
}
/**
* #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(getGlobalDir(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(getGlobalDir('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(getGlobalDir('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;
}
/**
* Process Co-Authored-By lines based on attribution setting
* @param {string} content - File content to process
* @param {null|undefined|string} attribution - null=remove, undefined=keep, string=replace
* @returns {string} Processed content
*/
function processAttribution(content, attribution) {
if (attribution === null) {
// Remove Co-Authored-By lines and the preceding blank line
return content.replace(/(\r?\n){2}Co-Authored-By:.*$/gim, '');
}
if (attribution === undefined) {
return content;
}
// Replace with custom attribution (escape $ to prevent backreference injection)
const safeAttribution = attribution.replace(/\$/g, '$$$$');
return content.replace(/Co-Authored-By:.*$/gim, `Co-Authored-By: ${safeAttribution}`);
}
/**
* 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',
};
/**
* 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).
if (
claudeTool === 'Task' ||
claudeTool === 'Agent' ||
claudeTool === 'AskUserQuestion' ||
claudeTool === 'ask_user'
) {
return null;
}
// Check for explicit mapping
if (claudeToGeminiTools[claudeTool]) {
return claudeToGeminiTools[claudeTool];
}
// Default: lowercase
return claudeTool.toLowerCase();
}
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];
}
// 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;
}
/**
* 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, 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;
}
/**
* 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');
// 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`;
if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
if (agent) fm += `agent: ${agent}\n`;
if (toolsBlock) fm += toolsBlock;
fm += '---';
return `${fm}\n${normalizedBody}`;
}
/**
* 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/ → ./.agent/
* Local: ~/.claude/ → .agent/, ./.claude/ → ./.agent/
* 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, '.agent/');
c = c.replace(/~\/\.claude\//g, '.agent/');
// Bare form (no trailing slash) — must come after slash form to avoid double-replace
c = c.replace(/\$HOME\/\.claude\b/g, '.agent');
c = c.replace(/~\/\.claude\b/g, '.agent');
}
c = c.replace(/\.\/\.claude\//g, './.agent/');
c = c.replace(/\.claude\//g, '.agent/');
// 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;
}
/**
* 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, 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
};
/**
* Convert a Claude Code tool name to Cursor CLI format
* @returns {string|null} Cursor tool name, or null if tool should be excluded
*/
function convertCursorToolName(claudeTool) {
if (claudeTool in claudeToCursorTools) {
return claudeToCursorTools[claudeTool];
}
// MCP tools keep their format (Cursor supports MCP)
if (claudeTool.startsWith('mcp__')) {
return claudeTool;
}
// Most tools share the same name (Read, Write, Glob, Grep, Task, WebSearch, WebFetch, TodoWrite)
return claudeTool;
}
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 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
};
/**
* Convert a Claude Code tool name to Windsurf Cascade format
* @returns {string|null} Windsurf tool name, or null if tool should be excluded
*/
function convertWindsurfToolName(claudeTool) {
if (claudeTool in claudeToWindsurfTools) {
return claudeToWindsurfTools[claudeTool];
}
// MCP tools keep their format (Windsurf supports MCP)
if (claudeTool.startsWith('mcp__')) {
return claudeTool;
}
// Most tools share the same name (Read, Write, Glob, Grep, Task, WebSearch, WebFetch, TodoWrite)
return claudeTool;
}
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/');
// 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()}`;
}
/**
* 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',
};
function convertAugmentToolName(claudeTool) {
if (claudeTool in claudeToAugmentTools) {
return claudeToAugmentTools[claudeTool];
}
if (claudeTool.startsWith('mcp__')) {
return claudeTool;
}
const toolMapping = {
Read: 'view',
Write: 'save-file',
Glob: 'view',
Grep: 'grep',
Task: null,
WebSearch: 'web-search',
WebFetch: 'web-fetch',
};
return toolMapping[claudeTool] || claudeTool;
}
function convertSlashCommandsToAugmentSkillMentions(content) {
return content.replace(/gsd:/gi, 'gsd-');
}
function convertClaudeToAugmentMarkdown(content) {
let converted = convertSlashCommandsToAugmentSkillMentions(content);
converted = converted.replace(/\bBash\(/g, 'launch-process(');
converted = converted.replace(/\bEdit\(/g, 'str-replace-editor(');
converted = converted.replace(/\bRead\(/g, 'view(');
converted = converted.replace(/\bWrite\(/g, 'save-file(');
converted = converted.replace(/\bTodoWrite\(/g, 'add_tasks(');
converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting');
// Replace subagent_type from Claude to Augment 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 Augment equivalents
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.augment/rules/`');
converted = converted.replace(/\.\/CLAUDE\.md/g, '.augment/rules/');
converted = converted.replace(/`CLAUDE\.md`/g, '`.augment/rules/`');
converted = converted.replace(/\bCLAUDE\.md\b/g, '.augment/rules/');
converted = converted.replace(/\.claude\/skills\//g, '.augment/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 "Augment"
converted = converted.replace(/\bClaude Code\b/g, 'Augment');
return converted;
}
function getAugmentSkillAdapterHeader(skillName) {
return `<augment_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 Augment tools when executing GSD workflows:
- \`launch-process\` for running commands (terminal operations)
- \`str-replace-editor\` for editing existing files
- \`view\` for reading files and listing directories
- \`save-file\` for creating new files
- \`grep\` for searching code (or use MCP servers for advanced search)
- \`web-search\`, \`web-fetch\` for web queries
- \`add_tasks\`, \`view_tasklist\`, \`update_tasks\` for task management
## D. Subagent Spawning
When the workflow needs to spawn a subagent:
- Use the built-in subagent spawning capability
- Define agent prompts in \`.augment/agents/\` directory
</augment_skill_adapter>`;
}
function convertClaudeCommandToAugmentSkill(content, skillName) {
const converted = convertClaudeToAugmentMarkdown(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 = getAugmentSkillAdapterHeader(skillName);
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
}
/**
* Convert Claude Code agent markdown to Augment agent format.
* Strips frontmatter fields Augment doesn't support (color, skills),
* converts tool references, and cleans up for Augment agents.
*/
function convertClaudeAgentToAugmentAgent(content) {
let converted = convertClaudeToAugmentMarkdown(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}`;
}
/**
* Copy Claude commands as Augment skills — one folder per skill with SKILL.md.
* Mirrors copyCommandsAsCursorSkills but uses Augment converters.
*/
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/');
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.
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n${body}`;
}
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');
converted = converted.replace(/\.claude\/skills\//g, '.cline/skills/');
converted = converted.replace(/\.\/\.claude\//g, './.cline/');
converted = converted.replace(/\.claude\//g, '.cline/');
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}`;
}
// ── End Cline converters ─────────────────────────────────────────────────────
function convertSlashCommandsToCodexSkillMentions(content) {
// Convert colon-style skill invocations to Codex $ prefix
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.
// Negative lookbehind excludes file paths like bin/gsd-tools.cjs where
// the slash is preceded by a word char, dot, or another slash.
converted = converted.replace(/(?<![a-zA-Z0-9./])\/gsd-([a-z0-9-]+)/gi, (_, commandName) => {
return `$gsd-${String(commandName).toLowerCase()}`;
});
return converted;
}
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/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');
// 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:
Direct mapping:
- \`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).
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) {
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)}`,
`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];
if (modelOverride) {
lines.push(`model = ${JSON.stringify(modelOverride)}`);
} 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)}`);
// 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', ...).
const _universalEffortCodex = resolveInstallTimeEffort(effortCfg, resolvedName !== agentName ? resolvedName : agentName);
const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
// 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';
}
/**
* 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;
}
/**
* Atomic write — write to <target>.tmp-<pid>-<n> first, then renameSync over
* the target. Eliminates the partial-write corruption window: an interrupted
* write leaves the temp file (which we clean up) but never truncates the
* original target. Used for any mutation of Codex config.toml so we cannot
* leave the user with a half-written file (#2760 fix 4).
*
* Every temp path written is recorded in __atomicWrittenTmps so that
* _cleanTmpFiles() can scope cleanup to files this installer process actually
* created, avoiding accidental deletion of unrelated tools' temp files.
*/
let __atomicWriteCounter = 0;
// Set<string> — absolute paths of .tmp-<pid>-<n> files this process created.
const __atomicWrittenTmps = new Set();
function atomicWriteFileSync(target, data, options) {
__atomicWriteCounter += 1;
const tmp = `${target}.tmp-${process.pid}-${__atomicWriteCounter}`;
__atomicWrittenTmps.add(tmp);
try {
fs.writeFileSync(tmp, data, options);
fs.renameSync(tmp, target);
// Successful rename: the tmp path no longer exists, but leave it in the
// Set so _cleanTmpFiles can recognise it as installer-owned if it somehow
// lingers (e.g. a rename succeeded but left a stale entry on some FS).
} catch (e) {
// Best-effort cleanup of the partial temp file; never mask the real error.
try { fs.rmSync(tmp, { force: true }); } catch (_) { /* ignore */ }
throw e;
}
}
/**
* 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.3-codex") 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;
}
/**
* 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) {
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, 'get-shit-done').replace(/\\/g, '/')}/`;
for (const file of agentEntries) {
let content = fs.readFileSync(path.join(agentsSrc, file), 'utf8');
// Replace full .claude/get-shit-done prefix so path resolves to the Codex
// GSD install before generic .claude → .codex conversion rewrites it.
content = content.replace(/~\/\.claude\/get-shit-done\//g, codexGsdPath);
content = content.replace(/\$HOME\/\.claude\/get-shit-done\//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);
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 } = {}) {
// 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
converted = convertClaudeToGeminiToml(converted);
}
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}`;
}
/**
* 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) {
// 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();
const body = content.substring(endIndex + 3).trim();
// 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')
*/
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');
const globalClaudeRegex = /~\/\.claude\//g;
const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
const localClaudeRegex = /\.\/\.claude\//g;
const opencodeDirRegex = /~\/\.opencode\//g;
const kiloDirRegex = /~\/\.kilo\//g;
content = content.replace(globalClaudeRegex, pathPrefix);
content = content.replace(globalClaudeHomeRegex, pathPrefix);
content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
content = content.replace(opencodeDirRegex, pathPrefix);
content = content.replace(kiloDirRegex, 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 get-shit-done/.
*
* 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 get-shit-done/ 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 (e.g. cline)
const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
skillDir = path.join(targetDir, skillsKindEntry.destSubpath, stemName);
} else {
// Legacy fallback for callers that have not yet been updated to pass runtime
skillDir = path.join(targetDir, 'skills', 'gsd-dev-preferences');
}
const skillFile = path.join(skillDir, 'SKILL.md');
if (fs.existsSync(skillFile)) return false;
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
*/
function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
if (!fs.existsSync(stagedDir)) return;
// Walk all SKILL.md files under stagedDir
const walkAndRewrite = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkAndRewrite(fullPath);
} else if (entry.name === 'SKILL.md') {
let content = fs.readFileSync(fullPath, 'utf8');
content = _applyRuntimeRewrites(content, runtime, pathPrefix);
fs.writeFileSync(fullPath, content);
}
}
};
walkAndRewrite(stagedDir);
}
/**
* 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
* @returns {string}
*/
function _applyRuntimeRewrites(content, runtime, pathPrefix) {
const dirName = getDirName(runtime);
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
switch (runtime) {
case 'codex':
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
content = content.replace(/~\/\.codex\//g, pathPrefix);
content = processAttribution(content, getCommitAttribution(runtime));
break;
case 'cursor':
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
content = content.replace(/~\/\.cursor\//g, pathPrefix);
content = processAttribution(content, getCommitAttribution(runtime));
break;
case 'windsurf':
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
content = content.replace(/~\/\.codeium\/windsurf\//g, pathPrefix);
content = processAttribution(content, getCommitAttribution(runtime));
break;
case 'augment':
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
content = content.replace(/~\/\.augment\//g, pathPrefix);
content = processAttribution(content, getCommitAttribution(runtime));
break;
case 'trae':
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
content = content.replace(/~\/\.trae\//g, pathPrefix);
content = processAttribution(content, getCommitAttribution(runtime));
break;
case 'codebuddy':
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
content = content.replace(/~\/\.codebuddy\//g, pathPrefix);
content = processAttribution(content, getCommitAttribution(runtime));
break;
case 'copilot':
// Copilot converter handles path rewrites; only attribution here
content = processAttribution(content, getCommitAttribution('copilot'));
break;
case 'antigravity':
// Antigravity converter handles path rewrites; only attribution here
content = processAttribution(content, getCommitAttribution('antigravity'));
break;
case 'claude':
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
content = processAttribution(content, getCommitAttribution(runtime));
break;
case 'qwen':
// Branding rewrites run before path rewrites to avoid consuming
// patterns that the path step would also match.
content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
// Base path rewrites (use ~/ and $HOME/ slash forms first — most specific)
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/~\/\.qwen\//g, pathPrefix);
content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix);
// Bare relative .claude/ → .qwen/ (residual refs not matched above)
content = content.replace(/\.claude\//g, '.qwen/');
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`);
content = processAttribution(content, getCommitAttribution(runtime));
break;
case 'hermes':
// Branding rewrites run before path rewrites (same rationale as qwen)
content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
// Base path rewrites
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/~\/\.hermes\//g, pathPrefix);
content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix);
// Bare relative .claude/ → .hermes/ (residual refs)
content = content.replace(/\.claude\//g, '.hermes/');
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`);
content = processAttribution(content, getCommitAttribution(runtime));
break;
default:
// Unknown runtime — no rewrites
break;
}
return content;
}
/**
* 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).
*/
function _copyStaged(stagedDir, destDir, kind) {
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;
}
// 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 Hermes nested case (prefix === ''): the destSubpath IS the namespace
* (skills/gsd) — remove the entire destDir.
*/
function _removeGsdEntries(destDir, kind) {
if (!fs.existsSync(destDir)) 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: remove intermediate-layout skills/gsd/gsd-*/ entries that existed
// between #2841 and #3664. Phase 2 (#3664) uses prefix='' producing bare-stem
// names (skills/gsd/<stem>/SKILL.md); the intermediate layout had the gsd-
// prefix inside the nested dir (skills/gsd/gsd-<stem>/SKILL.md). Only
// children whose name starts with gsd- are removed — the parent skills/gsd/
// directory and any non-gsd- siblings (user content) are preserved.
const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
if (fs.existsSync(nestedGsdDir)) {
for (const entry of fs.readdirSync(nestedGsdDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true });
}
}
}
}
// 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') {
// Claude global / Qwen: commands/gsd/ is a legacy location (global Claude
// uses skills/ now; Qwen always uses skills/). Remove whole directory.
// Claude local: commands/gsd/ is the primary current location — skip here,
// let layout's _removeGsdEntries handle gsd-prefixed file removal.
// #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 commands/gsd/ is the primary current location — skip here.
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 });
}
}
}
}
// 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);
}
}
function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
// Legacy cleanup before layout-driven writes
_runLegacyInstallMigrations(runtime, configDir, scope);
const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope);
// Compute pathPrefix once for the rewrite step (same derivation as the
// top-level install() function).
const _resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
const _homeDir = os.homedir().replace(/\\/g, '/');
const pathPrefix = computePathPrefix({
isGlobal: scope === 'global',
isOpencode: runtime === 'opencode',
isWindowsHost: process.platform === 'win32',
resolvedTarget: _resolvedTarget,
homeDir: _homeDir,
});
for (const kind of layout.kinds) {
const staged = kind.stage(resolvedProfile);
if (kind.kind === 'skills') {
applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix);
}
const dest = path.join(layout.configDir, kind.destSubpath);
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).
//
// For prefix='' (Hermes): _removeGsdEntries wipes the entire dest dir (skills/gsd/).
// Preserve every subdir that is NOT in the staged set — those are user-added dirs
// (e.g. user-content/) that GSD does not manage.
//
// For prefix='gsd-' (others): _removeGsdEntries removes only gsd-* entries.
// Non-gsd-* user dirs (e.g. my-custom-skill/) are untouched. Only 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>
if (kind.prefix === '') {
// Hermes: wipes entire dest dir — preserve anything not in staged.
const stagedNames = fs.existsSync(staged)
? new Set(fs.readdirSync(staged, { withFileTypes: true })
.filter(e => e.isDirectory()).map(e => e.name))
: new Set();
for (const entry of fs.readdirSync(dest, { withFileTypes: true })) {
if (!entry.isDirectory() || stagedNames.has(entry.name)) continue;
const snap = _snapshotDir(path.join(dest, entry.name));
if (snap.size > 0) toPreserve.set(entry.name, snap);
}
} else {
// Non-Hermes: only 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(staged, dest, kind);
// 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(staged, dest, kind);
}
}
}
/**
* 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);
for (const kind of layout.kinds) {
const dest = path.join(layout.configDir, kind.destSubpath);
_removeGsdEntries(dest, kind);
}
// #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(/~\/\.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));
// #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)
const processed = convertClaudeToGeminiMarkdown(content, { isCommand });
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, 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, '.windsurf/skills/');
jsContent = jsContent.replace(/CLAUDE\.md/g, '.windsurf/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-context-monitor.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
const targetDir = isGlobal
? getGlobalDir(runtime, explicitConfigDir)
: 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 === 'codebuddy') runtimeLabel = 'CodeBuddy';
console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
// Check if target directory exists
if (!fs.existsSync(targetDir)) {
console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
console.log(` Nothing to uninstall.\n`);
return;
}
let removedCount = 0;
// 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`);
}
}
// 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`);
}
}
}
// 1c. Claude local: remove commands/gsd/ (primary local install location).
// The layout's _removeGsdEntries uses the 'gsd-' prefix which applies to
// flat command dirs (OpenCode/Kilo). Claude local files use no prefix inside
// the namespaced directory, so layout does not remove them. Handle inline.
// Preserve dev-preferences.md across the wipe (#1423).
if (!isGlobal && runtime === 'claude') {
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. 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 get-shit-done directory
const gsdDir = path.join(targetDir, 'get-shit-done');
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 get-shit-done/`);
// Restore user-generated files
if (preservedProfile) {
try {
fs.mkdirSync(gsdDir, { recursive: true });
fs.writeFileSync(userProfilePath, preservedProfile);
console.log(` ${green}✓${reset} Preserved get-shit-done/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)`);
}
}
}
// 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)
for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool']) {
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;
}
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('get-shit-done')) {
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('get-shit-done')) {
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 get-shit-done 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
? getGlobalDir('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/get-shit-done/*'
: `${opencodeConfigDir.replace(/\\/g, '/')}/get-shit-done/*`;
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 get-shit-done 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
? getGlobalDir('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/get-shit-done/*'
: `${kiloConfigDir.replace(/\\/g, '/')}/get-shit-done/*`;
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 isHermes = runtime === 'hermes';
const gsdDir = path.join(configDir, 'get-shit-done');
const commandsDir = path.join(configDir, 'commands', 'gsd');
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['get-shit-done/' + rel] = hash;
}
// Record commands/gsd/ for any runtime that emits it (Gemini globally,
// Claude Code locally — see #2923). Manifest must reflect everything on
// disk so saveLocalPatches() can detect user edits and so per-runtime
// assertions about minimal-mode emit can read manifest.files instead of
// re-walking the dir.
if (fs.existsSync(commandsDir)) {
const cmdHashes = generateManifest(commandsDir);
for (const [rel, hash] of Object.entries(cmdHashes)) {
manifest.files['commands/gsd/' + rel] = hash;
}
}
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)) {
// Hermes uses prefix '' (bare stem names); all others use 'gsd-'
const skillListPrefix = isHermes ? '' : '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 (fs.existsSync(agentsDir)) {
for (const file of fs.readdirSync(agentsDir)) {
if (file.startsWith('gsd-') && file.endsWith('.md')) {
manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file));
}
}
}
// Track .clinerules file in manifest for Cline installs
if (isCline) {
const clinerulesDest = path.join(configDir, '.clinerules');
if (fs.existsSync(clinerulesDest)) {
manifest.files['.clinerules'] = fileHash(clinerulesDest);
}
}
// 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) {
const hooksDir = path.join(configDir, 'hooks');
if (fs.existsSync(hooksDir)) {
for (const file of fs.readdirSync(hooksDir)) {
if (file.startsWith('gsd-') && (file.endsWith('.js') || file.endsWith('.sh'))) {
manifest.files['hooks/' + file] = fileHash(path.join(hooksDir, file));
}
}
// 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));
}
}
}
}
}
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: get-shit-done/, 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[`get-shit-done/${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)'
: '/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 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 dirName = getDirName(runtime);
const src = path.join(__dirname, '..');
// 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.
const targetDir = isGlobal
? getGlobalDir(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>get-shit-done/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 path (stageSkillsForMode keeps strict core allowlist)
// 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.
//
// Note: --minimal uses stageSkillsForMode (back-compat: strict allowlist, no closure).
// Named profiles (--profile=X or marker-driven) use resolveProfile() for transitive closure.
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. This allows
// installRuntimeArtifacts to use stageSkillsForProfile uniformly across all
// profile modes without a null sentinel.
const _commandsDir = path.join(src, 'commands', 'gsd');
const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir);
const _resolvedProfile = resolveProfile({
modes: [_activeProfileName],
manifest: _skillsManifest,
});
// Unified staging function: for --minimal uses stageSkillsForMode (back-compat);
// for named profiles uses stageSkillsForProfile (new API with transitive closure).
function _stageSkills(commandsGsdDir) {
if (_isCoreProfileAlias) return stageSkillsForMode(commandsGsdDir, _effectiveInstallMode);
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 (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 get-shit-done/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, 'get-shit-done', '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/, get-shit-done/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 */ }
}
// get-shit-done/VERSION
const _earlyVersionPath = path.join(targetDir, 'get-shit-done', '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');
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: no-op (cline layout has empty kinds[])
// 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.
const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf ||
isAugment || isTrae || isCodebuddy || isQwen || isHermes ||
(runtime === 'claude' && isGlobal);
if (_isSkillsRuntime) {
// Layout-driven install for skills-based runtimes (full and minimal modes)
const scope = isGlobal ? 'global' : 'local';
installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile);
// 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: '' — skill dirs have bare stem names (no gsd- prefix)
const count = fs.readdirSync(hermesSkillsDir, { withFileTypes: true })
.filter(e => e.isDirectory()).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 {
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-*');
}
}
} 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-*');
}
} else if (isCline) {
// Cline is rules-based — commands are embedded in .clinerules (generated below).
// No skills/commands directory needed. Engine is installed via copyWithPathReplacement.
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: commands/gsd/ format — Claude Code reads local project
// commands from .claude/commands/gsd/, not .claude/skills/
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')) {
const count = fs.readdirSync(gsdDest).filter(f => f.endsWith('.md')).length;
console.log(` ${green}✓${reset} Installed ${count} commands to commands/gsd/`);
} else {
failures.push('commands/gsd');
}
// 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 get-shit-done skill with path replacement
// Preserve user-generated files before the wipe-and-copy so they survive re-install
const skillSrc = path.join(src, 'get-shit-done');
const skillDest = path.join(targetDir, 'get-shit-done');
const savedGsdArtifacts = preserveUserArtifacts(skillDest, USER_OWNED_ARTIFACTS);
copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal);
restoreUserArtifacts(skillDest, savedGsdArtifacts);
if (verifyInstalled(skillDest, 'get-shit-done')) {
console.log(` ${green}✓${reset} Installed workflow assets`);
} else {
failures.push('get-shit-done');
}
// Copy shared manifests into the get-shit-done payload
// at the co-located path that CJS modules resolve first:
// get-shit-done/bin/shared/*.json
//
// This source now lives under get-shit-done/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, 'get-shit-done', 'bin', 'shared', fileName);
const sharedDest = path.join(skillDest, 'bin', 'shared', fileName);
const displayPath = `get-shit-done/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(`get-shit-done/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 (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);
}
// #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, 'get-shit-done', '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, 'get-shit-done', '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) {
// 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 skip hooks entirely, so they must not
// receive the hooks/lib/ helpers either — 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 && 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, ...)`);
}
// Clear stale update cache so next session re-evaluates hook versions
// Cache lives at ~/.cache/gsd/ (see hooks/gsd-check-update.js line 35-36)
const updateCacheFile = path.join(os.homedir(), '.cache', 'gsd', 'gsd-update-check.json');
try { fs.unlinkSync(updateCacheFile); } catch (e) { /* cache may not exist yet */ }
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
if (runtime !== 'claude') {
const leakedPaths = [];
function scanForLeakedPaths(dir) {
if (!fs.existsSync(dir)) return;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (err) {
if (err.code === 'EPERM' || err.code === 'EACCES') {
return; // skip inaccessible directories
}
throw err;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
scanForLeakedPaths(fullPath);
} else if ((entry.name.endsWith('.md') || entry.name.endsWith('.toml')) && entry.name !== 'CHANGELOG.md') {
let content;
try {
content = fs.readFileSync(fullPath, 'utf8');
} catch (err) {
if (err.code === 'EPERM' || err.code === 'EACCES') {
continue; // skip inaccessible files
}
throw err;
}
const matches = content.match(/(?:~|\$HOME)\/\.claude\b/g);
if (matches) {
leakedPaths.push({ file: fullPath.replace(targetDir + '/', ''), count: matches.length });
}
}
}
}
scanForLeakedPaths(targetDir);
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 (isCodex && !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
// get-shit-done/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. get-shit-done/VERSION
const _rollbackVersionPath = path.join(targetDir, 'get-shit-done', '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);
} 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`);
} 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).
// Codex primarily needs gsd-check-update.js for the SessionStart update-check hook.
// 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'];
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)`);
}
}
}
} 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 (isCopilot) {
// Generate copilot-instructions.md
const templatePath = path.join(targetDir, 'get-shit-done', '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`);
}
// Copilot: no settings.json, no hooks, no statusline (like Codex)
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isCursor) {
// Cursor uses skills — no config.toml, no settings.json hooks needed
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isWindsurf) {
// Windsurf uses skills — no config.toml, no settings.json hooks needed
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isTrae) {
// Trae uses skills — no settings.json hooks needed
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isCline) {
// Cline uses .clinerules — generate a rules file with GSD system instructions
const clinerulesDest = path.join(targetDir, '.clinerules');
const clinerules = [
'# GSD Core — Git. Ship. Done.',
'',
'- GSD workflows live in `get-shit-done/workflows/`. Load the relevant workflow when',
' the user runs a `/gsd-*` command.',
'- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
'- GSD tools are at `get-shit-done/bin/gsd-tools.cjs`. Run with `node`.',
'- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
'- Do not apply GSD workflows unless the user explicitly asks for them.',
'- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
' step to the user using Cline\'s ask_user tool after completing it.',
].join('\n') + '\n';
fs.writeFileSync(clinerulesDest, clinerules);
console.log(` ${green}✓${reset} Wrote .clinerules`);
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).
// Gemini and Antigravity use AfterTool instead of PostToolUse for post-tool hooks
const postToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? '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) => localBashRunner === null
? null
: projectShellCommandText({
runnerToken: localBashRunner,
argTokens: [`${localPrefix}/hooks/${hookFile}`],
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');
// #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`);
}
}
// Configure SessionStart hook for update checking (skip for opencode)
if (!isOpencode && !isKilo) {
if (!settings.hooks) {
settings.hooks = {};
}
if (!settings.hooks.SessionStart) {
settings.hooks.SessionStart = [];
}
const hasGsdUpdateHook = settings.hooks.SessionStart.some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-check-update'))
);
// Guard: only register if the hook file was actually installed (#1754).
// When hooks/dist/ is missing from the npm package (as in v1.32.0), the
// copy step produces no files but the registration step ran unconditionally,
// causing "hook error" on every tool invocation.
const checkUpdateFile = path.join(targetDir, 'hooks', 'gsd-check-update.js');
if (!hasGsdUpdateHook && fs.existsSync(checkUpdateFile) && updateCheckCommand) {
settings.hooks.SessionStart.push({
hooks: [
{
type: 'command',
command: updateCheckCommand
}
]
});
console.log(` ${green}✓${reset} Configured update check hook`);
} else if (!hasGsdUpdateHook && !fs.existsSync(checkUpdateFile)) {
console.warn(` ${yellow}⚠${reset} Skipped update check hook — gsd-check-update.js not found at target`);
}
// Configure post-tool hook for context window monitoring
if (!settings.hooks[postToolEvent]) {
settings.hooks[postToolEvent] = [];
}
const hasContextMonitorHook = settings.hooks[postToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
);
const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
if (!hasContextMonitorHook && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
settings.hooks[postToolEvent].push({
matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task',
hooks: [
{
type: 'command',
command: contextMonitorCommand,
timeout: 10
}
]
});
console.log(` ${green}✓${reset} Configured context window monitor hook`);
} else if (!hasContextMonitorHook && !fs.existsSync(contextMonitorFile)) {
console.warn(` ${yellow}⚠${reset} Skipped context monitor hook — gsd-context-monitor.js not found at target`);
} else {
// Migrate existing context monitor hooks: add matcher and timeout if missing
for (const entry of settings.hooks[postToolEvent]) {
if (entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))) {
let migrated = false;
if (!entry.matcher) {
entry.matcher = 'Bash|Edit|Write|MultiEdit|Agent|Task';
migrated = true;
}
for (const h of entry.hooks) {
if (h.command && h.command.includes('gsd-context-monitor') && !h.timeout) {
h.timeout = 10;
migrated = true;
}
}
if (migrated) {
console.log(` ${green}✓${reset} Updated context monitor hook (added matcher + timeout)`);
}
}
}
}
// Configure PreToolUse hook for prompt injection detection
// Gemini and Antigravity use BeforeTool instead of PreToolUse for pre-tool hooks
const preToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse';
if (!settings.hooks[preToolEvent]) {
settings.hooks[preToolEvent] = [];
}
const hasPromptGuardHook = settings.hooks[preToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-prompt-guard'))
);
const promptGuardFile = path.join(targetDir, 'hooks', 'gsd-prompt-guard.js');
if (!hasPromptGuardHook && fs.existsSync(promptGuardFile) && promptGuardCommand) {
settings.hooks[preToolEvent].push({
matcher: 'Write|Edit',
hooks: [
{
type: 'command',
command: promptGuardCommand,
timeout: 5
}
]
});
console.log(` ${green}✓${reset} Configured prompt injection guard hook`);
} else if (!hasPromptGuardHook && !fs.existsSync(promptGuardFile)) {
console.warn(` ${yellow}⚠${reset} Skipped prompt guard hook — gsd-prompt-guard.js not found at target`);
}
// Configure PreToolUse hook for read-before-edit guidance (#1628)
// Prevents infinite retry loops when non-Claude models attempt to edit
// files without reading them first. Advisory-only — does not block.
const hasReadGuardHook = settings.hooks[preToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-read-guard'))
);
const readGuardFile = path.join(targetDir, 'hooks', 'gsd-read-guard.js');
if (!hasReadGuardHook && fs.existsSync(readGuardFile) && readGuardCommand) {
settings.hooks[preToolEvent].push({
matcher: 'Write|Edit',
hooks: [
{
type: 'command',
command: readGuardCommand,
timeout: 5
}
]
});
console.log(` ${green}✓${reset} Configured read-before-edit guard hook`);
} else if (!hasReadGuardHook && !fs.existsSync(readGuardFile)) {
console.warn(` ${yellow}⚠${reset} Skipped read guard hook — gsd-read-guard.js not found at target`);
}
// Configure PostToolUse hook for read-time prompt injection scanning (#2201)
// Scans content returned by the Read tool for injection patterns, including
// summarisation-specific patterns that survive context compression.
const hasReadInjectionScannerHook = settings.hooks[postToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-read-injection-scanner'))
);
const readInjectionScannerFile = path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js');
if (!hasReadInjectionScannerHook && fs.existsSync(readInjectionScannerFile) && readInjectionScannerCommand) {
settings.hooks[postToolEvent].push({
matcher: 'Read',
hooks: [
{
type: 'command',
command: readInjectionScannerCommand,
timeout: 5
}
]
});
console.log(` ${green}✓${reset} Configured read injection scanner hook`);
} else if (!hasReadInjectionScannerHook && !fs.existsSync(readInjectionScannerFile)) {
console.warn(` ${yellow}⚠${reset} Skipped read injection scanner hook — gsd-read-injection-scanner.js not found at target`);
}
// Community hooks — registered on install but opt-in at runtime.
// Each hook checks .planning/config.json for hooks.community: true
// and exits silently (no-op) if not enabled. This lets users enable
// them per-project by adding: "hooks": { "community": true }
// Configure workflow guard hook (opt-in via hooks.workflow_guard: true)
// Detects file edits outside GSD workflow context and advises using
// /gsd-quick or /gsd-fast for state-tracked changes. Also hard-blocks
// unsafe Bash commands that violate worktree-agent isolation.
const workflowGuardCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-workflow-guard.js', hookOpts)
: localCmd('gsd-workflow-guard.js');
const workflowGuardMatcher = 'Bash|Edit|Write|MultiEdit';
const workflowGuardHookEntry = settings.hooks[preToolEvent].find(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-workflow-guard'))
);
const hasWorkflowGuardHook = Boolean(workflowGuardHookEntry);
const workflowGuardFile = path.join(targetDir, 'hooks', 'gsd-workflow-guard.js');
if (hasWorkflowGuardHook && workflowGuardHookEntry.matcher !== workflowGuardMatcher) {
workflowGuardHookEntry.matcher = workflowGuardMatcher;
console.log(` ${green}✓${reset} Updated workflow guard hook matcher`);
} else if (!hasWorkflowGuardHook && fs.existsSync(workflowGuardFile) && workflowGuardCommand) {
settings.hooks[preToolEvent].push({
matcher: workflowGuardMatcher,
hooks: [
{
type: 'command',
command: workflowGuardCommand,
timeout: 5
}
]
});
console.log(` ${green}✓${reset} Configured workflow guard hook (opt-in via hooks.workflow_guard)`);
} else if (!hasWorkflowGuardHook && !fs.existsSync(workflowGuardFile)) {
console.warn(` ${yellow}⚠${reset} Skipped workflow guard hook — gsd-workflow-guard.js not found at target`);
}
// Configure commit validation hook (Conventional Commits enforcement, opt-in)
const validateCommitCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts)
: localShellCmd('gsd-validate-commit.sh');
const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-validate-commit'))
);
// Guard: only register if the .sh file was actually installed. If the npm package
// omitted the file (as happened in v1.32.0, bug #1817), registering a missing hook
// causes a hook error on every Bash tool invocation.
const validateCommitFile = path.join(targetDir, 'hooks', 'gsd-validate-commit.sh');
if (!hasValidateCommitHook && fs.existsSync(validateCommitFile) && validateCommitCommand) {
settings.hooks[preToolEvent].push({
matcher: 'Bash',
hooks: [
{
type: 'command',
command: validateCommitCommand,
timeout: 5
}
]
});
console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`);
} else if (!hasValidateCommitHook && !fs.existsSync(validateCommitFile)) {
console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — gsd-validate-commit.sh not found at target`);
} else if (!hasValidateCommitHook && !validateCommitCommand) {
console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — Bash executable path unavailable (#3393)`);
}
// Configure graphify auto-update hook (opt-in via graphify.auto_update; default false, #3347).
// PostToolUse Bash matcher — fires after git commit/merge/pull/rebase --continue/cherry-pick
// on the default branch, dispatches `graphify update .` in a detached subprocess. No-op unless
// .planning/config.json has BOTH graphify.enabled=true AND graphify.auto_update=true.
const graphifyUpdateCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-graphify-update.sh', hookOpts)
: localShellCmd('gsd-graphify-update.sh');
const hasGraphifyUpdateHook = settings.hooks[postToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-graphify-update'))
);
const graphifyUpdateFile = path.join(targetDir, 'hooks', 'gsd-graphify-update.sh');
if (!hasGraphifyUpdateHook && fs.existsSync(graphifyUpdateFile) && graphifyUpdateCommand) {
settings.hooks[postToolEvent].push({
matcher: 'Bash',
hooks: [
{
type: 'command',
command: graphifyUpdateCommand,
timeout: 5
}
]
});
console.log(` ${green}✓${reset} Configured graphify auto-update hook (opt-in via graphify.auto_update)`);
} else if (!hasGraphifyUpdateHook && !fs.existsSync(graphifyUpdateFile)) {
console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — gsd-graphify-update.sh not found at target`);
} else if (!hasGraphifyUpdateHook && !graphifyUpdateCommand) {
console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — Bash executable path unavailable (#3393)`);
}
// Configure session state orientation hook (opt-in)
const sessionStateCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts)
: localShellCmd('gsd-session-state.sh');
const hasSessionStateHook = settings.hooks.SessionStart.some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-session-state'))
);
const sessionStateFile = path.join(targetDir, 'hooks', 'gsd-session-state.sh');
if (!hasSessionStateHook && fs.existsSync(sessionStateFile) && sessionStateCommand) {
settings.hooks.SessionStart.push({
hooks: [
{
type: 'command',
command: sessionStateCommand
}
]
});
console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`);
} else if (!hasSessionStateHook && !fs.existsSync(sessionStateFile)) {
console.warn(` ${yellow}⚠${reset} Skipped session state hook — gsd-session-state.sh not found at target`);
} else if (!hasSessionStateHook && !sessionStateCommand) {
console.warn(` ${yellow}⚠${reset} Skipped session state hook — Bash executable path unavailable (#3393)`);
}
// Configure phase boundary detection hook (opt-in)
const phaseBoundaryCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts)
: localShellCmd('gsd-phase-boundary.sh');
const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-phase-boundary'))
);
const phaseBoundaryFile = path.join(targetDir, 'hooks', 'gsd-phase-boundary.sh');
if (!hasPhaseBoundaryHook && fs.existsSync(phaseBoundaryFile) && phaseBoundaryCommand) {
settings.hooks[postToolEvent].push({
matcher: 'Write|Edit',
hooks: [
{
type: 'command',
command: phaseBoundaryCommand,
timeout: 5
}
]
});
console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`);
} else if (!hasPhaseBoundaryHook && !fs.existsSync(phaseBoundaryFile)) {
console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — gsd-phase-boundary.sh not found at target`);
} else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
}
}
// 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'));
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';
if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae) {
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 && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline) {
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 && h.command && h.command.includes('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)`);
}
}
}
}
// 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 (!isCodex && !isCopilot && !isKilo && !isCursor && !isWindsurf && !isTrae && !isCline) {
writeSettings(settingsPath, validateHookFields(settings));
}
// Configure OpenCode permissions
if (isOpencode && !process.env.GSD_TEST_MODE) {
configureOpencodePermissions(isGlobal, configDir);
}
// Configure Kilo permissions
if (isKilo) {
configureKiloPermissions(isGlobal, configDir);
}
// For non-Claude runtimes, set resolve_model_ids: "omit" in ~/.gsd/defaults.json
// so resolveModelInternal() returns '' instead of Claude aliases (opus/sonnet/haiku)
// that the runtime can't resolve. Users can still use model_overrides for explicit IDs.
// See #1156. 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 */ }
if (defaults.resolve_model_ids !== 'omit') {
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';
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';
// 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;
}
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': 'kilo',
'12': 'opencode',
'13': 'qwen',
'14': 'trae',
'15': 'windsurf'
};
const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'hermes', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf'];
const ALL_RUNTIMES_OPTION = '16';
/**
* 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}) Kilo ${dim}(~/.config/kilo)${reset}
${cyan}12${reset}) OpenCode ${dim}(~/.config/opencode)${reset}
${cyan}13${reset}) Qwen Code ${dim}(~/.qwen)${reset}
${cyan}14${reset}) Trae ${dim}(~/.trae)${reset}
${cyan}15${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset}
${cyan}16${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 16 ("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 = getGlobalDir(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;
}
/**
* 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;
}
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('');
}
/**
* 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) {
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,
getCodexSkillAdapterHeader,
convertClaudeCommandToCursorSkill,
convertClaudeAgentToCursorAgent,
convertClaudeToGeminiMarkdown,
convertSlashCommandsToGeminiMentions,
_resetGsdCommandRoster,
convertClaudeToGeminiAgent,
convertClaudeAgentToCodexAgent,
generateCodexAgentToml,
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,
convertClaudeCommandToCodexSkill,
convertClaudeToOpencodeFrontmatter,
convertClaudeToKiloFrontmatter,
configureOpencodePermissions,
neutralizeAgentReferences,
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,
convertClaudeToAntigravityContent,
convertClaudeCommandToAntigravitySkill,
convertClaudeAgentToAntigravityAgent,
convertClaudeCommandToClaudeSkill,
skillFrontmatterName,
convertClaudeToWindsurfMarkdown,
convertClaudeCommandToWindsurfSkill,
convertClaudeAgentToWindsurfAgent,
convertClaudeToAugmentMarkdown,
convertClaudeCommandToAugmentSkill,
convertClaudeAgentToAugmentAgent,
convertClaudeToTraeMarkdown,
convertClaudeCommandToTraeSkill,
convertClaudeAgentToTraeAgent,
convertClaudeToCodebuddyMarkdown,
convertClaudeCommandToCodebuddySkill,
convertClaudeAgentToCodebuddyAgent,
convertClaudeToCliineMarkdown,
convertClaudeAgentToClineAgent,
writeManifest,
saveLocalPatches,
reportLocalPatches,
validateHookFields,
preserveUserArtifacts,
restoreUserArtifacts,
migrateLegacyDevPreferencesToSkill,
populatePristineDir,
USER_OWNED_ARTIFACTS,
finishInstall,
homePathCoveredByRc,
maybeSuggestPathExport,
runtimeMap,
allRuntimes,
GSD_UNINSTALL_HOOKS,
parseRuntimeInput,
buildRuntimePromptText,
buildUpdateBannerPromptText,
parseUpdateBannerInput,
buildUpdateBannerHookEntry,
buildHookCommand,
normalizeNodePath,
resolveNodeRunner,
rewriteLegacyManagedNodeHookCommands,
buildCodexHookBlock,
rewriteLegacyCodexHookBlock,
buildCodexHookWindowsShimIR,
ensureCodexHooksJsonSessionStart,
readGsdCommandNames,
installRuntimeArtifacts,
uninstallRuntimeArtifacts,
parseConfigDirFromArgs,
};
// Main logic — only run when not loaded as a module for testing
if (require.main === module && !process.env.GSD_TEST_MODE) {
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 globalDir = getGlobalDir(runtimeArg, null);
// Hermes nests GSD skills under skills/gsd/ as a single category (#2841).
// Other runtimes use a flat skills/ root.
const skillsRoot = runtimeArg === 'hermes'
? path.join(globalDir, 'skills', 'gsd')
: path.join(globalDir, 'skills');
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