#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const os = require('os'); const readline = require('readline'); const crypto = require('crypto'); // Colors const cyan = '\x1b[36m'; const green = '\x1b[32m'; const yellow = '\x1b[33m'; const dim = '\x1b[2m'; const reset = '\x1b[0m'; // Codex config.toml constants const GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by get-shit-done installer'; const 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', }; // Get version from package.json const pkg = require('../package.json'); // 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 hasCodex = args.includes('--codex'); const hasBoth = args.includes('--both'); // Legacy flag, keeps working const hasAll = args.includes('--all'); const hasUninstall = args.includes('--uninstall') || args.includes('-u'); // Runtime selection - can be set by flags or interactive prompt let selectedRuntimes = []; if (hasAll) { selectedRuntimes = ['claude', 'opencode', 'gemini', 'codex']; } else if (hasBoth) { selectedRuntimes = ['claude', 'opencode']; } else { if (hasOpencode) selectedRuntimes.push('opencode'); if (hasClaude) selectedRuntimes.push('claude'); if (hasGemini) selectedRuntimes.push('gemini'); if (hasCodex) selectedRuntimes.push('codex'); } // Helper to get directory name for a runtime (used for local/project installs) function getDirName(runtime) { if (runtime === 'opencode') return '.opencode'; if (runtime === 'gemini') return '.gemini'; if (runtime === 'codex') return '.codex'; return '.claude'; } /** * Get the config directory path relative to home directory for a runtime * Used for templating hooks that use path.join(homeDir, '', ...) * @param {string} runtime - 'claude', 'opencode', 'gemini', or 'codex' * @param {boolean} isGlobal - Whether this is a global install */ function getConfigDirFromHome(runtime, isGlobal) { if (!isGlobal) { // Local installs use the same dir name pattern return `'${getDirName(runtime)}'`; } // Global installs - OpenCode uses XDG path structure 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 === 'codex') return "'.codex'"; 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 a runtime * @param {string} runtime - 'claude', 'opencode', 'gemini', or 'codex' * @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 === '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'); } // 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' + ' Get Shit Done ' + dim + 'v' + pkg.version + reset + '\n' + ' A meta-prompting, context engineering and spec-driven\n' + ' development system for Claude Code, OpenCode, Gemini, and Codex by TÂCHES.\n'; // Parse --config-dir argument function parseConfigDirArg() { const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c'); if (configDirIndex !== -1) { const nextArg = args[configDirIndex + 1]; // Error if --config-dir is provided without a value or next arg is another flag if (!nextArg || nextArg.startsWith('-')) { console.error(` ${yellow}--config-dir requires a path argument${reset}`); process.exit(1); } return nextArg; } // Also handle --config-dir=value format const configDirArg = args.find(arg => arg.startsWith('--config-dir=') || arg.startsWith('-c=')); if (configDirArg) { const value = configDirArg.split('=')[1]; if (!value) { console.error(` ${yellow}--config-dir requires a non-empty path${reset}`); process.exit(1); } return value; } return null; } const explicitConfigDir = parseConfigDirArg(); const hasHelp = args.includes('--help') || args.includes('-h'); const forceStatusline = args.includes('--force-statusline'); console.log(banner); // Show help if requested if (hasHelp) { console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [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}--codex${reset} Install for Codex 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 ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --codex --global --config-dir ~/.codex-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Codex globally${reset}\n npx get-shit-done-cc --codex --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 / GEMINI_CONFIG_DIR / CODEX_HOME 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; } /** * 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. */ function buildHookCommand(configDir, hookName) { // Use forward slashes for Node.js compatibility on all platforms const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName; return `node "${hooksPath}"`; } /** * Read and parse settings.json, returning empty object if it doesn't exist */ function readSettings(settingsPath) { if (fs.existsSync(settingsPath)) { try { return JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch (e) { return {}; } } return {}; } /** * Write settings.json with proper formatting */ function writeSettings(settingsPath, settings) { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } // 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', or 'codex' * @returns {null|undefined|string} null = remove, undefined = keep default, string = custom */ function getCommitAttribution(runtime) { // Return cached value if available if (attributionCache.has(runtime)) { return attributionCache.get(runtime); } let result; if (runtime === 'opencode') { const config = readSettings(path.join(getGlobalDir('opencode', null), 'opencode.json')); result = 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.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.attribution || settings.attribution.commit === undefined) { result = undefined; } else if (settings.attribution.commit === '') { result = null; } else { result = settings.attribution.commit; } } else { // Codex currently has no attribution setting equivalent result = undefined; } // Cache and return attributionCache.set(runtime, result); return result; } /** * 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', AskUserQuestion: 'ask_user', }; /** * 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 — 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: exclude — agents are auto-registered as callable tools if (claudeTool === 'Task') { return null; } // Check for explicit mapping if (claudeToGeminiTools[claudeTool]) { return claudeToGeminiTools[claudeTool]; } // Default: lowercase return claudeTool.toLowerCase(); } function toSingleLine(value) { return value.replace(/\s+/g, ' ').trim(); } function yamlQuote(value) { return JSON.stringify(value); } 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, ''); } function convertSlashCommandsToCodexSkillMentions(content) { let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => { return `$gsd-${String(commandName).toLowerCase()}`; }); converted = converted.replace(/\/gsd-help\b/g, '$gsd-help'); return converted; } function convertClaudeToCodexMarkdown(content) { let converted = convertSlashCommandsToCodexSkillMentions(content); converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); return converted; } function getCodexSkillAdapterHeader(skillName) { const invocation = `$${skillName}`; return ` ## 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 (Execute mode), present a plain-text numbered list and pick a reasonable default. ## 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")\` - \`Task(model="...")\` → omit (Codex uses per-role config, not inline model selection) - \`fork_context: false\` by default — GSD agents load their own context via \`\` blocks 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 `; } 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 header * and cleans up frontmatter (removes tools/color fields). */ function convertClaudeAgentToCodexAgent(content) { let converted = convertClaudeToCodexMarkdown(content); const { frontmatter, body } = extractFrontmatterAndBody(converted); if (!frontmatter) return converted; const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; const description = extractFrontmatterField(frontmatter, 'description') || ''; const tools = extractFrontmatterField(frontmatter, 'tools') || ''; const roleHeader = ` role: ${name} tools: ${tools} purpose: ${toSingleLine(description)} `; const cleanFrontmatter = `---\nname: ${yamlQuote(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; return `${cleanFrontmatter}\n\n${roleHeader}\n${body}`; } /** * Generate a per-agent .toml config file for Codex. * Sets sandbox_mode and developer_instructions from the agent markdown body. */ function generateCodexAgentToml(agentName, agentContent) { const sandboxMode = CODEX_AGENT_SANDBOX[agentName] || 'read-only'; const { body } = extractFrontmatterAndBody(agentContent); const instructions = body.trim(); const lines = [ `sandbox_mode = "${sandboxMode}"`, `developer_instructions = """`, instructions, `"""`, ]; return lines.join('\n') + '\n'; } /** * Generate the GSD config block for Codex config.toml. * @param {Array<{name: string, description: string}>} agents */ function generateCodexConfigBlock(agents) { const lines = [ GSD_CODEX_MARKER, '[features]', 'multi_agent = true', 'default_mode_request_user_input = true', '', '[agents]', 'max_threads = 4', 'max_depth = 2', '', ]; for (const { name, description } of agents) { lines.push(`[agents.${name}]`); lines.push(`description = ${JSON.stringify(description)}`); lines.push(`config_file = "agents/${name}.toml"`); lines.push(''); } return lines.join('\n'); } /** * Strip GSD sections from Codex config.toml content. * Returns cleaned content, or null if file would be empty. */ function stripGsdFromCodexConfig(content) { const markerIndex = content.indexOf(GSD_CODEX_MARKER); if (markerIndex !== -1) { // Has GSD marker — remove everything from marker to EOF let before = content.substring(0, markerIndex).trimEnd(); // Also strip GSD-injected feature keys above the marker (Case 3 inject) before = before.replace(/^multi_agent\s*=\s*true\s*\n?/m, ''); before = before.replace(/^default_mode_request_user_input\s*=\s*true\s*\n?/m, ''); before = before.replace(/^\[features\]\s*\n(?=\[|$)/m, ''); before = before.replace(/\n{3,}/g, '\n\n').trim(); if (!before) return null; return before + '\n'; } // No marker but may have GSD-injected feature keys let cleaned = content; cleaned = cleaned.replace(/^multi_agent\s*=\s*true\s*\n?/m, ''); cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*\n?/m, ''); // Remove [agents.gsd-*] sections (from header to next section or EOF) cleaned = cleaned.replace(/^\[agents\.gsd-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, ''); // 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, ''); // Clean up excessive blank lines cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim(); if (!cleaned) return null; return cleaned + '\n'; } /** * Merge GSD config block into an existing or new config.toml. * Three cases: new file, existing with GSD marker, existing without marker. */ function mergeCodexConfig(configPath, gsdBlock) { // Case 1: No config.toml — create fresh if (!fs.existsSync(configPath)) { fs.writeFileSync(configPath, gsdBlock + '\n'); return; } const existing = fs.readFileSync(configPath, 'utf8'); const markerIndex = existing.indexOf(GSD_CODEX_MARKER); // Case 2: Has GSD marker — truncate and re-append if (markerIndex !== -1) { const before = existing.substring(0, markerIndex).trimEnd(); const newContent = before ? before + '\n\n' + gsdBlock + '\n' : gsdBlock + '\n'; fs.writeFileSync(configPath, newContent); return; } // Case 3: No marker — inject features if needed, append agents let content = existing; const featuresRegex = /^\[features\]\s*$/m; const hasFeatures = featuresRegex.test(content); if (hasFeatures) { if (!content.includes('multi_agent')) { content = content.replace(featuresRegex, '[features]\nmulti_agent = true'); } if (!content.includes('default_mode_request_user_input')) { content = content.replace(/^\[features\].*$/m, '$&\ndefault_mode_request_user_input = true'); } // Append agents block (skip the [features] section from gsdBlock) const agentsBlock = gsdBlock.substring(gsdBlock.indexOf('[agents]')); content = content.trimEnd() + '\n\n' + GSD_CODEX_MARKER + '\n' + agentsBlock + '\n'; } else { content = content.trimEnd() + '\n\n' + gsdBlock + '\n'; } fs.writeFileSync(configPath, 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 = []; for (const file of agentEntries) { const content = fs.readFileSync(path.join(agentsSrc, file), 'utf8'); const { frontmatter } = extractFrontmatterAndBody(content); const name = extractFrontmatterField(frontmatter, 'name') || file.replace('.md', ''); const description = extractFrontmatterField(frontmatter, 'description') || ''; agents.push({ name, description: toSingleLine(description) }); const tomlContent = generateCodexAgentToml(name, content); fs.writeFileSync(path.join(agentsTomlDir, `${name}.toml`), tomlContent); } const gsdBlock = generateCodexConfigBlock(agents); mergeCodexConfig(configPath, gsdBlock); return agents.length; } /** * Strip HTML tags for Gemini CLI output * Terminals don't support subscript — Gemini renders these as raw HTML. * Converts text to italic *(text)* for readable terminal output. */ function stripSubTags(content) { return content.replace(/(.*?)<\/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) * - mcp__* tools: must be excluded (auto-discovered at runtime) */ 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; const tools = []; for (const line of lines) { const trimmed = line.trim(); // 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; // 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'); return `---\n${newFrontmatter}\n---${stripSubTags(escapedBody)}`; } function convertClaudeToOpencodeFrontmatter(content) { // 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 with /gsd-command for opencode (flat command structure) convertedContent = convertedContent.replace(/\/gsd:/g, '/gsd-'); // Replace ~/.claude with ~/.config/opencode (OpenCode's correct config location) convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/opencode'); // Replace general-purpose subagent type with OpenCode's equivalent "general" convertedContent = convertedContent.replace(/subagent_type="general-purpose"/g, 'subagent_type="general"'); // 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; const allowedTools = []; for (const line of lines) { const trimmed = line.trim(); // 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:')) { 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; } // Remove name: field - opencode uses filename for command name if (trimmed.startsWith('name:')) { continue; } // Convert color names to hex for opencode 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 (including name: which opencode ignores) if (!inAllowedTools) { newLines.push(line); } } // Add tools object if we had allowed-tools or tools if (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' or 'opencode') */ 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 localClaudeRegex = /\.\/\.claude\//g; const opencodeDirRegex = /~\/\.opencode\//g; content = content.replace(globalClaudeRegex, pathPrefix); content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); content = content.replace(opencodeDirRegex, pathPrefix); content = processAttribution(content, getCommitAttribution(runtime)); 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(); } function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) { if (!fs.existsSync(srcDir)) { return; } fs.mkdirSync(skillsDir, { recursive: true }); // Remove previous GSD Codex skills to avoid stale command skills. const existing = fs.readdirSync(skillsDir, { withFileTypes: true }); for (const entry of existing) { if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) { fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); } } function recurse(currentSrcDir, currentPrefix) { const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(currentSrcDir, entry.name); if (entry.isDirectory()) { recurse(srcPath, `${currentPrefix}-${entry.name}`); continue; } if (!entry.name.endsWith('.md')) { continue; } const baseName = entry.name.replace('.md', ''); const skillName = `${currentPrefix}-${baseName}`; const skillDir = path.join(skillsDir, skillName); fs.mkdirSync(skillDir, { recursive: true }); let content = fs.readFileSync(srcPath, 'utf8'); const globalClaudeRegex = /~\/\.claude\//g; const localClaudeRegex = /\.\/\.claude\//g; const codexDirRegex = /~\/\.codex\//g; content = content.replace(globalClaudeRegex, pathPrefix); content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); content = content.replace(codexDirRegex, pathPrefix); content = processAttribution(content, getCommitAttribution(runtime)); content = convertClaudeCommandToCodexSkill(content, skillName); fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); } } recurse(srcDir, prefix); } /** * 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') */ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false) { const isOpencode = runtime === 'opencode'; const isCodex = runtime === 'codex'; 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); } else if (entry.name.endsWith('.md')) { // Replace ~/.claude/ and ./.claude/ with runtime-appropriate paths let content = fs.readFileSync(srcPath, 'utf8'); const globalClaudeRegex = /~\/\.claude\//g; const localClaudeRegex = /\.\/\.claude\//g; content = content.replace(globalClaudeRegex, pathPrefix); content = content.replace(localClaudeRegex, `./${dirName}/`); content = processAttribution(content, getCommitAttribution(runtime)); // Convert frontmatter for opencode compatibility if (isOpencode) { content = convertClaudeToOpencodeFrontmatter(content); fs.writeFileSync(destPath, content); } else if (runtime === 'gemini') { if (isCommand) { // Convert to TOML for Gemini (strip tags — terminals can't render subscript) content = stripSubTags(content); const tomlContent = convertClaudeToGeminiToml(content); // Replace extension with .toml const tomlPath = destPath.replace(/\.md$/, '.toml'); fs.writeFileSync(tomlPath, tomlContent); } else { fs.writeFileSync(destPath, content); } } else if (isCodex) { content = convertClaudeToCodexMarkdown(content); fs.writeFileSync(destPath, content); } else { fs.writeFileSync(destPath, content); } } else { fs.copyFileSync(srcPath, destPath); } } } /** * Clean up orphaned files from previous GSD versions */ function cleanupOrphanedFiles(configDir) { const orphanedFiles = [ 'hooks/gsd-notify.sh', // Removed in v1.6.x 'hooks/statusline.js', // Renamed to gsd-statusline.js in v1.9.0 ]; for (const relPath of orphanedFiles) { const fullPath = path.join(configDir, relPath); if (fs.existsSync(fullPath)) { fs.unlinkSync(fullPath); console.log(` ${green}✓${reset} Removed orphaned ${relPath}`); } } } /** * 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; } /** * 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') */ function uninstall(isGlobal, runtime = 'claude') { const isOpencode = runtime === 'opencode'; const isCodex = runtime === 'codex'; 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 === 'codex') runtimeLabel = 'Codex'; 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; // 1. Remove GSD commands/skills if (isOpencode) { // OpenCode: remove command/gsd-*.md files const commandDir = path.join(targetDir, 'command'); if (fs.existsSync(commandDir)) { const files = fs.readdirSync(commandDir); for (const file of files) { if (file.startsWith('gsd-') && file.endsWith('.md')) { fs.unlinkSync(path.join(commandDir, file)); removedCount++; } } console.log(` ${green}✓${reset} Removed GSD commands from command/`); } } else if (isCodex) { // Codex: remove skills/gsd-*/SKILL.md skill directories const skillsDir = path.join(targetDir, 'skills'); if (fs.existsSync(skillsDir)) { let skillCount = 0; const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && entry.name.startsWith('gsd-')) { fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); skillCount++; } } if (skillCount > 0) { removedCount++; console.log(` ${green}✓${reset} Removed ${skillCount} Codex skills`); } } // Codex: remove GSD agent .toml config files 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 configPath = path.join(targetDir, 'config.toml'); if (fs.existsSync(configPath)) { const content = fs.readFileSync(configPath, 'utf8'); const cleaned = stripGsdFromCodexConfig(content); if (cleaned === null) { // File is empty after stripping — delete it fs.unlinkSync(configPath); removedCount++; console.log(` ${green}✓${reset} Removed config.toml (was GSD-only)`); } else if (cleaned !== content) { fs.writeFileSync(configPath, cleaned); removedCount++; console.log(` ${green}✓${reset} Cleaned GSD sections from config.toml`); } } } else { // Claude Code & Gemini: remove commands/gsd/ directory const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(gsdCommandsDir)) { fs.rmSync(gsdCommandsDir, { recursive: true }); removedCount++; console.log(` ${green}✓${reset} Removed commands/gsd/`); } } // 2. Remove get-shit-done directory const gsdDir = path.join(targetDir, 'get-shit-done'); if (fs.existsSync(gsdDir)) { fs.rmSync(gsdDir, { recursive: true }); removedCount++; console.log(` ${green}✓${reset} Removed get-shit-done/`); } // 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)) { const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js']; let hookCount = 0; for (const hook of gsdHooks) { 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`); } } // 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); 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 SessionStart if (settings.hooks && settings.hooks.SessionStart) { const before = settings.hooks.SessionStart.length; settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry => { if (entry.hooks && Array.isArray(entry.hooks)) { // Filter out GSD hooks const hasGsdHook = entry.hooks.some(h => h.command && (h.command.includes('gsd-check-update') || h.command.includes('gsd-statusline')) ); return !hasGsdHook; } return true; }); if (settings.hooks.SessionStart.length < before) { settingsModified = true; console.log(` ${green}✓${reset} Removed GSD hooks from settings`); } // Clean up empty array if (settings.hooks.SessionStart.length === 0) { delete settings.hooks.SessionStart; } } // Remove GSD hooks from PostToolUse if (settings.hooks && settings.hooks.PostToolUse) { const before = settings.hooks.PostToolUse.length; settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => { if (entry.hooks && Array.isArray(entry.hooks)) { const hasGsdHook = entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor') ); return !hasGsdHook; } return true; }); if (settings.hooks.PostToolUse.length < before) { settingsModified = true; console.log(` ${green}✓${reset} Removed context monitor hook from settings`); } if (settings.hooks.PostToolUse.length === 0) { delete settings.hooks.PostToolUse; } } // 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 if (isOpencode) { // For local uninstalls, clean up ./.opencode/opencode.json // For global uninstalls, clean up ~/.config/opencode/opencode.json const opencodeConfigDir = isGlobal ? getOpencodeGlobalDir() : path.join(process.cwd(), '.opencode'); const configPath = path.join(opencodeConfigDir, 'opencode.json'); if (fs.existsSync(configPath)) { try { const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); let modified = false; // Remove GSD permission entries if (config.permission) { for (const permType of ['read', 'external_directory']) { if (config.permission[permType]) { const keys = Object.keys(config.permission[permType]); for (const key of keys) { if (key.includes('get-shit-done')) { delete config.permission[permType][key]; modified = true; } } // Clean up empty objects if (Object.keys(config.permission[permType]).length === 0) { delete config.permission[permType]; } } } if (Object.keys(config.permission).length === 0) { delete config.permission; } } if (modified) { fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); removedCount++; console.log(` ${green}✓${reset} Removed GSD permissions from opencode.json`); } } catch (e) { // Ignore JSON parse errors } } } if (removedCount === 0) { console.log(` ${yellow}⚠${reset} No GSD files found to remove.`); } console.log(` ${green}Done!${reset} GSD has been uninstalled from ${runtimeLabel}. Your other files and settings have been preserved. `); } /** * Parse JSONC (JSON with Comments) by stripping comments and trailing commas. * OpenCode supports JSONC format via jsonc-parser, so users may have comments. * This is a lightweight inline parser to avoid adding dependencies. */ function parseJsonc(content) { // Strip BOM if present if (content.charCodeAt(0) === 0xFEFF) { content = content.slice(1); } // Remove single-line and block comments while preserving strings let result = ''; let inString = false; let i = 0; while (i < content.length) { const char = content[i]; const next = content[i + 1]; if (inString) { result += char; // Handle escape sequences if (char === '\\' && i + 1 < content.length) { result += next; i += 2; continue; } if (char === '"') { inString = false; } i++; } else { if (char === '"') { inString = true; result += char; i++; } else if (char === '/' && next === '/') { // Skip single-line comment until end of line while (i < content.length && content[i] !== '\n') { i++; } } else if (char === '/' && next === '*') { // Skip block comment i += 2; while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) { i++; } i += 2; // Skip closing */ } else { result += char; i++; } } } // Remove trailing commas before } or ] result = result.replace(/,(\s*[}\]])/g, '$1'); return JSON.parse(result); } /** * Configure OpenCode permissions to allow reading GSD reference docs * This prevents permission prompts when GSD accesses the get-shit-done directory * @param {boolean} isGlobal - Whether this is a global or local install */ function configureOpencodePermissions(isGlobal = true) { // For local installs, use ./.opencode/opencode.json // For global installs, use ~/.config/opencode/opencode.json const opencodeConfigDir = isGlobal ? getOpencodeGlobalDir() : path.join(process.cwd(), '.opencode'); const configPath = path.join(opencodeConfigDir, 'opencode.json'); // Ensure config directory exists fs.mkdirSync(opencodeConfigDir, { recursive: true }); // 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 console.log(` ${yellow}⚠${reset} Could not parse opencode.json - 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) { config.permission = {}; } // Build the GSD path using the actual config directory // Use ~ shorthand if it's in the default location, otherwise use full path const defaultConfigDir = path.join(os.homedir(), '.config', 'opencode'); const gsdPath = opencodeConfigDir === defaultConfigDir ? '~/.config/opencode/get-shit-done/*' : `${opencodeConfigDir.replace(/\\/g, '/')}/get-shit-done/*`; let modified = false; // Configure read permission if (!config.permission.read || typeof config.permission.read !== 'object') { config.permission.read = {}; } if (config.permission.read[gsdPath] !== 'allow') { config.permission.read[gsdPath] = 'allow'; modified = true; } // Configure external_directory permission (the safety guard for paths outside project) if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') { config.permission.external_directory = {}; } if (config.permission.external_directory[gsdPath] !== 'allow') { config.permission.external_directory[gsdPath] = 'allow'; modified = true; } if (!modified) { return; // Already configured } // Write config back fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); console.log(` ${green}✓${reset} Configured read permission for GSD docs`); } /** * 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; } /** * Write file manifest after installation for future modification detection */ function writeManifest(configDir, runtime = 'claude') { const isOpencode = runtime === 'opencode'; const isCodex = runtime === 'codex'; const gsdDir = path.join(configDir, 'get-shit-done'); const commandsDir = path.join(configDir, 'commands', 'gsd'); const opencodeCommandDir = path.join(configDir, 'command'); const codexSkillsDir = path.join(configDir, 'skills'); const agentsDir = path.join(configDir, 'agents'); const manifest = { version: pkg.version, timestamp: new Date().toISOString(), files: {} }; const gsdHashes = generateManifest(gsdDir); for (const [rel, hash] of Object.entries(gsdHashes)) { manifest.files['get-shit-done/' + rel] = hash; } if (!isOpencode && !isCodex && fs.existsSync(commandsDir)) { const cmdHashes = generateManifest(commandsDir); for (const [rel, hash] of Object.entries(cmdHashes)) { manifest.files['commands/gsd/' + rel] = hash; } } if (isOpencode && 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 && fs.existsSync(codexSkillsDir)) { for (const skillName of listCodexSkillNames(codexSkillsDir)) { const skillRoot = path.join(codexSkillsDir, skillName); const skillHashes = generateManifest(skillRoot); for (const [rel, hash] of Object.entries(skillHashes)) { manifest.files[`skills/${skillName}/${rel}`] = hash; } } } if (fs.existsSync(agentsDir)) { for (const file of fs.readdirSync(agentsDir)) { if (file.startsWith('gsd-') && file.endsWith('.md')) { manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file)); } } } fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2)); return manifest; } /** * Detect user-modified GSD files by comparing against install manifest. * Backs up modified files to gsd-local-patches/ for reapply after update. */ function saveLocalPatches(configDir) { const manifestPath = path.join(configDir, MANIFEST_NAME); if (!fs.existsSync(manifestPath)) return []; let manifest; try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return []; } const patchesDir = path.join(configDir, PATCHES_DIR_NAME); const modified = []; for (const [relPath, originalHash] of Object.entries(manifest.files || {})) { const fullPath = path.join(configDir, relPath); if (!fs.existsSync(fullPath)) continue; const currentHash = fileHash(fullPath); if (currentHash !== originalHash) { const backupPath = path.join(patchesDir, relPath); fs.mkdirSync(path.dirname(backupPath), { recursive: true }); fs.copyFileSync(fullPath, backupPath); modified.push(relPath); } } if (modified.length > 0) { const meta = { backed_up_at: new Date().toISOString(), from_version: manifest.version, files: modified }; 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); } } 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' ? '/gsd-reapply-patches' : runtime === 'codex' ? '$gsd-reapply-patches' : '/gsd:reapply-patches'; 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 install(isGlobal, runtime = 'claude') { const isOpencode = runtime === 'opencode'; const isGemini = runtime === 'gemini'; const isCodex = runtime === 'codex'; const dirName = getDirName(runtime); const src = path.join(__dirname, '..'); // 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(), '.'); // Path prefix for file references in markdown content // For global installs: use full path // For local installs: use relative const pathPrefix = isGlobal ? `${targetDir.replace(/\\/g, '/')}/` : `./${dirName}/`; let runtimeLabel = 'Claude Code'; if (isOpencode) runtimeLabel = 'OpenCode'; if (isGemini) runtimeLabel = 'Gemini'; if (isCodex) runtimeLabel = 'Codex'; console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`); // Track installation failures const failures = []; // Save any locally modified GSD files before they get wiped saveLocalPatches(targetDir); // Clean up orphaned files from previous versions cleanupOrphanedFiles(targetDir); // OpenCode uses command/ (flat), Codex uses skills/, Claude/Gemini use commands/gsd/ if (isOpencode) { // OpenCode: 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 = path.join(src, 'commands', 'gsd'); 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 (isCodex) { const skillsDir = path.join(targetDir, 'skills'); const gsdSrc = path.join(src, 'commands', 'gsd'); copyCommandsAsCodexSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); if (installedSkillNames.length > 0) { console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`); } else { failures.push('skills/gsd-*'); } } else { // Claude Code & Gemini: nested structure in commands/ directory const commandsDir = path.join(targetDir, 'commands'); fs.mkdirSync(commandsDir, { recursive: true }); const gsdSrc = path.join(src, 'commands', 'gsd'); const gsdDest = path.join(commandsDir, 'gsd'); copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true); if (verifyInstalled(gsdDest, 'commands/gsd')) { console.log(` ${green}✓${reset} Installed commands/gsd`); } else { failures.push('commands/gsd'); } } // Copy get-shit-done skill with path replacement const skillSrc = path.join(src, 'get-shit-done'); const skillDest = path.join(targetDir, 'get-shit-done'); copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime); if (verifyInstalled(skillDest, 'get-shit-done')) { console.log(` ${green}✓${reset} Installed get-shit-done`); } else { failures.push('get-shit-done'); } // Copy agents to agents directory const agentsSrc = path.join(src, 'agents'); if (fs.existsSync(agentsSrc)) { const agentsDest = path.join(targetDir, 'agents'); fs.mkdirSync(agentsDest, { recursive: true }); // Remove old GSD agents (gsd-*.md) before copying new ones if (fs.existsSync(agentsDest)) { for (const file of fs.readdirSync(agentsDest)) { if (file.startsWith('gsd-') && file.endsWith('.md')) { fs.unlinkSync(path.join(agentsDest, file)); } } } // 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'); // Always replace ~/.claude/ as it is the source of truth in the repo const dirRegex = /~\/\.claude\//g; content = content.replace(dirRegex, pathPrefix); content = processAttribution(content, getCommitAttribution(runtime)); // Convert frontmatter for runtime compatibility if (isOpencode) { content = convertClaudeToOpencodeFrontmatter(content); } else if (isGemini) { content = convertClaudeToGeminiAgent(content); } else if (isCodex) { content = convertClaudeAgentToCodexAgent(content); } fs.writeFileSync(path.join(agentsDest, entry.name), content); } } if (verifyInstalled(agentsDest, 'agents')) { console.log(` ${green}✓${reset} Installed agents`); } else { failures.push('agents'); } } // Copy CHANGELOG.md const changelogSrc = path.join(src, 'CHANGELOG.md'); const changelogDest = path.join(targetDir, 'get-shit-done', 'CHANGELOG.md'); if (fs.existsSync(changelogSrc)) { fs.copyFileSync(changelogSrc, changelogDest); if (verifyFileInstalled(changelogDest, 'CHANGELOG.md')) { console.log(` ${green}✓${reset} Installed CHANGELOG.md`); } else { failures.push('CHANGELOG.md'); } } // Write VERSION file const versionDest = path.join(targetDir, 'get-shit-done', 'VERSION'); fs.writeFileSync(versionDest, pkg.version); if (verifyFileInstalled(versionDest, 'VERSION')) { console.log(` ${green}✓${reset} Wrote VERSION (${pkg.version})`); } else { failures.push('VERSION'); } if (!isCodex) { // 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); // Template .js files to replace '.claude' with runtime-specific config dir if (entry.endsWith('.js')) { let content = fs.readFileSync(srcFile, 'utf8'); content = content.replace(/'\.claude'/g, configDirReplacement); fs.writeFileSync(destFile, content); } else { fs.copyFileSync(srcFile, destFile); } } } if (verifyInstalled(hooksDest, 'hooks')) { console.log(` ${green}✓${reset} Installed hooks (bundled)`); } else { failures.push('hooks'); } } } 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); console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`); // Report any backed-up local patches reportLocalPatches(targetDir, runtime); if (isCodex) { // Generate Codex config.toml and per-agent .toml files const agentCount = installCodexConfig(targetDir, agentsSrc); console.log(` ${green}✓${reset} Generated config.toml with ${agentCount} agent roles`); console.log(` ${green}✓${reset} Generated ${agentCount} agent .toml config files`); return { settingsPath: null, settings: null, statuslineCommand: null, runtime }; } // Configure statusline and hooks in settings.json // Gemini shares same hook system as Claude Code for now const settingsPath = path.join(targetDir, 'settings.json'); const settings = cleanupOrphanedHooks(readSettings(settingsPath)); const statuslineCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-statusline.js') : 'node ' + dirName + '/hooks/gsd-statusline.js'; const updateCheckCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-check-update.js') : 'node ' + dirName + '/hooks/gsd-check-update.js'; const contextMonitorCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-context-monitor.js') : 'node ' + dirName + '/hooks/gsd-context-monitor.js'; // 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) { 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')) ); if (!hasGsdUpdateHook) { settings.hooks.SessionStart.push({ hooks: [ { type: 'command', command: updateCheckCommand } ] }); console.log(` ${green}✓${reset} Configured update check hook`); } // Configure PostToolUse hook for context window monitoring if (!settings.hooks.PostToolUse) { settings.hooks.PostToolUse = []; } const hasContextMonitorHook = settings.hooks.PostToolUse.some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor')) ); if (!hasContextMonitorHook) { settings.hooks.PostToolUse.push({ hooks: [ { type: 'command', command: contextMonitorCommand } ] }); console.log(` ${green}✓${reset} Configured context window monitor hook`); } } return { settingsPath, settings, statuslineCommand, runtime }; } /** * Apply statusline config, then print completion message */ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true) { const isOpencode = runtime === 'opencode'; const isCodex = runtime === 'codex'; if (shouldInstallStatusline && !isOpencode && !isCodex) { settings.statusLine = { type: 'command', command: statuslineCommand }; console.log(` ${green}✓${reset} Configured statusline`); } // Write settings when runtime supports settings.json if (!isCodex) { writeSettings(settingsPath, settings); } // Configure OpenCode permissions if (isOpencode) { configureOpencodePermissions(isGlobal); } let program = 'Claude Code'; if (runtime === 'opencode') program = 'OpenCode'; if (runtime === 'gemini') program = 'Gemini'; if (runtime === 'codex') program = 'Codex'; let command = '/gsd:new-project'; if (runtime === 'opencode') command = '/gsd-new-project'; if (runtime === 'codex') command = '$gsd-new-project'; console.log(` ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}. ${cyan}Join the community:${reset} https://discord.gg/gsd `); } /** * 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 */ 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(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset} ${cyan}2${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - open source, free models ${cyan}3${reset}) Gemini ${dim}(~/.gemini)${reset} ${cyan}4${reset}) Codex ${dim}(~/.codex)${reset} ${cyan}5${reset}) All `); rl.question(` Choice ${dim}[1]${reset}: `, (answer) => { answered = true; rl.close(); const choice = answer.trim() || '1'; if (choice === '5') { callback(['claude', 'opencode', 'gemini', 'codex']); } else if (choice === '4') { callback(['codex']); } else if (choice === '3') { callback(['gemini']); } else if (choice === '2') { callback(['opencode']); } else { callback(['claude']); } }); } /** * 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); }); } /** * Install GSD for all selected runtimes */ function installAllRuntimes(runtimes, isGlobal, isInteractive) { const results = []; for (const runtime of runtimes) { const result = install(isGlobal, runtime); results.push(result); } const statuslineRuntimes = ['claude', 'gemini']; const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime)); const finalize = (shouldInstallStatusline) => { for (const result of results) { const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline; finishInstall( result.settingsPath, result.settings, result.statuslineCommand, useStatusline, result.runtime, isGlobal ); } }; if (primaryStatuslineResult) { handleStatusline(primaryStatuslineResult.settings, isInteractive, finalize); } else { finalize(false); } } // Test-only exports — skip main logic when loaded as a module for testing if (process.env.GSD_TEST_MODE) { module.exports = { getCodexSkillAdapterHeader, convertClaudeAgentToCodexAgent, generateCodexAgentToml, generateCodexConfigBlock, stripGsdFromCodexConfig, mergeCodexConfig, installCodexConfig, convertClaudeCommandToCodexSkill, GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, }; } else { // Main logic 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 else block for GSD_TEST_MODE