* fix(#683): auto-degrade phase execution to sequential on worktree base mismatch Claude Code forks worktree-isolated executors off the repository default branch (origin/HEAD), not the orchestrator's HEAD. Running /gsd-execute-phase on a branch diverged from the default (unmerged milestone/feature branch) left every executor without the phase's plan files and tripped the worktree-branch-check guard with `exit 42` — 100% reproducible, all OSes. - New module src/worktree-base-ref.cts: HEAD-vs-fork-base drift detection (origin/HEAD with symbolic-ref fallback) and no-clobber worktree.baseRef management, exposed as `worktree base-check` / `worktree set-baseref`. - execute-phase.md: pre-dispatch, for Claude Code with worktrees enabled, auto-degrades the run to sequential on the main tree when a base mismatch is detected, recommending worktree.baseRef:"head". The exit-42 guard stays as a backstop. - Installer: fresh local Claude installs set worktree.baseRef:"head" in .claude/settings.local.json (no-clobber, respecting an explicit shared settings.json value); upgrades print an opt-in notice pointing at `gsd-tools worktree set-baseref`. - Docs: how-to guide, CLI/config reference, planning-config cross-ref. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#683): auto-apply worktree.baseRef on upgrade; gate fresh+upgrade on use_worktrees Per maintainer direction: on a local Claude Code UPGRADE, set worktree.baseRef:"head" automatically (no opt-in notice) when the project's workflow.use_worktrees is enabled, instead of merely printing a remediation notice. For consistency the FRESH path is now gated the same way: both paths compute worktrees-enabled once (bounded walk-up read of .planning/config.json, default enabled unless workflow.use_worktrees === false) and apply the no-clobber baseRef only when enabled — never overwriting an explicit value in settings.local.json or a shared settings.json. gsd-tools worktree set-baseref remains for manual use. Docs + changeset updated; tests hardened (file-exists assertions, fresh+disabled case, upgrade idempotency). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#683): measure workflow byte-budget on LF, fixing Windows-only CI failure The workflow-size-budget test failed only on Windows: git checks out the .md files as CRLF (no eol=lf in .gitattributes) and byteCount used fs.statSync().size (raw on-disk bytes), counting an extra \r per line. That inflated execute-phase.md — the XL high-water-mark file pinned near its ceiling by the tighten-only ratchet — from 88492 LF bytes to ~90245 on Windows, over the 90000 XL ceiling, while passing on the LF-checkout Mac/Linux runners. The ceilings are explicitly "calibrated against raw `wc -c`" on an LF checkout, so the measurement should be LF-based on every platform. byteCount now reads the file and counts Buffer.byteLength after stripping CR, making the budget platform-independent (a no-op on LF checkouts; verified statSync === normalized for all 88 workflow files). No ceilings changed. Added a regression test asserting CRLF and LF content of the same file count identically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#683): make worktree-base-ref test path mocks Windows-safe (path.join) tests/worktree-base-ref.test.cjs keyed its injected readFile/writeFile mocks (and a few expected `file` values) with forward-slash template literals like `${claudeDir}/settings.local.json`. The module composes those paths with path.join(), which emits backslashes on Windows, so the mock keys never matched the module's lookup → readFile returned null → resolveEffectiveBaseRef / cmdWorktreeBaseCheck / cmdWorktreeSetBaseRef (and the JSONC variants) failed on the Windows full-test runner only (they passed on Mac/Linux, and the install tests passed because they use the real filesystem). The module is correct; only the test fixtures hardcoded '/'. All mock keys and path assertions now use path.join(base, ...) mirroring the module, so they match on every platform (no-op on POSIX). 19 path references across 16 lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
11170 lines
458 KiB
JavaScript
Executable File
11170 lines
458 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const readline = require('readline');
|
||
const crypto = require('crypto');
|
||
const {
|
||
isManagedHookBasename,
|
||
isManagedHookCommand,
|
||
projectLocalHookPrefix,
|
||
projectLegacySettingsHookCommand,
|
||
projectManagedHookCommand,
|
||
projectPathActionProjection,
|
||
projectPortableHookBaseDir,
|
||
projectPersistentPathExportActions,
|
||
projectShellCommandText,
|
||
projectCodexHookTomlCommand,
|
||
shellHookOmitsBashRunner,
|
||
buildLocalShellHookCommand,
|
||
} = require('../gsd-core/bin/lib/shell-command-projection.cjs');
|
||
|
||
// Bidirectional GSD slash-command namespace transformer (#3583).
|
||
// Required at module scope so the command list can be computed once per install
|
||
// and passed down to convertClaudeCommandToClaudeSkill, avoiding repeated
|
||
// fs.readdirSync + RegExp work for every skill.
|
||
const {
|
||
transformContentToHyphen,
|
||
readCmdNames: readGsdCommandNames,
|
||
} = require(path.join(__dirname, '..', 'scripts', 'fix-slash-commands.cjs'));
|
||
const {
|
||
resolveAntigravityGlobalDir,
|
||
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||
const {
|
||
applyWorktreeBaseRef,
|
||
readBaseRefFromSettings,
|
||
} = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
|
||
|
||
/**
|
||
* Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
|
||
* verbatim (only branding swaps, no namespace conversion), so retired
|
||
* `/gsd:<cmd>` colon refs leak into installed agent prose. Sibling fixes
|
||
* #3583 / #3629 covered SKILL.md bodies, #3584 / #3606 covered runtime
|
||
* emissions — this is the agent-body surface (#3677).
|
||
*
|
||
* Explicit allow-list rather than deny-list so unknown / future runtimes
|
||
* default to "no rewrite" (better to leak than to mangle a runtime whose
|
||
* namespace behavior we haven't verified).
|
||
*/
|
||
const HYPHEN_NAME_AGENT_RUNTIMES = new Set(['claude', 'qwen', 'hermes']);
|
||
|
||
/**
|
||
* #3677 predicate — true when an agent body needs `/gsd:<cmd>` → `/gsd-<cmd>`
|
||
* normalization at install time.
|
||
*/
|
||
function shouldNormalizeHyphenNamespaceInAgentBody(runtime) {
|
||
if (typeof runtime !== 'string' || runtime === '') return false;
|
||
return HYPHEN_NAME_AGENT_RUNTIMES.has(runtime);
|
||
}
|
||
|
||
/**
|
||
* #3677 helper — applies the hyphen-namespace transform iff the predicate
|
||
* says so. Pure function; safe to call unconditionally from the install
|
||
* loop. Returns the input unchanged for runtimes that self-convert or
|
||
* intentionally keep colon refs.
|
||
*/
|
||
function normalizeAgentBodyForRuntime(content, runtime, cmdNames) {
|
||
if (!shouldNormalizeHyphenNamespaceInAgentBody(runtime)) return content;
|
||
return transformContentToHyphen(content, cmdNames);
|
||
}
|
||
|
||
// Colors
|
||
const cyan = '\x1b[36m';
|
||
const green = '\x1b[32m';
|
||
const yellow = '\x1b[33m';
|
||
const red = '\x1b[31m';
|
||
const bold = '\x1b[1m';
|
||
const dim = '\x1b[2m';
|
||
const reset = '\x1b[0m';
|
||
|
||
// Codex config.toml constants
|
||
const GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by gsd-core installer';
|
||
const GSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# GSD codex_hooks ownership: ';
|
||
// Codex's hook-enabling feature flag (issue #3566). Codex itself marks
|
||
// `codex_hooks` as a `legacy_key` in codex-rs/features/src/legacy.rs; the
|
||
// canonical current key under [features] is `hooks`. The installer always
|
||
// emits the canonical key going forward, recognizes legacy aliases as
|
||
// equivalent during reinstall, and migrates them forward on rewrite. The
|
||
// audit-marker string above is intentionally unchanged so existing
|
||
// installs' ownership lines continue to round-trip.
|
||
const CODEX_HOOKS_FEATURE_KEY = 'hooks';
|
||
const CODEX_HOOKS_FEATURE_LEGACY_KEYS = ['codex_hooks'];
|
||
const CODEX_HOOKS_FEATURE_ALL_KEYS = [CODEX_HOOKS_FEATURE_KEY, ...CODEX_HOOKS_FEATURE_LEGACY_KEYS];
|
||
function isCodexHooksFeatureKey(key) {
|
||
return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key);
|
||
}
|
||
|
||
// Copilot instructions marker constants
|
||
const GSD_COPILOT_INSTRUCTIONS_MARKER = '<!-- GSD Configuration \u2014 managed by gsd-core installer -->';
|
||
const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = '<!-- /GSD Configuration -->';
|
||
|
||
// GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
|
||
// git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
|
||
const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
|
||
|
||
const CODEX_AGENT_SANDBOX = {
|
||
'gsd-executor': 'workspace-write',
|
||
'gsd-planner': 'workspace-write',
|
||
'gsd-phase-researcher': 'workspace-write',
|
||
'gsd-project-researcher': 'workspace-write',
|
||
'gsd-research-synthesizer': 'workspace-write',
|
||
'gsd-verifier': 'workspace-write',
|
||
'gsd-codebase-mapper': 'workspace-write',
|
||
'gsd-roadmapper': 'workspace-write',
|
||
'gsd-debugger': 'workspace-write',
|
||
'gsd-plan-checker': 'read-only',
|
||
'gsd-integration-checker': 'read-only',
|
||
};
|
||
|
||
// Copilot tool name mapping — Claude Code tools to GitHub Copilot tools
|
||
// Tool mapping applies ONLY to agents, NOT to skills (per CONTEXT.md decision)
|
||
const claudeToCopilotTools = {
|
||
Read: 'read',
|
||
Write: 'edit',
|
||
Edit: 'edit',
|
||
Bash: 'execute',
|
||
Grep: 'search',
|
||
Glob: 'search',
|
||
Task: 'agent',
|
||
WebSearch: 'web',
|
||
WebFetch: 'web',
|
||
TodoWrite: 'todo',
|
||
AskUserQuestion: 'ask_user',
|
||
SlashCommand: 'skill',
|
||
};
|
||
|
||
// Get version from package.json
|
||
const pkg = require('../package.json');
|
||
|
||
// #2517 — runtime-aware tier resolution shared with core.cjs.
|
||
// Hoisted to top with absolute __dirname-based paths so `gsd install codex` works
|
||
// when invoked via npm global install (cwd is the user's project, not the gsd repo
|
||
// root). Inline `require('../gsd-core/...')` from inside install functions
|
||
// works only because Node resolves it relative to the install.js file regardless
|
||
// of cwd, but keeping the require at the top makes the dependency explicit and
|
||
// surfaces resolution failures at process start instead of at first install call.
|
||
const _gsdLibDir = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||
const { MODEL_PROFILES: GSD_MODEL_PROFILES } = require(path.join(_gsdLibDir, 'model-profiles.cjs'));
|
||
const {
|
||
RUNTIME_PROFILE_MAP: GSD_RUNTIME_PROFILE_MAP,
|
||
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,
|
||
'..',
|
||
'gsd-core',
|
||
'bin',
|
||
'shared',
|
||
'config-defaults.manifest.json'
|
||
);
|
||
let manifestData;
|
||
try {
|
||
manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||
} catch (_err) {
|
||
// Fail loudly — a missing manifest is a broken install, not a soft degradation.
|
||
throw new Error(
|
||
`gsd install: cannot load config-defaults.manifest.json at ${manifestPath}: ${_err.message}`
|
||
);
|
||
}
|
||
|
||
const tierDefaults =
|
||
(manifestData.effort &&
|
||
manifestData.effort.routing_tier_defaults &&
|
||
typeof manifestData.effort.routing_tier_defaults === 'object' &&
|
||
!Array.isArray(manifestData.effort.routing_tier_defaults))
|
||
? manifestData.effort.routing_tier_defaults
|
||
: { light: 'low', standard: 'high', heavy: 'xhigh' }; // guard: unreachable if manifest is valid
|
||
|
||
const effortDefault =
|
||
(manifestData.effort && typeof manifestData.effort.default === 'string')
|
||
? manifestData.effort.default
|
||
: 'high'; // guard: unreachable if manifest is valid
|
||
|
||
_gsdEffortCatalogCache = {
|
||
AGENT_DEFAULT_TIERS,
|
||
renderEffortForRuntime,
|
||
EFFORT_MANIFEST_TIER_DEFAULTS: tierDefaults,
|
||
EFFORT_MANIFEST_DEFAULT: effortDefault,
|
||
};
|
||
return _gsdEffortCatalogCache;
|
||
}
|
||
|
||
const {
|
||
MINIMAL_SKILL_ALLOWLIST,
|
||
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'));
|
||
const {
|
||
planLegacyCleanup,
|
||
applyLegacyCleanup,
|
||
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'legacy-cleanup.cjs'));
|
||
const {
|
||
updateCacheFileName,
|
||
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
|
||
|
||
// Parse args
|
||
const args = process.argv.slice(2);
|
||
const hasGlobal = args.includes('--global') || args.includes('-g');
|
||
const hasLocal = args.includes('--local') || args.includes('-l');
|
||
const 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');
|
||
const hasDryRun = args.includes('--dry-run');
|
||
// --profile=<name> or --profile=<n1>,<n2> (composable); mutually exclusive with --minimal
|
||
const _profileArgRaw = (() => {
|
||
for (const arg of args) {
|
||
if (arg.startsWith('--profile=')) return arg.slice('--profile='.length);
|
||
}
|
||
return null;
|
||
})();
|
||
// Resolve active profile name:
|
||
// 1. --minimal / --core-only → 'core' (back-compat alias)
|
||
// 2. --profile=<name> → named profile
|
||
// 3. neither → 'full' (default, back-compat)
|
||
// Note: when re-running as `gsd update` the marker is read later (after
|
||
// configDir is resolved) and may override 'full' — see writeActiveProfile call below.
|
||
const _profileIsCore = _profileArgRaw === 'core';
|
||
const _requestedProfileName = (hasMinimal || _profileIsCore) ? 'core' : (_profileArgRaw || null);
|
||
|
||
if (hasMinimal && _profileArgRaw) {
|
||
console.error(` ${yellow}Cannot specify both --minimal/--core-only and --profile${reset}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// 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 (shellHookOmitsBashRunner({ platform, runtime, 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];
|
||
}
|
||
// mcp__{tavily,ref,jina,exa,firecrawl}__* use the generic MCP passthrough like exa/firecrawl;
|
||
// add explicit Copilot registry mappings when the io.github ids are confirmed (#657 follow-up)
|
||
// Default: lowercase
|
||
return claudeTool.toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Apply Copilot-specific content conversion — CONV-06 (paths) + CONV-07 (command names).
|
||
* Path mappings depend on install mode:
|
||
* Global: ~/.claude/ → ~/.copilot/, ./.claude/ → ./.github/
|
||
* Local: ~/.claude/ → ./.github/, ./.claude/ → ./.github/
|
||
* Applied to ALL Copilot content (skills, agents, engine files).
|
||
* @param {string} content - Source content to convert
|
||
* @param {boolean} [isGlobal=false] - Whether this is a global install
|
||
*/
|
||
function convertClaudeToCopilotContent(content, isGlobal = false) {
|
||
let c = content;
|
||
// CONV-06: Path replacement — most specific first to avoid substring matches.
|
||
// Handle both `~/.claude/foo` (trailing slash) and bare `~/.claude` forms in
|
||
// one pass via a capture group, matching the approach used by Antigravity,
|
||
// OpenCode, Kilo, and Codex converters (issue #2545).
|
||
if (isGlobal) {
|
||
c = c.replace(/\$HOME\/\.claude(\/|\b)/g, '$HOME/.copilot$1');
|
||
c = c.replace(/~\/\.claude(\/|\b)/g, '~/.copilot$1');
|
||
} else {
|
||
c = c.replace(/\$HOME\/\.claude\//g, '.github/');
|
||
c = c.replace(/~\/\.claude\//g, '.github/');
|
||
c = c.replace(/\$HOME\/\.claude\b/g, '.github');
|
||
c = c.replace(/~\/\.claude\b/g, '.github');
|
||
}
|
||
c = c.replace(/\.\/\.claude\//g, './.github/');
|
||
c = c.replace(/\.claude\//g, '.github/');
|
||
// CONV-07: Command name conversion (all gsd: references → gsd-)
|
||
c = c.replace(/gsd:/g, 'gsd-');
|
||
// Runtime-neutral agent name replacement (#766)
|
||
c = neutralizeAgentReferences(c, 'copilot-instructions.md');
|
||
return c;
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
// Colon-style /gsd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below).
|
||
let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => {
|
||
return `$gsd-${String(commandName).toLowerCase()}`;
|
||
});
|
||
// Convert hyphen-style command references (workflow output) to Codex $ prefix.
|
||
// A real /gsd-<cmd> MENTION is defined positively by two boundaries, so any
|
||
// in-path occurrence is excluded by construction (no denylist of preceding
|
||
// chars to maintain — see #712, supersedes the #637/#704 lookbehind treadmill):
|
||
// 1. Left boundary: opens at start-of-string, whitespace, or an inline-prose
|
||
// delimiter (backtick/quote/paren/bracket) — e.g. `/gsd-execute-phase`.
|
||
// 2. Right boundary: the command token is NOT followed by a path separator
|
||
// `/` (a path continues: `/gsd-core/bin/...`; a command does not). The
|
||
// `(?![a-z0-9/-])` also blocks regex backtracking to a shorter command.
|
||
// This converts backtick-wrapped MENTIONS (`/gsd-foo`) while leaving backtick-
|
||
// wrapped PATHS (`/gsd-core/workflows/update.md`) untouched (#712).
|
||
converted = converted.replace(/(?<=^|[\s`"'([])\/gsd-([a-z0-9-]+)(?![a-z0-9/-])/gi, (_, commandName) => {
|
||
return `$gsd-${String(commandName).toLowerCase()}`;
|
||
});
|
||
return converted;
|
||
}
|
||
|
||
function convertClaudeToCodexMarkdown(content) {
|
||
let converted = convertSlashCommandsToCodexSkillMentions(content);
|
||
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
|
||
// Remove /clear references — Codex has no equivalent command
|
||
// Handle backtick-wrapped: `\/clear` then: → (removed)
|
||
converted = converted.replace(/`\/clear`\s*,?\s*then:?\s*\n?/gi, '');
|
||
// Handle bare: /clear then: → (removed)
|
||
converted = converted.replace(/\/clear\s*,?\s*then:?\s*\n?/gi, '');
|
||
// Handle standalone /clear on its own line
|
||
converted = converted.replace(/^\s*`?\/clear`?\s*$/gm, '');
|
||
// Path replacement: .claude → .codex (#1430)
|
||
converted = converted.replace(/\$HOME\/\.claude\//g, '$HOME/.codex/');
|
||
converted = converted.replace(/~\/\.claude\//g, '~/.codex/');
|
||
converted = converted.replace(/\.\/\.claude\//g, './.codex/');
|
||
// Bare ~/.claude without trailing slash (e.g. configDir = ~/.claude)
|
||
converted = converted.replace(/\$HOME\/\.claude\b/g, '$HOME/.codex');
|
||
converted = converted.replace(/~\/\.claude\b/g, '~/.codex');
|
||
// Bare/project-relative .claude/... references (#2639). Covers strings like
|
||
// "check `.claude/skills/`" where there is no ~/, $HOME/, or ./ anchor.
|
||
// Negative lookbehind prevents double-replacing already-anchored forms and
|
||
// avoids matching inside URLs or other slash-prefixed paths.
|
||
converted = converted.replace(/(?<![A-Za-z0-9_\-./~$])\.claude\//g, '.codex/');
|
||
// `.claudeignore` → `.codexignore` (#2639). Codex honors its own ignore
|
||
// file; leaving the Claude-specific name is misleading in agent prompts.
|
||
converted = converted.replace(/\.claudeignore\b/g, '.codexignore');
|
||
// 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, 'gsd-core').replace(/\\/g, '/')}/`;
|
||
|
||
for (const file of agentEntries) {
|
||
let content = fs.readFileSync(path.join(agentsSrc, file), 'utf8');
|
||
// Replace full .claude/gsd-core prefix so path resolves to the Codex
|
||
// GSD install before generic .claude → .codex conversion rewrites it.
|
||
content = content.replace(/~\/\.claude\/gsd-core\//g, codexGsdPath);
|
||
content = content.replace(/\$HOME\/\.claude\/gsd-core\//g, codexGsdPath);
|
||
// Route TOML emit through the same full Claude→Codex conversion pipeline
|
||
// used on the `.md` emit path (#2639). Covers: slash-command rewrites,
|
||
// $ARGUMENTS → {{GSD_ARGS}}, /clear removal, anchored and bare .claude/
|
||
// paths, .claudeignore → .codexignore, and standalone "Claude" /
|
||
// CLAUDE.md neutralization via neutralizeAgentReferences(..., 'AGENTS.md').
|
||
content = convertClaudeToCodexMarkdown(content);
|
||
const { frontmatter } = extractFrontmatterAndBody(content);
|
||
const name = extractFrontmatterField(frontmatter, 'name') || file.replace('.md', '');
|
||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||
|
||
agents.push({ name, description: toSingleLine(description) });
|
||
|
||
// Pass model overrides from both per-project `.planning/config.json` and
|
||
// `~/.gsd/defaults.json` (project wins on conflict) so Codex TOML files
|
||
// embed the configured model — Codex cannot receive model inline (#2256).
|
||
// Previously only the global file was read, which silently dropped the
|
||
// per-project override the reporter had set for gsd-codebase-mapper.
|
||
// #2517 — also pass the runtime-aware tier resolver so profile tiers can
|
||
// resolve to Codex-native model IDs + reasoning_effort when `runtime: "codex"`
|
||
// is set in defaults.json.
|
||
const modelOverrides = readGsdEffectiveModelOverrides(targetDir);
|
||
// Pass `targetDir` so per-project .planning/config.json wins over global
|
||
// ~/.gsd/defaults.json — without this, the PR's headline claim that
|
||
// setting runtime in the project config reaches the Codex emit path is
|
||
// false (review finding #1).
|
||
const runtimeResolver = readGsdRuntimeProfileResolver(targetDir);
|
||
// #443 — pass unified effort config so model_reasoning_effort in the .toml
|
||
// follows the same config-driven precedence as the Claude .md effort key.
|
||
const effortCfg = readGsdEffectiveEffortConfig(targetDir);
|
||
const tomlContent = generateCodexAgentToml(name, content, modelOverrides, runtimeResolver, effortCfg);
|
||
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 gsd-core/.
|
||
*
|
||
* These files are created/refreshed by user-facing workflows (e.g.
|
||
* /gsd-profile-user) and must be preserved across reinstalls. Critically, they
|
||
* MUST be excluded from gsd-file-manifest.json — otherwise saveLocalPatches()
|
||
* will compare a refreshed file against a stale manifest hash and emit a
|
||
* spurious "locally modified GSD file" warning (bug #2771).
|
||
*
|
||
* Invariant: a file is either distribution (manifest-tracked, diff'd against
|
||
* manifest) or user artifact (preserved across installs, never diff'd). Never
|
||
* both. Both preserveUserArtifacts call sites and writeManifest must agree on
|
||
* this list, which is why it lives here as a single constant.
|
||
*
|
||
* Paths are relative to the gsd-core/ directory.
|
||
*/
|
||
const USER_OWNED_ARTIFACTS = ['USER-PROFILE.md'];
|
||
|
||
/**
|
||
* Save user-generated files from destDir to an in-memory map before a wipe.
|
||
*
|
||
* @param {string} destDir - Directory that is about to be wiped
|
||
* @param {string[]} fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve
|
||
* @returns {Map<string, string>} Map of fileName → file content (only entries that existed)
|
||
*/
|
||
function preserveUserArtifacts(destDir, fileNames) {
|
||
const saved = new Map();
|
||
for (const name of fileNames) {
|
||
const fullPath = path.join(destDir, name);
|
||
if (fs.existsSync(fullPath)) {
|
||
try {
|
||
saved.set(name, fs.readFileSync(fullPath, 'utf8'));
|
||
} catch { /* skip unreadable files */ }
|
||
}
|
||
}
|
||
return saved;
|
||
}
|
||
|
||
/**
|
||
* Restore user-generated files saved by preserveUserArtifacts after a wipe.
|
||
*
|
||
* @param {string} destDir - Directory that was wiped and recreated
|
||
* @param {Map<string, string>} saved - Map returned by preserveUserArtifacts
|
||
*/
|
||
function restoreUserArtifacts(destDir, saved) {
|
||
for (const [name, content] of saved) {
|
||
const fullPath = path.join(destDir, name);
|
||
try {
|
||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||
fs.writeFileSync(fullPath, content, 'utf8');
|
||
} catch { /* skip unwritable paths */ }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Migrate a legacy dev-preferences.md (saved from commands/gsd/) into the
|
||
* runtime-aware SKILL.md location used by the writer after #2973.
|
||
*
|
||
* For runtimes with a nested skills layout (e.g. Hermes: skills/gsd/<stem>/),
|
||
* the target is <configDir>/skills/gsd/dev-preferences/SKILL.md.
|
||
* For runtimes with a flat skills layout (prefix='gsd-'), the target is
|
||
* <configDir>/skills/gsd-dev-preferences/SKILL.md.
|
||
*
|
||
* Skips silently if no legacy file was preserved, or if a SKILL.md already
|
||
* exists at the new location (don't clobber user-customized skill content
|
||
* — they may have edited the new file directly). Returns true on actual
|
||
* migration so callers can log a one-line confirmation.
|
||
*
|
||
* @param {string} targetDir - Resolved runtime config directory (e.g. ~/.claude)
|
||
* @param {Map<string, string>} saved - Map returned by preserveUserArtifacts
|
||
* @param {string} [runtime] - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude')
|
||
* @param {'global'|'local'} [scope] - install scope
|
||
* @returns {boolean} - true if a file was migrated, false otherwise
|
||
*/
|
||
function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = 'global') {
|
||
if (!saved || !saved.has('dev-preferences.md')) return false;
|
||
let skillDir;
|
||
if (runtime) {
|
||
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
|
||
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
|
||
if (!skillsKindEntry) return false; // runtime has no skills layout (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 gsd-core directory
|
||
const gsdDir = path.join(targetDir, 'gsd-core');
|
||
if (fs.existsSync(gsdDir)) {
|
||
// Preserve user-generated files before wipe (#1423)
|
||
const userProfilePath = path.join(gsdDir, 'USER-PROFILE.md');
|
||
const preservedProfile = fs.existsSync(userProfilePath) ? fs.readFileSync(userProfilePath, 'utf-8') : null;
|
||
|
||
fs.rmSync(gsdDir, { recursive: true });
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed gsd-core/`);
|
||
|
||
// Restore user-generated files
|
||
if (preservedProfile) {
|
||
try {
|
||
fs.mkdirSync(gsdDir, { recursive: true });
|
||
fs.writeFileSync(userProfilePath, preservedProfile);
|
||
console.log(` ${green}✓${reset} Preserved gsd-core/USER-PROFILE.md`);
|
||
} catch (err) {
|
||
console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Remove GSD agents (gsd-*.md files only)
|
||
const agentsDir = path.join(targetDir, 'agents');
|
||
if (fs.existsSync(agentsDir)) {
|
||
const files = fs.readdirSync(agentsDir);
|
||
let agentCount = 0;
|
||
for (const file of files) {
|
||
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
||
fs.unlinkSync(path.join(agentsDir, file));
|
||
agentCount++;
|
||
}
|
||
}
|
||
if (agentCount > 0) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed ${agentCount} GSD agents`);
|
||
}
|
||
}
|
||
|
||
// 4. Remove GSD hooks
|
||
const hooksDir = path.join(targetDir, 'hooks');
|
||
if (fs.existsSync(hooksDir)) {
|
||
let hookCount = 0;
|
||
for (const hook of GSD_UNINSTALL_HOOKS) {
|
||
const hookPath = path.join(hooksDir, hook);
|
||
if (fs.existsSync(hookPath)) {
|
||
fs.unlinkSync(hookPath);
|
||
hookCount++;
|
||
}
|
||
}
|
||
if (hookCount > 0) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed ${hookCount} GSD hooks`);
|
||
}
|
||
|
||
// Remove only the GSD-managed files from hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
|
||
// hooks/lib/ lives inside the user's runtime hooks directory (shared space) and
|
||
// may contain user-owned custom helpers. We must not recursively delete the dir.
|
||
const hooksLibDir = path.join(hooksDir, 'lib');
|
||
if (fs.existsSync(hooksLibDir)) {
|
||
let removedLibFiles = 0;
|
||
for (const file of GSD_HOOK_LIB_FILES) {
|
||
const filePath = path.join(hooksLibDir, file);
|
||
try {
|
||
fs.unlinkSync(filePath);
|
||
removedLibFiles++;
|
||
} catch (_) {
|
||
// Ignore missing files (best effort, non-fatal)
|
||
}
|
||
}
|
||
// Only remove the directory itself if it is now empty (preserve any user files)
|
||
try {
|
||
fs.rmdirSync(hooksLibDir);
|
||
} catch (_) {
|
||
// Directory not empty or other error — leave it alone
|
||
}
|
||
if (removedLibFiles > 0) {
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed ${removedLibFiles} hooks/lib/ helper(s)`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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('gsd-core')) {
|
||
delete config.permission[permType][key];
|
||
modified = true;
|
||
}
|
||
}
|
||
// Clean up empty objects
|
||
if (Object.keys(config.permission[permType]).length === 0) {
|
||
delete config.permission[permType];
|
||
}
|
||
}
|
||
}
|
||
if (Object.keys(config.permission).length === 0) {
|
||
delete config.permission;
|
||
}
|
||
}
|
||
|
||
if (modified) {
|
||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed GSD permissions from ${path.basename(configPath)}`);
|
||
}
|
||
} catch (e) {
|
||
// Ignore JSON parse errors
|
||
}
|
||
}
|
||
}
|
||
|
||
// 7. For Kilo, clean up permissions from kilo.json or kilo.jsonc
|
||
if (isKilo) {
|
||
const configPath = resolveKiloConfigPath(targetDir);
|
||
if (fs.existsSync(configPath)) {
|
||
try {
|
||
const config = parseJsonc(fs.readFileSync(configPath, 'utf8'));
|
||
let modified = false;
|
||
|
||
// Remove GSD permission entries
|
||
if (config.permission) {
|
||
for (const permType of ['read', 'external_directory']) {
|
||
if (config.permission[permType]) {
|
||
const keys = Object.keys(config.permission[permType]);
|
||
for (const key of keys) {
|
||
if (key.includes('gsd-core')) {
|
||
delete config.permission[permType][key];
|
||
modified = true;
|
||
}
|
||
}
|
||
// Clean up empty objects
|
||
if (Object.keys(config.permission[permType]).length === 0) {
|
||
delete config.permission[permType];
|
||
}
|
||
}
|
||
}
|
||
if (Object.keys(config.permission).length === 0) {
|
||
delete config.permission;
|
||
}
|
||
}
|
||
|
||
if (modified) {
|
||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed GSD permissions from ${path.basename(configPath)}`);
|
||
}
|
||
} catch (e) {
|
||
// Ignore JSON parse errors
|
||
}
|
||
}
|
||
}
|
||
|
||
// Remove the file manifest that the installer wrote at install time.
|
||
// Without this step the metadata file persists after uninstall (#1908).
|
||
const manifestPath = path.join(targetDir, MANIFEST_NAME);
|
||
if (fs.existsSync(manifestPath)) {
|
||
fs.rmSync(manifestPath, { force: true });
|
||
removedCount++;
|
||
console.log(` ${green}✓${reset} Removed ${MANIFEST_NAME}`);
|
||
}
|
||
|
||
if (removedCount === 0) {
|
||
console.log(` ${yellow}⚠${reset} No GSD files found to remove.`);
|
||
}
|
||
|
||
console.log(`
|
||
${green}Done!${reset} GSD has been uninstalled from ${runtimeLabel}.
|
||
Your other files and settings have been preserved.
|
||
`);
|
||
}
|
||
|
||
/**
|
||
* Parse JSONC (JSON with Comments) by stripping comments and trailing commas.
|
||
* OpenCode supports JSONC format via jsonc-parser, so users may have comments.
|
||
* This is a lightweight inline parser to avoid adding dependencies.
|
||
*/
|
||
function parseJsonc(content) {
|
||
// Strip BOM if present
|
||
if (content.charCodeAt(0) === 0xFEFF) {
|
||
content = content.slice(1);
|
||
}
|
||
|
||
// Remove single-line and block comments while preserving strings
|
||
let result = '';
|
||
let inString = false;
|
||
let i = 0;
|
||
while (i < content.length) {
|
||
const char = content[i];
|
||
const next = content[i + 1];
|
||
|
||
if (inString) {
|
||
result += char;
|
||
// Handle escape sequences
|
||
if (char === '\\' && i + 1 < content.length) {
|
||
result += next;
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (char === '"') {
|
||
inString = false;
|
||
}
|
||
i++;
|
||
} else {
|
||
if (char === '"') {
|
||
inString = true;
|
||
result += char;
|
||
i++;
|
||
} else if (char === '/' && next === '/') {
|
||
// Skip single-line comment until end of line
|
||
while (i < content.length && content[i] !== '\n') {
|
||
i++;
|
||
}
|
||
} else if (char === '/' && next === '*') {
|
||
// Skip block comment
|
||
i += 2;
|
||
while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) {
|
||
i++;
|
||
}
|
||
i += 2; // Skip closing */
|
||
} else {
|
||
result += char;
|
||
i++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Remove trailing commas before } or ]
|
||
result = result.replace(/,(\s*[}\]])/g, '$1');
|
||
|
||
return JSON.parse(result);
|
||
}
|
||
|
||
/**
|
||
* Configure OpenCode permissions to allow reading GSD reference docs
|
||
* This prevents permission prompts when GSD accesses the gsd-core directory
|
||
* @param {boolean} isGlobal - Whether this is a global or local install
|
||
* @param {string|null} configDir - Resolved config directory when already known
|
||
*/
|
||
function configureOpencodePermissions(isGlobal = true, configDir = null) {
|
||
// For local installs, use ./.opencode/
|
||
// For global installs, use ~/.config/opencode/
|
||
const opencodeConfigDir = configDir || (isGlobal
|
||
? 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/gsd-core/*'
|
||
: `${opencodeConfigDir.replace(/\\/g, '/')}/gsd-core/*`;
|
||
|
||
let modified = false;
|
||
|
||
// Configure read permission
|
||
if (!config.permission.read || typeof config.permission.read !== 'object') {
|
||
config.permission.read = {};
|
||
}
|
||
if (config.permission.read[gsdPath] !== 'allow') {
|
||
config.permission.read[gsdPath] = 'allow';
|
||
modified = true;
|
||
}
|
||
|
||
// Configure external_directory permission (the safety guard for paths outside project)
|
||
if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
|
||
config.permission.external_directory = {};
|
||
}
|
||
if (config.permission.external_directory[gsdPath] !== 'allow') {
|
||
config.permission.external_directory[gsdPath] = 'allow';
|
||
modified = true;
|
||
}
|
||
|
||
if (!modified) {
|
||
return; // Already configured
|
||
}
|
||
|
||
// Write config back
|
||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
||
console.log(` ${green}✓${reset} Configured read permission for GSD docs`);
|
||
}
|
||
|
||
/**
|
||
* Configure Kilo permissions to allow reading GSD reference docs
|
||
* This prevents permission prompts when GSD accesses the gsd-core directory
|
||
* @param {boolean} isGlobal - Whether this is a global or local install
|
||
* @param {string|null} configDir - Resolved config directory when already known
|
||
*/
|
||
function configureKiloPermissions(isGlobal = true, configDir = null) {
|
||
// For local installs, use ./.kilo/
|
||
// For global installs, use ~/.config/kilo/
|
||
const kiloConfigDir = configDir || (isGlobal
|
||
? 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/gsd-core/*'
|
||
: `${kiloConfigDir.replace(/\\/g, '/')}/gsd-core/*`;
|
||
|
||
let modified = false;
|
||
|
||
// Configure read permission
|
||
if (!config.permission.read || typeof config.permission.read !== 'object') {
|
||
config.permission.read = {};
|
||
}
|
||
if (config.permission.read[gsdPath] !== 'allow') {
|
||
config.permission.read[gsdPath] = 'allow';
|
||
modified = true;
|
||
}
|
||
|
||
// Configure external_directory permission (the safety guard for paths outside project)
|
||
if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
|
||
config.permission.external_directory = {};
|
||
}
|
||
if (config.permission.external_directory[gsdPath] !== 'allow') {
|
||
config.permission.external_directory[gsdPath] = 'allow';
|
||
modified = true;
|
||
}
|
||
|
||
if (!modified) {
|
||
return; // Already configured
|
||
}
|
||
|
||
// Write config back
|
||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
||
console.log(` ${green}✓${reset} Configured read permission for GSD docs`);
|
||
}
|
||
|
||
/**
|
||
* Verify a directory exists and contains files
|
||
*/
|
||
function verifyInstalled(dirPath, description) {
|
||
if (!fs.existsSync(dirPath)) {
|
||
console.error(` ${yellow}✗${reset} Failed to install ${description}: directory not created`);
|
||
return false;
|
||
}
|
||
try {
|
||
const entries = fs.readdirSync(dirPath);
|
||
if (entries.length === 0) {
|
||
console.error(` ${yellow}✗${reset} Failed to install ${description}: directory is empty`);
|
||
return false;
|
||
}
|
||
} catch (e) {
|
||
console.error(` ${yellow}✗${reset} Failed to install ${description}: ${e.message}`);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Verify a file exists
|
||
*/
|
||
function verifyFileInstalled(filePath, description) {
|
||
if (!fs.existsSync(filePath)) {
|
||
console.error(` ${yellow}✗${reset} Failed to install ${description}: file not created`);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Install to the specified directory for a specific runtime
|
||
* @param {boolean} isGlobal - Whether to install globally or locally
|
||
* @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex')
|
||
*/
|
||
|
||
// ──────────────────────────────────────────────────────
|
||
// Local Patch Persistence
|
||
// ──────────────────────────────────────────────────────
|
||
|
||
const PATCHES_DIR_NAME = 'gsd-local-patches';
|
||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||
|
||
/**
|
||
* Compute SHA256 hash of file contents
|
||
*/
|
||
function fileHash(filePath) {
|
||
const content = fs.readFileSync(filePath);
|
||
return crypto.createHash('sha256').update(content).digest('hex');
|
||
}
|
||
|
||
/**
|
||
* Recursively collect all files in dir with their hashes
|
||
*/
|
||
function generateManifest(dir, baseDir) {
|
||
if (!baseDir) baseDir = dir;
|
||
const manifest = {};
|
||
if (!fs.existsSync(dir)) return manifest;
|
||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(dir, entry.name);
|
||
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
|
||
if (entry.isDirectory()) {
|
||
Object.assign(manifest, generateManifest(fullPath, baseDir));
|
||
} else {
|
||
manifest[relPath] = fileHash(fullPath);
|
||
}
|
||
}
|
||
return manifest;
|
||
}
|
||
|
||
function normalizeInstallRelativePath(relPath) {
|
||
if (typeof relPath !== 'string' || relPath.trim() === '' || relPath.includes('\0')) {
|
||
return null;
|
||
}
|
||
if (path.isAbsolute(relPath) || path.win32.isAbsolute(relPath)) {
|
||
return null;
|
||
}
|
||
const normalized = relPath.replace(/\\/g, '/');
|
||
const segments = normalized.split('/');
|
||
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
||
return null;
|
||
}
|
||
return segments.join('/');
|
||
}
|
||
|
||
function resolveInstallRelativePath(baseDir, relPath) {
|
||
const normalized = normalizeInstallRelativePath(relPath);
|
||
if (!normalized) return null;
|
||
const root = path.resolve(baseDir);
|
||
const fullPath = path.resolve(root, normalized);
|
||
if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
|
||
return null;
|
||
}
|
||
if (hasExistingSymlinkBetween(root, fullPath)) {
|
||
return null;
|
||
}
|
||
return { relPath: normalized, fullPath };
|
||
}
|
||
|
||
function hasExistingSymlinkBetween(root, fullPath) {
|
||
const resolvedRoot = path.resolve(root);
|
||
const resolvedFullPath = path.resolve(fullPath);
|
||
if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + path.sep)) {
|
||
return true;
|
||
}
|
||
|
||
let cursor = resolvedRoot;
|
||
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) {
|
||
return true;
|
||
}
|
||
|
||
const relative = path.relative(resolvedRoot, resolvedFullPath);
|
||
for (const segment of relative.split(path.sep)) {
|
||
if (!segment) continue;
|
||
cursor = path.join(cursor, segment);
|
||
if (!fs.existsSync(cursor)) return false;
|
||
if (fs.lstatSync(cursor).isSymbolicLink()) return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Write file manifest after installation for future modification detection
|
||
*/
|
||
function writeManifest(configDir, runtime = 'claude', options = {}) {
|
||
const isOpencode = runtime === 'opencode';
|
||
const isKilo = runtime === 'kilo';
|
||
const isGemini = runtime === 'gemini';
|
||
const isCodex = runtime === 'codex';
|
||
const isCopilot = runtime === 'copilot';
|
||
const isAntigravity = runtime === 'antigravity';
|
||
const isCursor = runtime === 'cursor';
|
||
const isWindsurf = runtime === 'windsurf';
|
||
const isTrae = runtime === 'trae';
|
||
const isCline = runtime === 'cline';
|
||
const isHermes = runtime === 'hermes';
|
||
const gsdDir = path.join(configDir, 'gsd-core');
|
||
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['gsd-core/' + 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') || file.endsWith('.toml'))) {
|
||
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: gsd-core/, commands/gsd/, command/, skills/, agents/,
|
||
// hooks/, plus runtime-specific root files (#3004 CR). Stage every
|
||
// top-level dir that actually contains a modified path; root-level files
|
||
// are copied directly without the transform pipeline (they don't need
|
||
// path replacement).
|
||
const stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pristine-stage-'));
|
||
let written = 0;
|
||
try {
|
||
const topLevels = new Set();
|
||
const safeModified = [];
|
||
for (const relPath of modified) {
|
||
const norm = normalizeInstallRelativePath(relPath);
|
||
if (!norm) continue;
|
||
safeModified.push(norm);
|
||
const slash = norm.indexOf('/');
|
||
topLevels.add(slash === -1 ? '' : norm.slice(0, slash));
|
||
}
|
||
|
||
for (const top of topLevels) {
|
||
if (top === '') {
|
||
// Root-level files — copy directly from package source. The transform
|
||
// pipeline is directory-oriented; root files don't need path-prefix
|
||
// substitution (they're not markdown content with embedded paths).
|
||
for (const relPath of safeModified) {
|
||
const norm = normalizeInstallRelativePath(relPath);
|
||
if (!norm) continue;
|
||
if (norm.includes('/')) continue;
|
||
const srcRef = resolveInstallRelativePath(packageSrc, norm);
|
||
const stagedRef = resolveInstallRelativePath(stageRoot, norm);
|
||
if (!srcRef || !stagedRef || !fs.existsSync(srcRef.fullPath)) continue;
|
||
const stagedFile = stagedRef.fullPath;
|
||
fs.mkdirSync(path.dirname(stagedFile), { recursive: true });
|
||
fs.copyFileSync(srcRef.fullPath, stagedFile);
|
||
}
|
||
continue;
|
||
}
|
||
const srcDir = path.join(packageSrc, top);
|
||
const stageDir = path.join(stageRoot, top);
|
||
if (!fs.existsSync(srcDir)) continue;
|
||
copyWithPathReplacement(srcDir, stageDir, pathPrefix, runtime, false, isGlobal);
|
||
}
|
||
|
||
for (const relPath of safeModified) {
|
||
// Only populate pristine for paths we successfully staged. If a path's
|
||
// source dir does not exist (obsolete manifest entry), skip silently
|
||
// rather than corrupting pristine with stale data.
|
||
const stagedRef = resolveInstallRelativePath(stageRoot, relPath);
|
||
const outRef = resolveInstallRelativePath(pristineDir, relPath);
|
||
if (!stagedRef || !outRef || !fs.existsSync(stagedRef.fullPath)) continue;
|
||
fs.mkdirSync(path.dirname(outRef.fullPath), { recursive: true });
|
||
fs.copyFileSync(stagedRef.fullPath, outRef.fullPath);
|
||
written++;
|
||
}
|
||
} finally {
|
||
try { fs.rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
||
}
|
||
return written;
|
||
}
|
||
|
||
/**
|
||
* Detect user-modified GSD files by comparing against install manifest.
|
||
* Backs up modified files to gsd-local-patches/ for reapply after update.
|
||
* Also saves pristine copies (from manifest) to gsd-pristine/ to enable
|
||
* three-way merge during reapply-patches (pristine vs user vs new).
|
||
*
|
||
* The optional `pristineCtx` parameter (set by the install entry point)
|
||
* carries the source package root, runtime, pathPrefix, and isGlobal
|
||
* needed to populate gsd-pristine/. If omitted (legacy callers), pristine
|
||
* stays empty — the verifier falls back to its over-broad heuristic, same
|
||
* behavior as before #2998.
|
||
*/
|
||
function saveLocalPatches(configDir, pristineCtx) {
|
||
const manifestPath = path.join(configDir, MANIFEST_NAME);
|
||
if (!fs.existsSync(manifestPath)) return [];
|
||
|
||
let manifest;
|
||
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return []; }
|
||
|
||
// Normalize legacy manifests written before #2771 fix: strip user-owned artifacts
|
||
// that were incorrectly recorded so refreshes don't surface false patches warnings.
|
||
if (manifest.files) {
|
||
for (const artifact of USER_OWNED_ARTIFACTS) {
|
||
delete manifest.files[`gsd-core/${artifact}`];
|
||
}
|
||
}
|
||
|
||
const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
|
||
const pristineDir = path.join(configDir, 'gsd-pristine');
|
||
const modified = [];
|
||
const pristineHashes = {};
|
||
|
||
for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
|
||
const safeRef = resolveInstallRelativePath(configDir, relPath);
|
||
if (!safeRef) continue;
|
||
const { relPath: safeRelPath, fullPath } = safeRef;
|
||
if (!fs.existsSync(fullPath)) continue;
|
||
const currentHash = fileHash(fullPath);
|
||
if (currentHash !== originalHash) {
|
||
// Back up the user's modified version
|
||
const backupRef = resolveInstallRelativePath(patchesDir, safeRelPath);
|
||
if (!backupRef) continue;
|
||
const backupPath = backupRef.fullPath;
|
||
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
||
fs.copyFileSync(fullPath, backupPath);
|
||
modified.push(safeRelPath);
|
||
pristineHashes[safeRelPath] = originalHash;
|
||
}
|
||
}
|
||
|
||
// Save pristine copies of modified files from the CURRENT install (before wipe).
|
||
// Pristine semantically represents "what the install would write to configDir
|
||
// if the user had not modified it" — used by /gsd-reapply-patches Step 5
|
||
// (#2972) as the diff baseline for the user-added-lines computation. Without
|
||
// this dir the verifier degrades to its over-broad fallback heuristic (#2998).
|
||
if (modified.length > 0) {
|
||
const meta = {
|
||
backed_up_at: new Date().toISOString(),
|
||
from_version: manifest.version,
|
||
from_manifest_timestamp: manifest.timestamp,
|
||
files: modified,
|
||
pristine_hashes: {}
|
||
};
|
||
// Record the original (pristine) hash for each modified file
|
||
// This lets the reapply workflow verify reconstructed pristine files
|
||
for (const relPath of modified) {
|
||
meta.pristine_hashes[relPath] = pristineHashes[relPath];
|
||
}
|
||
fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
|
||
console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified GSD file(s) — backed up to ' + PATCHES_DIR_NAME + '/');
|
||
for (const f of modified) {
|
||
console.log(' ' + dim + f + reset);
|
||
}
|
||
|
||
// #2998 / #3407: maintain gsd-pristine/ as the diff baseline for the
|
||
// reapply-patches verifier (#2972).
|
||
//
|
||
// #3407 root-cause fix: the prior approach (#3004 CR) wiped gsd-pristine/
|
||
// and re-populated it from pristineCtx.packageSrc (the NEW release source).
|
||
// For files that changed between the old and new release this wrote NEW-
|
||
// release bytes as the pristine baseline while backup-meta.json recorded
|
||
// OLD-release hashes — a hash mismatch that caused the #3657 verifier guard
|
||
// (OK_PRISTINE_DRIFT_DETECTED) to skip the baseline and fall back to over-
|
||
// broad mode on every upgrade.
|
||
//
|
||
// Correct approach: `gsd-pristine/` is populated lazily by saveLocalPatches'
|
||
// regenerate branch (not by a separate install-time step); the fix works by
|
||
// induction across upgrades — each clean upgrade persists hash-validated
|
||
// entries for the next run. During this call we must PRESERVE entries whose
|
||
// hash matches originalHash, not overwrite them with new-release bytes.
|
||
//
|
||
// Per-file decision:
|
||
// - sha256(gsd-pristine/X) === originalHash → correct; keep it
|
||
// - gsd-pristine/X exists but hash mismatch → stale from a previous
|
||
// buggy run (#3407); remove so verifier falls back cleanly
|
||
// - gsd-pristine/X absent → attempt hash-validated
|
||
// regeneration: generate candidate from new-release source; if
|
||
// sha256(candidate) === originalHash the file is identical between
|
||
// old and new releases so candidate bytes ARE the old-release pristine
|
||
// and can be used; discard otherwise (over-broad fallback)
|
||
if (pristineCtx) {
|
||
let preserved = 0;
|
||
// Track which relPaths had stale pristine entries (hash mismatch) that we
|
||
// removed. After the regeneration pass we compute `removed` = stale entries
|
||
// that could NOT be recovered (over-broad fallback applies to those only).
|
||
const stalePaths = new Set();
|
||
// Track which relPaths were successfully regenerated (from either missing or stale).
|
||
const regeneratedPaths = new Set();
|
||
const missingPaths = [];
|
||
for (const relPath of modified) {
|
||
const outRef = resolveInstallRelativePath(pristineDir, relPath);
|
||
if (!outRef) continue;
|
||
const { fullPath: pristinePath } = outRef;
|
||
if (fs.existsSync(pristinePath)) {
|
||
try {
|
||
const onDiskHash = fileHash(pristinePath);
|
||
if (onDiskHash === pristineHashes[relPath]) {
|
||
preserved++;
|
||
continue; // correct old-release bytes already in place — keep them
|
||
}
|
||
} catch { /* read error — treat as mismatch */ }
|
||
// Hash mismatch or read error: stale pristine from a previous buggy
|
||
// run (#3407). Remove so verifier falls back to over-broad mode.
|
||
try { fs.rmSync(pristinePath, { force: true, recursive: true }); } catch { /* best-effort */ }
|
||
// Only count as removed if the file is actually gone post-removal.
|
||
if (!fs.existsSync(pristinePath)) {
|
||
stalePaths.add(relPath);
|
||
}
|
||
}
|
||
// File absent from gsd-pristine/ (or just removed above as stale):
|
||
// attempt hash-validated regeneration from new-release source.
|
||
missingPaths.push(relPath);
|
||
}
|
||
// Regenerate missing entries into a temp dir, then validate each hash
|
||
// before promoting. Only files whose new-release generated bytes hash to
|
||
// originalHash are safe to use — they were unchanged between releases.
|
||
if (missingPaths.length > 0) {
|
||
let tempPristineDir = null;
|
||
try {
|
||
tempPristineDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pristine-regen-'));
|
||
populatePristineDir({
|
||
packageSrc: pristineCtx.packageSrc,
|
||
pristineDir: tempPristineDir,
|
||
modified: missingPaths,
|
||
runtime: pristineCtx.runtime,
|
||
pathPrefix: pristineCtx.pathPrefix,
|
||
isGlobal: pristineCtx.isGlobal,
|
||
});
|
||
for (const relPath of missingPaths) {
|
||
const tempRef = resolveInstallRelativePath(tempPristineDir, relPath);
|
||
const outRef = resolveInstallRelativePath(pristineDir, relPath);
|
||
if (!tempRef || !outRef || !fs.existsSync(tempRef.fullPath)) continue;
|
||
try {
|
||
const candidateHash = fileHash(tempRef.fullPath);
|
||
if (candidateHash !== pristineHashes[relPath]) continue; // new-release differs — discard
|
||
fs.mkdirSync(path.dirname(outRef.fullPath), { recursive: true });
|
||
fs.copyFileSync(tempRef.fullPath, outRef.fullPath);
|
||
regeneratedPaths.add(relPath);
|
||
} catch { /* hash or copy error — skip; over-broad fallback applies */ }
|
||
}
|
||
} catch (err) {
|
||
// Match the pre-fix behavior: log a warning and continue (verifier falls back to over-broad mode for missing files).
|
||
console.warn(`gsd-pristine regen skipped: ${err.message}`);
|
||
} finally {
|
||
if (tempPristineDir) {
|
||
try { fs.rmSync(tempPristineDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||
}
|
||
}
|
||
}
|
||
// `regenerated` = total files successfully regenerated (from missing OR stale).
|
||
const regenerated = regeneratedPaths.size;
|
||
// `removed` = stale entries that were deleted and NOT subsequently regenerated.
|
||
// Entries that were stale-deleted but then successfully regenerated are counted
|
||
// only in `regenerated` — the counts are non-overlapping.
|
||
const removed = [...stalePaths].filter(p => !regeneratedPaths.has(p)).length;
|
||
if (preserved > 0) {
|
||
console.log(' ' + green + '✓' + reset + ' Preserved ' + cyan + 'gsd-pristine/' + reset + ' (' + preserved + ' file(s)) for three-way merge');
|
||
}
|
||
if (regenerated > 0) {
|
||
console.log(' ' + green + '✓' + reset + ' Regenerated ' + cyan + 'gsd-pristine/' + reset + ' (' + regenerated + ' file(s)) via hash-validated new-release source');
|
||
}
|
||
if (removed > 0) {
|
||
console.log(' ' + yellow + 'i' + reset + ' Removed ' + removed + ' stale gsd-pristine/ snapshot(s); regenerated ' + regenerated + ' of those — falls back to over-broad verify heuristic for the rest');
|
||
}
|
||
}
|
||
}
|
||
return modified;
|
||
}
|
||
|
||
/**
|
||
* After install, report backed-up patches for user to reapply.
|
||
*/
|
||
function reportLocalPatches(configDir, runtime = 'claude') {
|
||
const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
|
||
const metaPath = path.join(patchesDir, 'backup-meta.json');
|
||
if (!fs.existsSync(metaPath)) return [];
|
||
|
||
let meta;
|
||
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
|
||
|
||
if (meta.files && meta.files.length > 0) {
|
||
const reapplyCommand = (runtime === 'opencode' || runtime === 'kilo' || runtime === 'copilot')
|
||
? '/gsd-update --reapply'
|
||
: runtime === 'gemini'
|
||
? '/gsd:update --reapply'
|
||
: runtime === 'codex'
|
||
? '$gsd-update --reapply'
|
||
: runtime === 'cursor'
|
||
? 'gsd-update --reapply (mention the skill name)'
|
||
: '/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>gsd-core/bin/...
|
||
// For global installs: use $HOME/ so paths expand correctly inside double-quoted
|
||
// shell commands (~ does NOT expand inside double quotes, causing MODULE_NOT_FOUND).
|
||
// For local installs: use resolved absolute path (may be outside $HOME).
|
||
// Exception: OpenCode does not expand $HOME in @file references on any platform —
|
||
// `@$HOME/...` is treated as a literal path relative to the config dir, producing
|
||
// `command/$HOME/...` (file not found). Use the absolute path for OpenCode so
|
||
// @-references resolve correctly (#2376 Windows, #2831 macOS/Linux).
|
||
// gsd update marker re-application (ADR-0010 Deviation 2):
|
||
// Resolve which profile to use for this runtime's install:
|
||
// 1. --minimal / --core-only → back-compat 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 gsd-core/VERSION
|
||
//
|
||
// These are referenced by restoreCodexSnapshot(), defined below inside the
|
||
// config block. Defining the variables here (outer scope) makes them
|
||
// accessible by closure.
|
||
const codexPreInstallSkillNames = new Set();
|
||
// Map<skillDirName, Map<relPath, Buffer>> — full content snapshot of each
|
||
// pre-existing gsd-* skill directory. Best-effort: read errors are silently
|
||
// skipped so a partial snapshot is still better than none.
|
||
const codexPreInstallSkillContents = new Map();
|
||
const codexPreInstallAgentFiles = new Set();
|
||
// Map<filename, Buffer> — content snapshot of each pre-existing gsd-* agent file.
|
||
const codexPreInstallAgentContents = new Map();
|
||
let codexPreInstallVersionBytes = null;
|
||
if (isCodex && !isMinimalMode(_effectiveInstallMode)) {
|
||
const _preSkillsDir = path.join(targetDir, 'skills');
|
||
if (fs.existsSync(_preSkillsDir)) {
|
||
for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
|
||
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
||
codexPreInstallSkillNames.add(entry.name);
|
||
// Recursively snapshot all files in this skill dir.
|
||
const skillDir = path.join(_preSkillsDir, entry.name);
|
||
const fileMap = new Map();
|
||
const _snapshotDir = (dir, relBase) => {
|
||
let children;
|
||
try { children = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
||
for (const child of children) {
|
||
const relPath = relBase ? `${relBase}/${child.name}` : child.name;
|
||
const fullPath = path.join(dir, child.name);
|
||
if (child.isDirectory()) {
|
||
_snapshotDir(fullPath, relPath);
|
||
} else {
|
||
try { fileMap.set(relPath, fs.readFileSync(fullPath)); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
};
|
||
_snapshotDir(skillDir, '');
|
||
codexPreInstallSkillContents.set(entry.name, fileMap);
|
||
}
|
||
}
|
||
}
|
||
const _preAgentsDir = path.join(targetDir, 'agents');
|
||
if (fs.existsSync(_preAgentsDir)) {
|
||
for (const file of fs.readdirSync(_preAgentsDir)) {
|
||
if (file.startsWith('gsd-') && (file.endsWith('.md') || file.endsWith('.toml'))) {
|
||
codexPreInstallAgentFiles.add(file);
|
||
try {
|
||
codexPreInstallAgentContents.set(file, fs.readFileSync(path.join(_preAgentsDir, file)));
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
}
|
||
const _preVersionPath = path.join(targetDir, 'gsd-core', 'VERSION');
|
||
if (fs.existsSync(_preVersionPath)) {
|
||
try { codexPreInstallVersionBytes = fs.readFileSync(_preVersionPath); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
|
||
// #3245 CR finding 2 — Rollback coverage extends to ALL post-snapshot operations,
|
||
// not just the Codex config/hook error paths. Any throw between snapshot capture and
|
||
// the Codex config block (skills copy, agents copy, VERSION write, manifest write, etc.)
|
||
// must also trigger rollback so the caller is never left in a partially-installed state.
|
||
//
|
||
// _codexPreConfigRollback covers the four surfaces that can be mutated before
|
||
// config.toml is touched: skills/, agents/, gsd-core/VERSION, and orphaned
|
||
// atomic-write temp files. It is safe to call before any writes have happened.
|
||
// The full restoreCodexSnapshot() (defined inside the config block) additionally
|
||
// handles config.toml, which is not yet touched at this point in the pipeline.
|
||
const _codexPreConfigRollback = !isCodex || isMinimalMode(_effectiveInstallMode) ? null : () => {
|
||
rollbackInstallerMigrations();
|
||
// skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
|
||
const _earlySkillsDir = path.join(targetDir, 'skills');
|
||
for (const skillName of codexPreInstallSkillNames) {
|
||
const skillDirPath = path.join(_earlySkillsDir, skillName);
|
||
const fileMap = codexPreInstallSkillContents.get(skillName);
|
||
try {
|
||
fs.rmSync(skillDirPath, { recursive: true, force: true });
|
||
fs.mkdirSync(skillDirPath, { recursive: true });
|
||
if (fileMap) {
|
||
for (const [relPath, buf] of fileMap) {
|
||
const destFile = path.join(skillDirPath, relPath);
|
||
try {
|
||
fs.mkdirSync(path.dirname(destFile), { recursive: true });
|
||
fs.writeFileSync(destFile, buf);
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
// skills/gsd-* — pass 2: remove any newly-created dirs not in the snapshot.
|
||
if (fs.existsSync(_earlySkillsDir)) {
|
||
try {
|
||
for (const entry of fs.readdirSync(_earlySkillsDir, { withFileTypes: true })) {
|
||
if (entry.isDirectory() && entry.name.startsWith('gsd-') && !codexPreInstallSkillNames.has(entry.name)) {
|
||
try { fs.rmSync(path.join(_earlySkillsDir, entry.name), { recursive: true, force: true }); }
|
||
catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
// agents/gsd-* — pass 1: restore snapshot entries.
|
||
const _earlyAgentsDir = path.join(targetDir, 'agents');
|
||
for (const file of codexPreInstallAgentFiles) {
|
||
const buf = codexPreInstallAgentContents.get(file);
|
||
if (buf !== undefined) {
|
||
try {
|
||
fs.mkdirSync(_earlyAgentsDir, { recursive: true });
|
||
fs.writeFileSync(path.join(_earlyAgentsDir, file), buf);
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
// agents/gsd-* — pass 2: remove any newly-created files not in the snapshot.
|
||
if (fs.existsSync(_earlyAgentsDir)) {
|
||
try {
|
||
for (const file of fs.readdirSync(_earlyAgentsDir)) {
|
||
if (file.startsWith('gsd-') && (file.endsWith('.md') || file.endsWith('.toml')) && !codexPreInstallAgentFiles.has(file)) {
|
||
try { fs.unlinkSync(path.join(_earlyAgentsDir, file)); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
// gsd-core/VERSION
|
||
const _earlyVersionPath = path.join(targetDir, 'gsd-core', 'VERSION');
|
||
if (codexPreInstallVersionBytes !== null) {
|
||
try { fs.writeFileSync(_earlyVersionPath, codexPreInstallVersionBytes); } catch (_) { /* best-effort */ }
|
||
} else if (fs.existsSync(_earlyVersionPath)) {
|
||
try { fs.unlinkSync(_earlyVersionPath); } catch (_) { /* best-effort */ }
|
||
}
|
||
// Orphaned atomic-write temp files.
|
||
const _earlyTmpPattern = /\.tmp-\d+-\d+$/;
|
||
function _earlyCleanTmpFiles(dir) {
|
||
if (!fs.existsSync(dir)) return;
|
||
let entries;
|
||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
||
for (const entry of entries) {
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
_earlyCleanTmpFiles(full);
|
||
} else if (_earlyTmpPattern.test(entry.name) && __atomicWrittenTmps.has(full)) {
|
||
try { fs.unlinkSync(full); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
}
|
||
_earlyCleanTmpFiles(targetDir);
|
||
};
|
||
|
||
// Run manifest-backed cleanup migrations after rollback snapshots exist and
|
||
// before package materialization. Codex rollback paths invoke the migration
|
||
// rollback handle if a later install step fails.
|
||
//
|
||
// Runtime scope comes from docs/installer-migrations.md#runtime-configuration-contract-registry:
|
||
// every supported runtime uses this same planner/apply/report path, while
|
||
// individual migration records decide whether a runtime-specific config
|
||
// rewrite is allowed by that runtime's documented ownership boundary.
|
||
// #3245 CR finding 2 — wrap the pre-config install operations in a try/catch so
|
||
// that ANY throw between snapshot capture and the Codex config block triggers rollback.
|
||
// Non-Codex paths are unaffected (_codexPreConfigRollback is null for them).
|
||
//
|
||
// agentsSrc is declared here (let, not const) because installCodexConfig() inside the
|
||
// Codex config block below also references it, and that block is outside the try scope.
|
||
let agentsSrc = path.join(src, 'agents');
|
||
// Capture upgrade signal BEFORE files are written (#683). Must be declared at function
|
||
// scope (outside the try block below) so it is accessible in the settings section later.
|
||
// Absent VERSION = fresh install; present VERSION = upgrade/re-install.
|
||
const priorInstallExisted = fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION'));
|
||
try {
|
||
installerMigrationResult = runInstallerMigrations({
|
||
configDir: targetDir,
|
||
runtime,
|
||
scope: isGlobal ? 'global' : 'local',
|
||
migrations: options.installerMigrations,
|
||
baselineScan: true,
|
||
});
|
||
// #3541: non-interactive runs (typical /gsd-update via Claude Code) have
|
||
// no stdin TTY and therefore no way to answer prompt-user migration
|
||
// actions. Resolve safe categories by classification (stale SDK build
|
||
// artifacts → remove; user-facing skills → keep; bundled GSD hooks →
|
||
// remove [#3610]) and log every resolution; anything that cannot be
|
||
// safely defaulted falls through to assertInstallerMigrationsUnblocked,
|
||
// which now emits a grouped error with the documented resolution path.
|
||
//
|
||
// #3610: the classifier-based resolution must run regardless of TTY.
|
||
// For unambiguous categories (e.g. `hooks/gsd-*` bundled hooks left
|
||
// behind by a previous version), there is no actual "user choice" to
|
||
// make — the file is a known GSD-managed artifact and the installer is
|
||
// about to write the fresh bundled version. Gating the resolver on
|
||
// `!isTTY` made `npx @opengsd/gsd-core@latest --codex` hard-abort with
|
||
// 12 blocked bundled hooks. The env-override branch (operator-supplied
|
||
// GSD_INSTALLER_MIGRATION_RESOLVE) still applies only in non-TTY mode.
|
||
const _migrationIsTty = process.stdin && process.stdin.isTTY === true;
|
||
if (Array.isArray(installerMigrationResult.blocked) &&
|
||
installerMigrationResult.blocked.length > 0 &&
|
||
installerMigrationResult.plan &&
|
||
Array.isArray(installerMigrationResult.plan.actions)) {
|
||
const { resolutions } = resolveInstallerMigrationPromptsForNonTty(
|
||
installerMigrationResult,
|
||
{ isTty: false }
|
||
);
|
||
for (const entry of resolutions) {
|
||
console.log(
|
||
` ↪ installer-migration auto-resolved: ${entry.relPath} → ${entry.choice} ` +
|
||
`(category=${entry.category}, source=${entry.source})`
|
||
);
|
||
}
|
||
// If we resolved anything, the original run returned early without
|
||
// applying the (now-unblocked) plan — apply it here.
|
||
if (resolutions.length > 0 && installerMigrationResult.plan.blocked.length === 0) {
|
||
const applyResult = applyInstallerMigrationPlan({
|
||
configDir: targetDir,
|
||
plan: installerMigrationResult.plan,
|
||
});
|
||
installerMigrationResult = {
|
||
...installerMigrationResult,
|
||
...applyResult,
|
||
blocked: [],
|
||
};
|
||
}
|
||
}
|
||
reportInstallerMigrationResult(installerMigrationResult);
|
||
assertInstallerMigrationsUnblocked(installerMigrationResult);
|
||
|
||
// Artifact install dispatcher — routes to layout-driven path for all
|
||
// skills-based runtimes (both full and minimal/core profiles); keeps
|
||
// back-compat paths for commands-based runtimes (OpenCode/Kilo/Gemini/
|
||
// Claude-local).
|
||
//
|
||
// installRuntimeArtifacts handles legacy migration + skill/agent staging
|
||
// via layout kinds for all profile modes. _resolvedProfile already reflects
|
||
// the user's --profile=core / --minimal choice.
|
||
//
|
||
// Non-layout side-effects preserved inline:
|
||
// Hermes: writeHermesCategoryDescription (not a layout kind)
|
||
// Cline: 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 gsd-core skill with path replacement
|
||
// Preserve user-generated files before the wipe-and-copy so they survive re-install
|
||
const skillSrc = path.join(src, 'gsd-core');
|
||
const skillDest = path.join(targetDir, 'gsd-core');
|
||
const savedGsdArtifacts = preserveUserArtifacts(skillDest, USER_OWNED_ARTIFACTS);
|
||
copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal);
|
||
restoreUserArtifacts(skillDest, savedGsdArtifacts);
|
||
if (verifyInstalled(skillDest, 'gsd-core')) {
|
||
console.log(` ${green}✓${reset} Installed workflow assets`);
|
||
} else {
|
||
failures.push('gsd-core');
|
||
}
|
||
|
||
// Copy shared manifests into the gsd-core payload
|
||
// at the co-located path that CJS modules resolve first:
|
||
// gsd-core/bin/shared/*.json
|
||
//
|
||
// This source now lives under gsd-core/bin/shared in-repo.
|
||
const sharedPayloadFiles = [
|
||
'model-catalog.json',
|
||
'config-defaults.manifest.json',
|
||
'config-schema.manifest.json',
|
||
'runtime-aliases.manifest.json',
|
||
];
|
||
for (const fileName of sharedPayloadFiles) {
|
||
const sharedSrc = path.join(src, 'gsd-core', 'bin', 'shared', fileName);
|
||
const sharedDest = path.join(skillDest, 'bin', 'shared', fileName);
|
||
const displayPath = `gsd-core/bin/shared/${fileName}`;
|
||
if (fs.existsSync(sharedSrc)) {
|
||
fs.mkdirSync(path.dirname(sharedDest), { recursive: true });
|
||
fs.copyFileSync(sharedSrc, sharedDest);
|
||
if (verifyFileInstalled(sharedDest, displayPath)) {
|
||
console.log(` ${green}✓${reset} Installed ${displayPath}`);
|
||
} else {
|
||
failures.push(displayPath);
|
||
}
|
||
} else {
|
||
failures.push(`gsd-core/bin/shared/${fileName} (source missing)`);
|
||
}
|
||
}
|
||
|
||
// Copy agents to agents directory.
|
||
// Skipped under --minimal: gsd-* subagent descriptions are eagerly loaded
|
||
// into the runtime's Agent tool schema, costing ~6k tokens per turn even
|
||
// when no GSD workflow is active. See open-gsd/gsd-core#2762.
|
||
// Note: agentsSrc is declared as let before the enclosing try block so it
|
||
// is accessible by installCodexConfig() in the Codex config section below.
|
||
agentsSrc = _stageAgents(path.join(src, 'agents'));
|
||
const agentsDest = path.join(targetDir, 'agents');
|
||
|
||
// Always remove stale gsd-* agents first so re-installing with
|
||
// `--minimal` actually shrinks a previously-full install.
|
||
// For Codex this also covers per-agent `.toml` files alongside the `.md`
|
||
// sources so a full → minimal switch doesn't leave stale registrations.
|
||
if (fs.existsSync(agentsDest)) {
|
||
for (const file of fs.readdirSync(agentsDest)) {
|
||
if (
|
||
file.startsWith('gsd-') &&
|
||
(file.endsWith('.md') || (isCodex && file.endsWith('.toml')))
|
||
) {
|
||
fs.unlinkSync(path.join(agentsDest, file));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (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, 'gsd-core', 'CHANGELOG.md');
|
||
if (fs.existsSync(changelogSrc)) {
|
||
fs.copyFileSync(changelogSrc, changelogDest);
|
||
if (verifyFileInstalled(changelogDest, 'CHANGELOG.md')) {
|
||
console.log(` ${green}✓${reset} Installed CHANGELOG.md`);
|
||
} else {
|
||
failures.push('CHANGELOG.md');
|
||
}
|
||
}
|
||
|
||
// Write VERSION file
|
||
const versionDest = path.join(targetDir, 'gsd-core', 'VERSION');
|
||
fs.writeFileSync(versionDest, pkg.version);
|
||
if (verifyFileInstalled(versionDest, 'VERSION')) {
|
||
console.log(` ${green}✓${reset} Wrote VERSION (${pkg.version})`);
|
||
} else {
|
||
failures.push('VERSION');
|
||
}
|
||
|
||
if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline) {
|
||
// 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, ...)`);
|
||
}
|
||
|
||
// Remove legacy get-shit-done-cc artifacts and stale update caches (#607).
|
||
// cleanupLegacyGsdCc handles both the legacy shared cache and the per-package
|
||
// cache (formerly an inline unlinkSync here). A cleanup failure must never
|
||
// abort a successful install — log a warning and continue.
|
||
// install() is never reached in --dry-run mode (the early-exit at the CLI
|
||
// dispatch handles preview), so cleanup here always applies for real.
|
||
try {
|
||
cleanupLegacyGsdCc({ dryRun: false });
|
||
} catch (cleanupErr) {
|
||
console.warn(` ${yellow}Warning: legacy cleanup failed: ${cleanupErr.message}${reset}`);
|
||
}
|
||
|
||
if (failures.length > 0) {
|
||
console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// Write file manifest for future modification detection
|
||
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
|
||
console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
|
||
|
||
// Report any backed-up local patches
|
||
reportLocalPatches(targetDir, runtime);
|
||
|
||
// Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped)
|
||
if (runtime !== 'claude') {
|
||
const leakedPaths = [];
|
||
// Only scan files that were written by this install (manifest-tracked).
|
||
// Scanning the entire targetDir can match user-authored content that
|
||
// legitimately references ~/.claude (e.g. personal notes), producing
|
||
// false-positive warnings. Restricting to the manifest avoids that.
|
||
let manifestFiles = null;
|
||
try {
|
||
const manifestPath = path.join(targetDir, MANIFEST_NAME);
|
||
if (fs.existsSync(manifestPath)) {
|
||
const manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||
if (manifestData && typeof manifestData.files === 'object') {
|
||
manifestFiles = Object.keys(manifestData.files);
|
||
}
|
||
}
|
||
} catch (_manifestParseErr) {
|
||
// If we cannot read/parse the manifest, skip the scan entirely to
|
||
// avoid false positives rather than falling back to a full directory walk.
|
||
manifestFiles = null;
|
||
}
|
||
if (manifestFiles !== null) {
|
||
for (const relPath of manifestFiles) {
|
||
const fileName = path.basename(relPath);
|
||
if (!(fileName.endsWith('.md') || fileName.endsWith('.toml'))) continue;
|
||
if (fileName === 'CHANGELOG.md') continue;
|
||
const fullPath = path.join(targetDir, relPath);
|
||
let content;
|
||
try {
|
||
content = fs.readFileSync(fullPath, 'utf8');
|
||
} catch (err) {
|
||
if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ENOENT') {
|
||
continue; // skip inaccessible or missing files
|
||
}
|
||
throw err;
|
||
}
|
||
const matches = content.match(/(?:~|\$HOME)\/\.claude\b/g);
|
||
if (matches) {
|
||
leakedPaths.push({ file: relPath, count: matches.length });
|
||
}
|
||
}
|
||
}
|
||
if (leakedPaths.length > 0) {
|
||
const totalLeaks = leakedPaths.reduce((sum, l) => sum + l.count, 0);
|
||
console.warn(`\n ${yellow}⚠${reset} Found ${totalLeaks} unreplaced .claude path reference(s) in ${leakedPaths.length} file(s):`);
|
||
for (const leak of leakedPaths.slice(0, 5)) {
|
||
console.warn(` ${dim}${leak.file}${reset} (${leak.count})`);
|
||
}
|
||
if (leakedPaths.length > 5) {
|
||
console.warn(` ${dim}... and ${leakedPaths.length - 5} more file(s)${reset}`);
|
||
}
|
||
console.warn(` ${dim}These paths may not resolve correctly for ${runtimeLabel}.${reset}`);
|
||
}
|
||
}
|
||
|
||
} catch (_earlyInstallErr) {
|
||
// Installer Migration Module Phase 4: docs/installer-migrations.md
|
||
// requires safe migrations to run before package materialization without
|
||
// leaving stale state behind when materialization fails. Roll migration
|
||
// actions back for every runtime; Codex then layers its broader runtime
|
||
// snapshot rollback on top.
|
||
rollbackInstallerMigrations();
|
||
// #3245 CR finding 2 — any throw in the pre-config install operations (skills copy,
|
||
// agents copy, VERSION write, manifest write, etc.) triggers the Codex pre-config
|
||
// rollback so the caller is never left in a partially-installed state.
|
||
rollbackInstallerMigrations();
|
||
if (_codexPreConfigRollback) {
|
||
_codexPreConfigRollback();
|
||
}
|
||
throw _earlyInstallErr;
|
||
}
|
||
|
||
if (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
|
||
// gsd-core/VERSION — restore or remove
|
||
// *.tmp-* — best-effort cleanup of installer-owned atomic-write temps
|
||
//
|
||
// Safe to call multiple times (idempotent): each remove/write is guarded by
|
||
// existence checks. Safe to call before any snapshots are captured (variables
|
||
// default to empty Set / null). Does NOT touch non-gsd-* user content.
|
||
const restoreCodexSnapshot = () => {
|
||
rollbackInstallerMigrations();
|
||
// 1. config.toml
|
||
if (codexConfigPreInstallSnapshot !== null) {
|
||
try { fs.writeFileSync(codexConfigPathPreInstall, codexConfigPreInstallSnapshot); }
|
||
catch (_) { /* best-effort restore — surface the original error */ }
|
||
} else if (fs.existsSync(codexConfigPathPreInstall)) {
|
||
try { fs.rmSync(codexConfigPathPreInstall); } catch (_) { /* best-effort */ }
|
||
}
|
||
|
||
// 1b. hooks.json
|
||
// If installer migrations touched hooks.json, rollbackInstallerMigrations()
|
||
// already restored the pre-migration file. Don't overwrite that state with
|
||
// a post-migration snapshot.
|
||
if (!migrationTouchesHooksJson) {
|
||
if (codexHooksJsonPreInstallSnapshot !== null) {
|
||
try { fs.writeFileSync(codexHooksJsonPathPreInstall, codexHooksJsonPreInstallSnapshot); }
|
||
catch (_) { /* best-effort restore — surface the original error */ }
|
||
} else if (fs.existsSync(codexHooksJsonPathPreInstall)) {
|
||
try { fs.rmSync(codexHooksJsonPathPreInstall); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
|
||
// 2. skills/gsd-*
|
||
// • Dirs that pre-existed: wipe current contents, restore snapshotted files.
|
||
// The restore iterates the SNAPSHOT manifest (codexPreInstallSkillNames) rather
|
||
// than just the current filesystem so that dirs deleted during the install
|
||
// (copyCommandsAsCodexSkills removes pre-existing gsd-* dirs before re-writing)
|
||
// are restored even when they are absent from disk at rollback time (#3245 CR).
|
||
// • Dirs that did not pre-exist: remove entirely.
|
||
const _rollbackSkillsDir = path.join(targetDir, 'skills');
|
||
// Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
|
||
for (const skillName of codexPreInstallSkillNames) {
|
||
const skillDirPath = path.join(_rollbackSkillsDir, skillName);
|
||
const fileMap = codexPreInstallSkillContents.get(skillName);
|
||
try {
|
||
fs.rmSync(skillDirPath, { recursive: true, force: true });
|
||
fs.mkdirSync(skillDirPath, { recursive: true });
|
||
if (fileMap) {
|
||
for (const [relPath, buf] of fileMap) {
|
||
const destFile = path.join(skillDirPath, relPath);
|
||
try {
|
||
fs.mkdirSync(path.dirname(destFile), { recursive: true });
|
||
fs.writeFileSync(destFile, buf);
|
||
} catch (_) { /* best-effort file restore */ }
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort dir restore */ }
|
||
}
|
||
// Pass 2 — remove any newly-created gsd-* dirs (not in the pre-install snapshot).
|
||
if (fs.existsSync(_rollbackSkillsDir)) {
|
||
try {
|
||
for (const entry of fs.readdirSync(_rollbackSkillsDir, { withFileTypes: true })) {
|
||
if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) continue;
|
||
if (!codexPreInstallSkillNames.has(entry.name)) {
|
||
// New dir written this session: remove entirely.
|
||
try { fs.rmSync(path.join(_rollbackSkillsDir, entry.name), { recursive: true, force: true }); }
|
||
catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
|
||
// 3. agents/gsd-*.{md,toml}
|
||
// • Files that pre-existed: restore bytes from content snapshot.
|
||
// Iterates the SNAPSHOT manifest (codexPreInstallAgentFiles) so that files
|
||
// deleted by the pre-copy stale-removal pass (lines 7862-7870) are restored
|
||
// even when absent from disk at rollback time (#3245 CR).
|
||
// • Files that did not pre-exist: remove.
|
||
const _rollbackAgentsDir = path.join(targetDir, 'agents');
|
||
// Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
|
||
for (const file of codexPreInstallAgentFiles) {
|
||
const buf = codexPreInstallAgentContents.get(file);
|
||
if (buf !== undefined) {
|
||
try {
|
||
fs.mkdirSync(_rollbackAgentsDir, { recursive: true });
|
||
fs.writeFileSync(path.join(_rollbackAgentsDir, file), buf);
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
// Pass 2 — remove any newly-created gsd-* agent files (not in the pre-install snapshot).
|
||
if (fs.existsSync(_rollbackAgentsDir)) {
|
||
try {
|
||
for (const file of fs.readdirSync(_rollbackAgentsDir)) {
|
||
if (!file.startsWith('gsd-') || (!file.endsWith('.md') && !file.endsWith('.toml'))) continue;
|
||
if (!codexPreInstallAgentFiles.has(file)) {
|
||
// New file written this session: remove.
|
||
try { fs.unlinkSync(path.join(_rollbackAgentsDir, file)); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
} catch (_) { /* best-effort */ }
|
||
}
|
||
|
||
// 4. gsd-core/VERSION
|
||
const _rollbackVersionPath = path.join(targetDir, 'gsd-core', 'VERSION');
|
||
if (codexPreInstallVersionBytes !== null) {
|
||
try { fs.writeFileSync(_rollbackVersionPath, codexPreInstallVersionBytes); }
|
||
catch (_) { /* best-effort */ }
|
||
} else if (fs.existsSync(_rollbackVersionPath)) {
|
||
try { fs.unlinkSync(_rollbackVersionPath); } catch (_) { /* best-effort */ }
|
||
}
|
||
|
||
// 5. Orphaned atomic-write temp files (<file>.tmp-<pid>-<n>) in targetDir.
|
||
// These can accumulate if an atomic write fails mid-rename. Best-effort scan.
|
||
//
|
||
// Only delete temp files whose absolute path is in __atomicWrittenTmps —
|
||
// the Set populated by atomicWriteFileSync for every temp this installer
|
||
// process actually created. This scopes cleanup to installer-owned writes
|
||
// and avoids clobbering unrelated tools' temp files that happen to match
|
||
// the same *.tmp-<pid>-<n> suffix pattern.
|
||
const _tmpPattern = /\.tmp-\d+-\d+$/;
|
||
function _cleanTmpFiles(dir) {
|
||
if (!fs.existsSync(dir)) return;
|
||
let entries;
|
||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
||
for (const entry of entries) {
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
_cleanTmpFiles(full);
|
||
} else if (_tmpPattern.test(entry.name) && __atomicWrittenTmps.has(full)) {
|
||
try { fs.unlinkSync(full); } catch (_) { /* best-effort */ }
|
||
}
|
||
}
|
||
}
|
||
_cleanTmpFiles(targetDir);
|
||
};
|
||
|
||
let agentCount = 0;
|
||
if (!isMinimalMode(_effectiveInstallMode)) {
|
||
try {
|
||
// Generate Codex config.toml and per-agent .toml files.
|
||
agentCount = installCodexConfig(targetDir, agentsSrc);
|
||
} catch (e) {
|
||
restoreCodexSnapshot();
|
||
throw e;
|
||
}
|
||
console.log(` ${green}✓${reset} Generated config.toml with ${agentCount} agent roles`);
|
||
console.log(` ${green}✓${reset} Generated ${agentCount} agent .toml config files`);
|
||
// Re-write the manifest now that .toml agent files exist on disk.
|
||
// The initial writeManifest call (before Codex config generation) could
|
||
// not include agents/gsd-*.toml because those files did not yet exist.
|
||
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
|
||
} else {
|
||
console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
|
||
}
|
||
|
||
// Copy only the hook files that Codex actually registers via its hook configuration (#2153).
|
||
// 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, 'gsd-core', 'templates', 'copilot-instructions.md');
|
||
const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
|
||
if (fs.existsSync(templatePath)) {
|
||
const template = fs.readFileSync(templatePath, 'utf8');
|
||
mergeCopilotInstructions(instructionsPath, template);
|
||
console.log(` ${green}✓${reset} Generated copilot-instructions.md`);
|
||
}
|
||
// 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 `gsd-core/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 `gsd-core/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) => buildLocalShellHookCommand({
|
||
localPrefix,
|
||
hookFile,
|
||
bashRunner: localBashRunner,
|
||
runtime,
|
||
platform: process.platform,
|
||
});
|
||
const statuslineCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'gsd-statusline.js', hookOpts)
|
||
: localCmd('gsd-statusline.js');
|
||
const updateCheckCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'gsd-check-update.js', hookOpts)
|
||
: localCmd('gsd-check-update.js');
|
||
const contextMonitorCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'gsd-context-monitor.js', hookOpts)
|
||
: localCmd('gsd-context-monitor.js');
|
||
const promptGuardCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'gsd-prompt-guard.js', hookOpts)
|
||
: localCmd('gsd-prompt-guard.js');
|
||
const readGuardCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'gsd-read-guard.js', hookOpts)
|
||
: localCmd('gsd-read-guard.js');
|
||
const readInjectionScannerCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'gsd-read-injection-scanner.js', hookOpts)
|
||
: localCmd('gsd-read-injection-scanner.js');
|
||
|
||
// #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 PreToolUse hook for worktree absolute-path safety (#260)
|
||
// Hard-blocks Edit/Write/MultiEdit tool calls with absolute paths that resolve
|
||
// outside the current worktree root. Prevents executor agents from
|
||
// accidentally writing to the main checkout when running in isolation="worktree".
|
||
const worktreePathGuardCommand = isGlobal
|
||
? buildHookCommand(targetDir, 'gsd-worktree-path-guard.js', hookOpts)
|
||
: localCmd('gsd-worktree-path-guard.js');
|
||
const hasWorktreePathGuardHook = settings.hooks[preToolEvent].some(entry =>
|
||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-worktree-path-guard'))
|
||
);
|
||
const worktreePathGuardFile = path.join(targetDir, 'hooks', 'gsd-worktree-path-guard.js');
|
||
if (!hasWorktreePathGuardHook && fs.existsSync(worktreePathGuardFile) && worktreePathGuardCommand) {
|
||
settings.hooks[preToolEvent].push({
|
||
matcher: 'Write|Edit|MultiEdit',
|
||
hooks: [
|
||
{
|
||
type: 'command',
|
||
command: worktreePathGuardCommand,
|
||
timeout: 5
|
||
}
|
||
]
|
||
});
|
||
console.log(` ${green}✓${reset} Configured worktree path guard hook`);
|
||
} else if (!hasWorktreePathGuardHook && !fs.existsSync(worktreePathGuardFile)) {
|
||
console.warn(` ${yellow}⚠${reset} Skipped worktree path guard hook — gsd-worktree-path-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'));
|
||
|
||
// #683: Set worktree.baseRef:"head" in settings.local.json for local Claude installs.
|
||
// Both fresh and upgrade paths apply only when worktrees are enabled for the project.
|
||
// Never applies to global installs, non-Claude runtimes, or when the user already
|
||
// has an explicit baseRef in EITHER settings.local.json OR settings.json (no-clobber).
|
||
// Guard: skip entirely when settings is not a plain object (e.g. parsed to [] or primitive)
|
||
// to avoid crashing applyWorktreeBaseRef on unexpected top-level shapes.
|
||
if (isLocalClaude && settings !== null && typeof settings === 'object' && !Array.isArray(settings)) {
|
||
// Read shared settings.json baseRef so no-clobber spans both files (#683 FIX 1).
|
||
// shared settings.json no-clobber is checked here; settings.local.json no-clobber
|
||
// is enforced inside applyWorktreeBaseRef itself.
|
||
const sharedSettingsForBaseRef = readSettings(path.join(targetDir, 'settings.json')) || {};
|
||
const sharedBaseRef = readBaseRefFromSettings(sharedSettingsForBaseRef);
|
||
|
||
// Compute worktrees-enabled ONCE for both fresh and upgrade paths (FIX A: DRY + consistency).
|
||
// Read workflow.use_worktrees from .planning/config.json by walking up from
|
||
// targetDir (same walk-up pattern as readGsdRuntimeProfileResolver). Defaults
|
||
// to enabled (true) when the file is missing, unreadable, or the key is absent;
|
||
// only boolean false disables (string "false" stays enabled).
|
||
let worktreesEnabled = true; // default: enabled
|
||
try {
|
||
let probeDir = path.resolve(targetDir);
|
||
for (let depth = 0; depth < 8; depth += 1) {
|
||
const candidate = path.join(probeDir, '.planning', 'config.json');
|
||
if (fs.existsSync(candidate)) {
|
||
try {
|
||
const parsed = JSON.parse(stripJsonComments(fs.readFileSync(candidate, 'utf-8')));
|
||
if (parsed && typeof parsed === 'object' &&
|
||
parsed.workflow && parsed.workflow.use_worktrees === false) {
|
||
worktreesEnabled = false;
|
||
}
|
||
} catch {
|
||
// Malformed config.json — treat as enabled (safe fallback).
|
||
}
|
||
break;
|
||
}
|
||
const parent = path.dirname(probeDir);
|
||
if (parent === probeDir) break;
|
||
probeDir = parent;
|
||
}
|
||
} catch {
|
||
// Any unexpected error reading .planning — default to enabled.
|
||
}
|
||
|
||
if (worktreesEnabled && sharedBaseRef === null) {
|
||
if (!priorInstallExisted) {
|
||
// Fresh install — apply no-clobber baseRef set.
|
||
// canonical no-clobber logic: src/worktree-base-ref.cts applyWorktreeBaseRef (#683)
|
||
const { changed } = applyWorktreeBaseRef(settings);
|
||
if (changed) {
|
||
console.log(` ${green}✓${reset} Set worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
|
||
}
|
||
} else {
|
||
// Upgrade — auto-apply no-clobber baseRef set when worktrees are enabled.
|
||
const { changed } = applyWorktreeBaseRef(settings);
|
||
if (changed) {
|
||
console.log(` ${green}✓${reset} Enabled worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
|
||
}
|
||
}
|
||
}
|
||
// When worktreesEnabled is false: do nothing, print nothing (both fresh and upgrade).
|
||
}
|
||
|
||
persistActiveProfileMarker();
|
||
return {
|
||
settingsPath,
|
||
settings,
|
||
statuslineCommand,
|
||
updateBannerCommand,
|
||
runtime,
|
||
configDir: targetDir,
|
||
rollbackInstallerMigrations,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Apply statusline config, then print completion message
|
||
*/
|
||
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null, bannerOpts = {}) {
|
||
const isOpencode = runtime === 'opencode';
|
||
const isKilo = runtime === 'kilo';
|
||
const isCodex = runtime === 'codex';
|
||
const isCopilot = runtime === 'copilot';
|
||
const isCursor = runtime === 'cursor';
|
||
const isWindsurf = runtime === 'windsurf';
|
||
const isTrae = runtime === 'trae';
|
||
const isCline = runtime === 'cline';
|
||
|
||
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('');
|
||
}
|
||
|
||
// Runtime subdir names to scan for legacy get-shit-done-cc artifacts (#607).
|
||
// Covers both local (project-relative) and common global forms.
|
||
const _LEGACY_SCAN_SUBDIR_NAMES = [
|
||
'.claude',
|
||
'.gemini',
|
||
'.opencode',
|
||
'.config/opencode',
|
||
'.kilo',
|
||
'.config/kilo',
|
||
'.codex',
|
||
'.copilot',
|
||
'.github', // copilot local form
|
||
'.agent', // antigravity local form
|
||
'.cursor',
|
||
'.windsurf',
|
||
'.codeium/windsurf',
|
||
'.augment',
|
||
'.trae',
|
||
'.qwen',
|
||
'.hermes',
|
||
'.codebuddy',
|
||
'.cline',
|
||
];
|
||
|
||
/**
|
||
* Detect and remove leftover get-shit-done-cc artifacts across ALL known
|
||
* runtime config directories (issue #607).
|
||
*
|
||
* Exported so tests can call it directly without spawning a subprocess.
|
||
*
|
||
* Scans ONLY subdirs under homeDir — never cwd — to avoid touching the
|
||
* user's active-project hooks when the installer is run from a project dir.
|
||
*
|
||
* @param {object} [opts]
|
||
* @param {string} [opts.homeDir=os.homedir()] - home directory to scan
|
||
* @param {boolean} [opts.dryRun=false] - preview only; no mutations
|
||
* @param {object} [opts.logger=console] - injectable logger
|
||
* @returns {{ plan: {path:string,reason:string}[], result: object }}
|
||
*/
|
||
function cleanupLegacyGsdCc({ homeDir = os.homedir(), dryRun = false, logger = console } = {}) {
|
||
// Build de-duplicated list of candidate config dirs to scan.
|
||
// Only scan under homeDir — never cwd — to prevent accidental deletion of
|
||
// the user's active-project hooks when the installer is invoked from a
|
||
// project directory that has .claude/hooks or similar subdirs.
|
||
const seen = new Set();
|
||
const configDirs = [];
|
||
for (const name of _LEGACY_SCAN_SUBDIR_NAMES) {
|
||
const candidate = path.join(homeDir, name);
|
||
if (!seen.has(candidate) && fs.existsSync(candidate)) {
|
||
seen.add(candidate);
|
||
configDirs.push(candidate);
|
||
}
|
||
}
|
||
|
||
// planLegacyCleanup scans each configDir and already includes the legacy
|
||
// shared cache (gsd-update-check.json) as a plan entry.
|
||
const plan = planLegacyCleanup(configDirs, { homeDir });
|
||
|
||
// Apply the plan (dryRun honors the flag).
|
||
const result = applyLegacyCleanup(plan, { dryRun, logger });
|
||
|
||
// Also clear / preview the per-package cache so next session re-evaluates
|
||
// hook versions (replaces the former inline unlinkSync on line ~9104).
|
||
const perPkgCacheFile = path.join(homeDir, '.cache', 'gsd', updateCacheFileName);
|
||
if (dryRun) {
|
||
logger.log('[dry-run] would remove: ' + perPkgCacheFile + ' (per-package-update-cache)');
|
||
} else {
|
||
try { fs.unlinkSync(perPkgCacheFile); } catch (_e) { /* cache may not exist yet */ }
|
||
}
|
||
|
||
// Concise summary
|
||
if (plan.length > 0 || !dryRun) {
|
||
const verb = dryRun ? 'Would remove' : 'Removed';
|
||
const count = dryRun ? plan.length : result.removed.length;
|
||
logger.log(`[legacy-cleanup] ${verb} ${count} legacy artifact(s).`);
|
||
}
|
||
|
||
return { plan, result };
|
||
}
|
||
|
||
/**
|
||
* Install GSD for all selected runtimes
|
||
*/
|
||
function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
||
const results = [];
|
||
const installerMigrations = discoverInstallerMigrations({
|
||
migrationsDir: path.join(_gsdLibDir, 'installer-migrations'),
|
||
});
|
||
|
||
const rollbackFinalizedInstallerMigrations = (error) => {
|
||
const rollbackFailures = [];
|
||
for (const result of [...results].reverse()) {
|
||
if (!result || typeof result.rollbackInstallerMigrations !== 'function') continue;
|
||
try {
|
||
result.rollbackInstallerMigrations();
|
||
} catch (rollbackError) {
|
||
rollbackFailures.push({
|
||
runtime: result.runtime,
|
||
error: rollbackError.message,
|
||
});
|
||
}
|
||
}
|
||
if (rollbackFailures.length > 0) {
|
||
error.installerMigrationRollbackFailures = rollbackFailures;
|
||
}
|
||
};
|
||
|
||
try {
|
||
for (const runtime of runtimes) {
|
||
const result = install(isGlobal, runtime, { installerMigrations });
|
||
results.push(result);
|
||
}
|
||
} catch (error) {
|
||
rollbackFinalizedInstallerMigrations(error);
|
||
throw error;
|
||
}
|
||
|
||
const statuslineRuntimes = ['claude', 'gemini'];
|
||
const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
|
||
|
||
const finalize = (shouldInstallStatusline, shouldInstallBanner) => {
|
||
try {
|
||
const printSummaries = () => {
|
||
for (const result of results) {
|
||
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,
|
||
convertSlashCommandsToCodexSkillMentions,
|
||
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,
|
||
cleanupLegacyGsdCc,
|
||
};
|
||
|
||
// Main logic — only run when not loaded as a module for testing
|
||
if (require.main === module && !process.env.GSD_TEST_MODE) {
|
||
if (hasDryRun) {
|
||
// --dry-run: preview legacy cleanup and exit without installing.
|
||
if (hasUninstall) {
|
||
console.log('Note: --dry-run previews legacy get-shit-done-cc cleanup only; it does not preview --uninstall.');
|
||
}
|
||
console.log('Dry run — no files will be modified.\n');
|
||
// cleanupLegacyGsdCc with dryRun:true is the single source of truth for
|
||
// both the legacy artifacts and the per-package cache path — no duplicate
|
||
// printing here.
|
||
const { plan } = cleanupLegacyGsdCc({ dryRun: true });
|
||
if (plan.length === 0) {
|
||
console.log(' (no legacy get-shit-done-cc artifacts found)');
|
||
}
|
||
process.exit(0);
|
||
} else if (hasSkillsRoot) {
|
||
// Print the skills root directory for a given runtime (used by /gsd-sync-skills).
|
||
// Usage: node install.js --skills-root <runtime>
|
||
const runtimeArg = args[args.indexOf('--skills-root') + 1];
|
||
if (!runtimeArg || runtimeArg.startsWith('--')) {
|
||
console.error('Usage: node install.js --skills-root <runtime>');
|
||
process.exit(1);
|
||
}
|
||
const 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
|