diff --git a/bin/install.js b/bin/install.js index b7f11e4cf..d189ef3e4 100755 --- a/bin/install.js +++ b/bin/install.js @@ -4,31 +4,49 @@ 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', -}; + +const { + cyan, green, yellow, dim, reset, + MANIFEST_NAME, PATCHES_DIR_NAME, + expandTilde, toHomePrefix, getDirName, getConfigDirFromHome, + getOpencodeGlobalDir, getGlobalDir, + readSettings, writeSettings, + buildHookCommand, + getCommitAttribution, processAttribution, + extractFrontmatterAndBody, extractFrontmatterField, + cleanupOrphanedFiles, cleanupOrphanedHooks, + parseJsonc, + fileHash, generateManifest, + saveLocalPatches, reportLocalPatches, + verifyInstalled, verifyFileInstalled, +} = require('./lib/core.js'); + +const { + convertClaudeToCodexMarkdown, + convertClaudeAgentToCodexAgent, + stripGsdFromCodexConfig, + installCodexConfig, + listCodexSkillNames, + copyCommandsAsCodexSkills, +} = require('./lib/codex.js'); + +const { + convertToolName, + convertClaudeToOpencodeFrontmatter, + configureOpencodePermissions, +} = require('./lib/opencode.js'); + +const { + convertGeminiToolName, + stripSubTags, + convertClaudeToGeminiAgent, + convertClaudeToGeminiToml, +} = require('./lib/gemini.js'); + +const { + registerHooks, + configureStatusline, +} = require('./lib/claude.js'); // Get version from package.json const pkg = require('../package.json'); @@ -58,121 +76,6 @@ if (hasAll) { if (hasCodex) selectedRuntimes.push('codex'); } -/** - * Convert a pathPrefix (which uses absolute paths for global installs) to a - * $HOME-relative form for replacing $HOME/.claude/ references in bash code blocks. - * Preserves $HOME as a shell variable so paths remain portable across machines. - */ -function toHomePrefix(pathPrefix) { - const home = os.homedir().replace(/\\/g, '/'); - const normalized = pathPrefix.replace(/\\/g, '/'); - if (normalized.startsWith(home)) { - return '$HOME' + normalized.slice(home.length); - } - // For relative paths or paths not under $HOME, return as-is - return normalized; -} - -// 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' + @@ -222,767 +125,8 @@ if (hasHelp) { 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) { - let before = existing.substring(0, markerIndex).trimEnd(); - if (before) { - // Strip any GSD-managed sections that leaked above the marker from previous installs - before = before.replace(/^\[agents\.gsd-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, ''); - before = before.replace(/^\[agents\]\n(?:(?!\[)[^\n]*\n?)*/m, ''); - before = before.replace(/\n{3,}/g, '\n\n').trimEnd(); - - // Re-inject feature keys if user has [features] above the marker - const hasFeatures = /^\[features\]\s*$/m.test(before); - if (hasFeatures) { - if (!before.includes('multi_agent')) { - before = before.replace(/^\[features\]\s*$/m, '[features]\nmulti_agent = true'); - } - if (!before.includes('default_mode_request_user_input')) { - before = before.replace(/^\[features\].*$/m, '$&\ndefault_mode_request_user_input = true'); - } - } - // Skip [features] from gsdBlock if user already has it - const block = hasFeatures - ? GSD_CODEX_MARKER + '\n' + gsdBlock.substring(gsdBlock.indexOf('[agents]')) - : gsdBlock; - fs.writeFileSync(configPath, before + '\n\n' + block + '\n'); - } else { - fs.writeFileSync(configPath, gsdBlock + '\n'); - } - 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 = []; - - // Compute the Codex pathPrefix for replacing .claude paths - const codexPathPrefix = `${targetDir.replace(/\\/g, '/')}/`; - - for (const file of agentEntries) { - let content = fs.readFileSync(path.join(agentsSrc, file), 'utf8'); - // Replace .claude paths before generating TOML (source files use ~/.claude and $HOME/.claude) - content = content.replace(/~\/\.claude\//g, codexPathPrefix); - content = content.replace(/\$HOME\/\.claude\//g, toHomePrefix(codexPathPrefix)); - 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 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"'); - - // 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 @@ -1043,69 +187,6 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) { } } -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 globalClaudeHomeRegex = /\$HOME\/\.claude\//g; - const localClaudeRegex = /\.\/\.claude\//g; - const codexDirRegex = /~\/\.codex\//g; - content = content.replace(globalClaudeRegex, pathPrefix); - content = content.replace(globalClaudeHomeRegex, toHomePrefix(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 @@ -1171,81 +252,6 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand } } -/** - * 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 @@ -1543,170 +549,6 @@ function uninstall(isGlobal, runtime = 'claude') { `); } -/** - * 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 @@ -1714,41 +556,6 @@ function verifyFileInstalled(filePath, description) { * @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 */ @@ -1800,78 +607,6 @@ function writeManifest(configDir, runtime = 'claude') { 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'; @@ -2118,8 +853,6 @@ function install(isGlobal, runtime = 'claude') { } // Configure statusline and hooks in settings.json - // Gemini uses AfterTool instead of PostToolUse for post-tool hooks - const postToolEvent = runtime === 'gemini' ? 'AfterTool' : 'PostToolUse'; const settingsPath = path.join(targetDir, 'settings.json'); const settings = cleanupOrphanedHooks(readSettings(settingsPath)); const statuslineCommand = isGlobal @@ -2143,52 +876,8 @@ function install(isGlobal, runtime = 'claude') { } } - // 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 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')) - ); - - if (!hasContextMonitorHook) { - settings.hooks[postToolEvent].push({ - hooks: [ - { - type: 'command', - command: contextMonitorCommand - } - ] - }); - console.log(` ${green}✓${reset} Configured context window monitor hook`); - } - } + // Configure SessionStart and PostToolUse/AfterTool hooks (skip for opencode/codex) + registerHooks(settings, runtime, updateCheckCommand, contextMonitorCommand); return { settingsPath, settings, statuslineCommand, runtime }; } @@ -2201,10 +890,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS const isCodex = runtime === 'codex'; if (shouldInstallStatusline && !isOpencode && !isCodex) { - settings.statusLine = { - type: 'command', - command: statuslineCommand - }; + configureStatusline(settings, statuslineCommand); console.log(` ${green}✓${reset} Configured statusline`); } @@ -2408,9 +1094,34 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { } } -// Test-only exports — skip main logic when loaded as a module for testing +// Test-only exports — backward-compatible re-exports of per-module functions. +// Most tests now import from bin/lib/ directly. This block retained for +// orchestration functions (copyWithPathReplacement, copyFlattenedCommands) +// and for any external consumers relying on the GSD_TEST_MODE interface. if (process.env.GSD_TEST_MODE) { + // Inline require for identifiers not used in install.js function body + // but re-exported for backward compatibility with external consumers. + const { + convertSlashCommandsToCodexSkillMentions, + getCodexSkillAdapterHeader, + convertClaudeCommandToCodexSkill, + generateCodexAgentToml, + generateCodexConfigBlock, + mergeCodexConfig, + toSingleLine, + yamlQuote, + } = require('./lib/codex.js'); + const { + GSD_CODEX_MARKER, + CODEX_AGENT_SANDBOX, + colorNameToHex, + claudeToOpencodeTools, + claudeToGeminiTools, + } = require('./lib/core.js'); + module.exports = { + // Codex exports + convertSlashCommandsToCodexSkillMentions, getCodexSkillAdapterHeader, convertClaudeAgentToCodexAgent, generateCodexAgentToml, @@ -2421,6 +1132,42 @@ if (process.env.GSD_TEST_MODE) { convertClaudeCommandToCodexSkill, GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, + // Converter functions (Plans 01-03) + convertClaudeToOpencodeFrontmatter, + convertClaudeToGeminiAgent, + convertClaudeToGeminiToml, + convertClaudeToCodexMarkdown, + convertToolName, + convertGeminiToolName, + extractFrontmatterAndBody, + extractFrontmatterField, + stripSubTags, + toSingleLine, + yamlQuote, + // Core / shared helpers + processAttribution, + expandTilde, + buildHookCommand, + readSettings, + writeSettings, + toHomePrefix, + getCommitAttribution, + copyWithPathReplacement, + copyFlattenedCommands, + cleanupOrphanedFiles, + cleanupOrphanedHooks, + generateManifest, + fileHash, + parseJsonc, + configureOpencodePermissions, + copyCommandsAsCodexSkills, + listCodexSkillNames, + getDirName, + getConfigDirFromHome, + // Constants + colorNameToHex, + claudeToOpencodeTools, + claudeToGeminiTools, }; } else { diff --git a/bin/lib/claude.js b/bin/lib/claude.js new file mode 100644 index 000000000..01598c26a --- /dev/null +++ b/bin/lib/claude.js @@ -0,0 +1,91 @@ +'use strict'; + +const { + green, reset, +} = require('./core.js'); + +// ─── Hook Registration ────────────────────────────── + +/** + * Register GSD hooks in settings.json (SessionStart, PostToolUse/AfterTool). + * Claude and Gemini use settings.json hooks; OpenCode and Codex do not. + * + * @param {object} settings - The parsed settings.json object + * @param {string} runtime - Target runtime ('claude', 'gemini', etc.) + * @param {string} updateCheckCommand - Command string for gsd-check-update hook + * @param {string} contextMonitorCommand - Command string for gsd-context-monitor hook + * @returns {object} The modified settings object + */ +function registerHooks(settings, runtime, updateCheckCommand, contextMonitorCommand) { + const isOpencode = runtime === 'opencode'; + if (isOpencode) return settings; + + const postToolEvent = runtime === 'gemini' ? 'AfterTool' : 'PostToolUse'; + + 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`); + } + + 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')) + ); + + if (!hasContextMonitorHook) { + settings.hooks[postToolEvent].push({ + hooks: [ + { + type: 'command', + command: contextMonitorCommand + } + ] + }); + console.log(` ${green}✓${reset} Configured context window monitor hook`); + } + + return settings; +} + +// ─── Statusline Configuration ──────────────────────── + +/** + * Configure the statusline in settings.json. + * + * @param {object} settings - The parsed settings.json object + * @param {string} statuslineCommand - Command string for the statusline + * @returns {object} The modified settings object + */ +function configureStatusline(settings, statuslineCommand) { + settings.statusLine = { + type: 'command', + command: statuslineCommand + }; + return settings; +} + +module.exports = { + registerHooks, + configureStatusline, +}; diff --git a/bin/lib/codex.js b/bin/lib/codex.js new file mode 100644 index 000000000..2c9fa13f3 --- /dev/null +++ b/bin/lib/codex.js @@ -0,0 +1,398 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const { + GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, + processAttribution, getCommitAttribution, + extractFrontmatterAndBody, extractFrontmatterField, + getDirName, toHomePrefix, + green, yellow, dim, reset, + verifyInstalled, +} = require('./core.js'); + +// ─── Local Helpers ──────────────────────────────────── + +function toSingleLine(value) { + return value.replace(/\s+/g, ' ').trim(); +} + +function yamlQuote(value) { + return JSON.stringify(value); +} + +// ─── Codex Converters ───────────────────────────────── + +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) { + let before = existing.substring(0, markerIndex).trimEnd(); + if (before) { + // Strip any GSD-managed sections that leaked above the marker from previous installs + before = before.replace(/^\[agents\.gsd-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, ''); + before = before.replace(/^\[agents\]\n(?:(?!\[)[^\n]*\n?)*/m, ''); + before = before.replace(/\n{3,}/g, '\n\n').trimEnd(); + + // Re-inject feature keys if user has [features] above the marker + const hasFeatures = /^\[features\]\s*$/m.test(before); + if (hasFeatures) { + if (!before.includes('multi_agent')) { + before = before.replace(/^\[features\]\s*$/m, '[features]\nmulti_agent = true'); + } + if (!before.includes('default_mode_request_user_input')) { + before = before.replace(/^\[features\].*$/m, '$&\ndefault_mode_request_user_input = true'); + } + } + // Skip [features] from gsdBlock if user already has it + const block = hasFeatures + ? GSD_CODEX_MARKER + '\n' + gsdBlock.substring(gsdBlock.indexOf('[agents]')) + : gsdBlock; + fs.writeFileSync(configPath, before + '\n\n' + block + '\n'); + } else { + fs.writeFileSync(configPath, gsdBlock + '\n'); + } + 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 = []; + + // Compute the Codex pathPrefix for replacing .claude paths + const codexPathPrefix = `${targetDir.replace(/\\/g, '/')}/`; + + for (const file of agentEntries) { + let content = fs.readFileSync(path.join(agentsSrc, file), 'utf8'); + // Replace .claude paths before generating TOML (source files use ~/.claude and $HOME/.claude) + content = content.replace(/~\/\.claude\//g, codexPathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, toHomePrefix(codexPathPrefix)); + 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; +} + +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 globalClaudeHomeRegex = /\$HOME\/\.claude\//g; + const localClaudeRegex = /\.\/\.claude\//g; + const codexDirRegex = /~\/\.codex\//g; + content = content.replace(globalClaudeRegex, pathPrefix); + content = content.replace(globalClaudeHomeRegex, toHomePrefix(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); +} + +// ─── Exports ────────────────────────────────────────── + +module.exports = { + convertSlashCommandsToCodexSkillMentions, + convertClaudeToCodexMarkdown, + getCodexSkillAdapterHeader, + convertClaudeCommandToCodexSkill, + convertClaudeAgentToCodexAgent, + generateCodexAgentToml, + generateCodexConfigBlock, + stripGsdFromCodexConfig, + mergeCodexConfig, + installCodexConfig, + listCodexSkillNames, + copyCommandsAsCodexSkills, + // Re-export constants that tests import via codex module + GSD_CODEX_MARKER, + CODEX_AGENT_SANDBOX, + // Re-export local helpers used by GSD_TEST_MODE exports in install.js + toSingleLine, + yamlQuote, +}; diff --git a/bin/lib/core.js b/bin/lib/core.js new file mode 100644 index 000000000..008978ba1 --- /dev/null +++ b/bin/lib/core.js @@ -0,0 +1,647 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const crypto = require('crypto'); + +const pkg = require('../../package.json'); + +// ─── Constants ──────────────────────────────────────── + +// 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', +}; + +// 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', +}; + +// ────────────────────────────────────────────────────── +// Local Patch Persistence +// ────────────────────────────────────────────────────── + +const PATCHES_DIR_NAME = 'gsd-local-patches'; +const MANIFEST_NAME = 'gsd-file-manifest.json'; + +// ─── Path Helpers ───────────────────────────────────── + +/** + * Convert a pathPrefix (which uses absolute paths for global installs) to a + * $HOME-relative form for replacing $HOME/.claude/ references in bash code blocks. + * Preserves $HOME as a shell variable so paths remain portable across machines. + */ +function toHomePrefix(pathPrefix) { + const home = os.homedir().replace(/\\/g, '/'); + const normalized = pathPrefix.replace(/\\/g, '/'); + if (normalized.startsWith(home)) { + return '$HOME' + normalized.slice(home.length); + } + // For relative paths or paths not under $HOME, return as-is + return normalized; +} + +// 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'); +} + +/** + * 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; +} + +// ─── Hook Helpers ───────────────────────────────────── + +/** + * 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}"`; +} + +// ─── Settings I/O ───────────────────────────────────── + +/** + * 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'); +} + +// ─── Attribution ────────────────────────────────────── + +// 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', null), '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', null), '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}`); +} + +// ─── Frontmatter Helpers ────────────────────────────── + +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, ''); +} + +// ─── Cleanup ────────────────────────────────────────── + +/** + * 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; +} + +// ─── JSONC ──────────────────────────────────────────── + +/** + * 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); +} + +// ─── File Utilities ─────────────────────────────────── + +/** + * 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; +} + +/** + * 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 || []; +} + +// ─── Verification ───────────────────────────────────── + +/** + * 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; +} + +// ─── Exports ────────────────────────────────────────── + +module.exports = { + // Constants + cyan, green, yellow, dim, reset, + GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, + colorNameToHex, claudeToOpencodeTools, claudeToGeminiTools, + MANIFEST_NAME, PATCHES_DIR_NAME, + // Path helpers + expandTilde, toHomePrefix, getDirName, getConfigDirFromHome, + getOpencodeGlobalDir, getGlobalDir, + // Settings + readSettings, writeSettings, + // Hooks + buildHookCommand, + // Attribution + getCommitAttribution, processAttribution, + // Frontmatter + extractFrontmatterAndBody, extractFrontmatterField, + // Cleanup + cleanupOrphanedFiles, cleanupOrphanedHooks, + // JSONC + parseJsonc, + // File utilities + fileHash, generateManifest, + saveLocalPatches, reportLocalPatches, + // Verification + verifyInstalled, verifyFileInstalled, +}; diff --git a/bin/lib/gemini.js b/bin/lib/gemini.js new file mode 100644 index 000000000..9a7699b5d --- /dev/null +++ b/bin/lib/gemini.js @@ -0,0 +1,175 @@ +'use strict'; + +const { + claudeToGeminiTools, + extractFrontmatterAndBody, extractFrontmatterField, +} = require('./core.js'); + +/** + * 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(); +} + +/** + * 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)}`; +} + +/** + * 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; +} + +module.exports = { + convertGeminiToolName, + stripSubTags, + convertClaudeToGeminiAgent, + convertClaudeToGeminiToml, +}; diff --git a/bin/lib/opencode.js b/bin/lib/opencode.js new file mode 100644 index 000000000..e847ac52c --- /dev/null +++ b/bin/lib/opencode.js @@ -0,0 +1,216 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { + claudeToOpencodeTools, colorNameToHex, + extractFrontmatterAndBody, extractFrontmatterField, + parseJsonc, + getOpencodeGlobalDir, + green, yellow, dim, reset, +} = require('./core.js'); + +/** + * 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(); +} + +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 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"'); + + // 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}`; +} + +/** + * 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`); +} + +module.exports = { + convertToolName, + convertClaudeToOpencodeFrontmatter, + configureOpencodePermissions, +}; diff --git a/package-lock.json b/package-lock.json index 6b969aa9d..10747a9e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "get-shit-done-cc": "bin/install.js" }, "devDependencies": { + "@stryker-mutator/core": "^9.6.0", "c8": "^11.0.0", "esbuild": "^0.24.0" }, @@ -19,6 +20,547 @@ "node": ">=16.7.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz", + "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-decorators": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", + "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -327,131 +869,475 @@ ], "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.3.tgz", + "integrity": "sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.0.tgz", + "integrity": "sha512-/HjF1LN0a1h4/OFsbGKHNDtWICFU/dqXCdym719HFTyJo9IG7Otr+ziGWc9S0iQuohRZllh+WprSgd5UW5Fw0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/core": "^11.1.5", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.8.tgz", + "integrity": "sha512-Di6dgmiZ9xCSUxWUReWTqDtbhXCuG2MQm2xmgSAIruzQzBqNf49b8E07/vbCYY506kDe8BiwJbegXweG8M1klw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.5.tgz", + "integrity": "sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.8.tgz", + "integrity": "sha512-sLcpbb9B3XqUEGrj1N66KwhDhEckzZ4nI/W6SvLXyBX8Wic3LDLENlWRvkOGpCPoserabe+MxQkpiMoI8irvyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/external-editor": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.8.tgz", + "integrity": "sha512-QieW3F1prNw3j+hxO7/NKkG1pk3oz7pOB6+5Upwu3OIwADfPX0oZVppsqlL+Vl/uBHHDSOBY0BirLctLnXwGGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-2.0.3.tgz", + "integrity": "sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.3.tgz", + "integrity": "sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/input": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.8.tgz", + "integrity": "sha512-p0IJslw0AmedLEkOU+yrEX3Aj2RTpQq7ZOf8nc1DIhjzaxRWrrgeuE5Kyh39fVRgtcACaMXx/9WNo8+GjgBOfw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/type": "^4.0.3" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", - "cpu": [ - "arm64" - ], + "node_modules/@inquirer/number": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.8.tgz", + "integrity": "sha512-uGLiQah9A0F9UIvJBX52m0CnqtLaym0WpT9V4YZrjZ+YRDKZdwwoEPz06N6w8ChE2lrnsdyhY9sL+Y690Kh9gQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/type": "^4.0.3" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/password": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.8.tgz", + "integrity": "sha512-zt1sF4lYLdvPqvmvHdmjOzuUUjuCQ897pdUCO8RbXMUDKXJTTyOQgtn23le+jwcb+MpHl3VAFvzIdxRAf6aPlA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/core": "^11.1.5", + "@inquirer/type": "^4.0.3" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/prompts": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.3.0.tgz", + "integrity": "sha512-JAj66kjdH/F1+B7LCigjARbwstt3SNUOSzMdjpsvwJmzunK88gJeXmcm95L9nw1KynvFVuY4SzXh/3Y0lvtgSg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "@inquirer/checkbox": "^5.1.0", + "@inquirer/confirm": "^6.0.8", + "@inquirer/editor": "^5.0.8", + "@inquirer/expand": "^5.0.8", + "@inquirer/input": "^5.0.8", + "@inquirer/number": "^4.0.8", + "@inquirer/password": "^5.0.8", + "@inquirer/rawlist": "^5.2.4", + "@inquirer/search": "^4.1.4", + "@inquirer/select": "^5.1.0" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", - "cpu": [ - "arm64" - ], + "node_modules/@inquirer/rawlist": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.4.tgz", + "integrity": "sha512-fTuJ5Cq9W286isLxwj6GGyfTjx1Zdk4qppVEPexFuA6yioCCXS4V1zfKroQqw7QdbDPN73xs2DiIAlo55+kBqg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/type": "^4.0.3" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", - "cpu": [ - "ia32" - ], + "node_modules/@inquirer/search": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.4.tgz", + "integrity": "sha512-9yPTxq7LPmYjrGn3DRuaPuPbmC6u3fiWcsE9ggfLcdgO/ICHYgxq7mEy1yJ39brVvgXhtOtvDVjDh9slJxE4LQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.0.tgz", + "integrity": "sha512-OyYbKnchS1u+zRe14LpYrN8S0wH1vD0p2yKISvSsJdH2TpI87fh4eZdWnpdbrGauCRWDph3NwxRmM4Pcm/hx1Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/core": "^11.1.5", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.3.tgz", + "integrity": "sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@istanbuljs/schema": { @@ -464,6 +1350,28 @@ "node": ">=8" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -492,6 +1400,121 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stryker-mutator/api": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-9.6.0.tgz", + "integrity": "sha512-kJEEwOVoWDXGEIXuM+9efT6LSJ7nyxnQQvjEoKg8GSZXbDUjfD0tqA0aBD06U1SzQLKCM7ffjgPffr154MHZKw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-metrics": "3.7.2", + "mutation-testing-report-schema": "3.7.2", + "tslib": "~2.8.0", + "typed-inject": "~5.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stryker-mutator/core": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-9.6.0.tgz", + "integrity": "sha512-oSbw01l6HXHt0iW9x5fQj7yHGGT8ZjCkXSkI7Bsu0juO7Q6vRMXk7XcvKpCBgRgzKXi1osg8+iIzj7acHuxepQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@inquirer/prompts": "^8.0.0", + "@stryker-mutator/api": "9.6.0", + "@stryker-mutator/instrumenter": "9.6.0", + "@stryker-mutator/util": "9.6.0", + "ajv": "~8.18.0", + "chalk": "~5.6.0", + "commander": "~14.0.0", + "diff-match-patch": "1.0.5", + "emoji-regex": "~10.6.0", + "execa": "~9.6.0", + "json-rpc-2.0": "^1.7.0", + "lodash.groupby": "~4.6.0", + "minimatch": "~10.2.4", + "mutation-server-protocol": "~0.4.0", + "mutation-testing-elements": "3.7.2", + "mutation-testing-metrics": "3.7.2", + "mutation-testing-report-schema": "3.7.2", + "npm-run-path": "~6.0.0", + "progress": "~2.0.3", + "rxjs": "~7.8.1", + "semver": "^7.6.3", + "source-map": "~0.7.4", + "tree-kill": "~1.2.2", + "tslib": "2.8.1", + "typed-inject": "~5.0.0", + "typed-rest-client": "~2.2.0" + }, + "bin": { + "stryker": "bin/stryker.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stryker-mutator/core/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stryker-mutator/instrumenter": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-9.6.0.tgz", + "integrity": "sha512-tWdRYfm9LF4Go7cNOos0xEIOEnN7ZOSj38rfXvGZS9IINlvYBrBCl2xcz/67v6l5A7xksMWWByZRIq2bgdnnUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/core": "~7.29.0", + "@babel/generator": "~7.29.0", + "@babel/parser": "~7.29.0", + "@babel/plugin-proposal-decorators": "~7.29.0", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/preset-typescript": "~7.28.0", + "@stryker-mutator/api": "9.6.0", + "@stryker-mutator/util": "9.6.0", + "angular-html-parser": "~10.4.0", + "semver": "~7.7.0", + "tslib": "2.8.1", + "weapon-regex": "~1.3.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stryker-mutator/util": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-9.6.0.tgz", + "integrity": "sha512-gw7fJOFNHEj9inAEOodD9RrrMEMhZmWJ46Ww/kDJAXlSsBBmdwCzeomNLngmLTvgp14z7Tfq85DHYwvmNMdOxA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -499,6 +1522,33 @@ "dev": true, "license": "MIT" }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/angular-html-parser": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/angular-html-parser/-/angular-html-parser-10.4.0.tgz", + "integrity": "sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -535,6 +1585,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/brace-expansion": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", @@ -548,6 +1611,40 @@ "node": "18 || 20 || >=22" } }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/c8": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", @@ -571,15 +1668,97 @@ "c8": "bin/c8.js" }, "engines": { - "node": "20 || >=22" - }, - "peerDependencies": { - "monocart-coverage-reports": "^2" + "node": "20 || >=22" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "monocart-coverage-reports": { - "optional": true + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001776", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", + "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" } }, "node_modules/cliui": { @@ -617,6 +1796,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -639,6 +1828,64 @@ "node": ">= 8" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -646,6 +1893,39 @@ "dev": true, "license": "MIT" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", @@ -697,6 +1977,100 @@ "node": ">=6" } }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -731,6 +2105,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -741,6 +2135,62 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -759,6 +2209,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -769,6 +2232,32 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -776,6 +2265,40 @@ "dev": true, "license": "MIT" }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -786,6 +2309,45 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -818,18 +2380,72 @@ "node": ">=10" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-rpc-2.0": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/json-rpc-2.0/-/json-rpc-2.0-1.7.1.tgz", + "integrity": "sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": ">=8" + "node": ">=6" } }, "node_modules/locate-path": { @@ -848,6 +2464,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.2.6", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", @@ -874,10 +2497,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, "node_modules/minimatch": { - "version": "10.2.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", - "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -900,6 +2540,110 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mutation-server-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mutation-server-protocol/-/mutation-server-protocol-0.4.1.tgz", + "integrity": "sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.1.12" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mutation-testing-elements": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-3.7.2.tgz", + "integrity": "sha512-i7X2Q4X5eYon72W2QQ9HND7plVhQcqTnv+Xc3KeYslRZSJ4WYJoal8LFdbWm7dKWLNE0rYkCUrvboasWzF3MMA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mutation-testing-metrics": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-3.7.2.tgz", + "integrity": "sha512-ichXZSC4FeJbcVHYOWzWUhNuTJGogc0WiQol8lqEBrBSp+ADl3fmcZMqrx0ogInEUiImn+A8JyTk6uh9vd25TQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-report-schema": "3.7.2" + } + }, + "node_modules/mutation-testing-report-schema": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-3.7.2.tgz", + "integrity": "sha512-fN5M61SDzIOeJyatMOhGPLDOFz5BQIjTNPjo4PcHIEUWrejO4i4B5PFuQ/2l43709hEsTxeiXX00H73WERKcDw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -932,6 +2676,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -969,6 +2726,55 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -979,6 +2785,33 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -1015,6 +2848,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -1028,6 +2937,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1056,6 +2975,19 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -1084,6 +3016,111 @@ "node": "20 || >=22" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/typed-inject": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/typed-inject/-/typed-inject-5.0.0.tgz", + "integrity": "sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/typed-rest-client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.2.0.tgz", + "integrity": "sha512-/e2Rk9g20N0r44kaQLb3v6QGuryOD8SPb53t43Y5kqXXA+SqWuU7zLiMxetw61jNn/JFrxTdr5nPDhGY/eTNhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "des.js": "^1.1.0", + "js-md4": "^0.3.2", + "qs": "^6.14.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + }, + "engines": { + "node": ">= 16.0.0" + } + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -1099,6 +3136,13 @@ "node": ">=10.12.0" } }, + "node_modules/weapon-regex": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-1.3.6.tgz", + "integrity": "sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1143,6 +3187,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -1184,6 +3235,29 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 855d384c1..a3c0fc59b 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "node": ">=16.7.0" }, "devDependencies": { + "@stryker-mutator/core": "^9.6.0", "c8": "^11.0.0", "esbuild": "^0.24.0" }, @@ -46,6 +47,8 @@ "build:hooks": "node scripts/build-hooks.js", "prepublishOnly": "npm run build:hooks", "test": "node scripts/run-tests.cjs", - "test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs" + "test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs", + "test:coverage:install": "npx c8 --reporter text --include 'bin/install.js' --include 'bin/lib/*.js' --exclude 'tests/**' node scripts/run-tests.cjs", + "test:mutation": "npx stryker run" } } diff --git a/stryker.config.json b/stryker.config.json new file mode 100644 index 000000000..355315ec2 --- /dev/null +++ b/stryker.config.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker/master/packages/core/schema/stryker-core.schema.json", + "mutate": [ + "bin/lib/core.js", + "bin/lib/codex.js", + "bin/lib/opencode.js", + "bin/lib/gemini.js", + "bin/lib/claude.js", + "bin/install.js:152-198", + "bin/install.js:208-263" + ], + "testRunner": "command", + "commandRunner": { + "command": "node --test tests/codex-config.test.cjs tests/install-converters.test.cjs tests/install-utils.test.cjs tests/install-flow.test.cjs" + }, + "reporters": ["clear-text", "progress"], + "concurrency": 2, + "timeoutMS": 30000, + "mutator": { + "excludedMutations": [ + "StringLiteral" + ] + } +} diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index 758eedc84..f738c8eb2 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -5,9 +5,6 @@ * per-agent .toml generation, and uninstall cleanup. */ -// Enable test exports from install.js (skips main CLI logic) -process.env.GSD_TEST_MODE = '1'; - const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); @@ -23,7 +20,7 @@ const { mergeCodexConfig, GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, -} = require('../bin/install.js'); +} = require('../bin/lib/codex.js'); // ─── getCodexSkillAdapterHeader ───────────────────────────────────────────────── @@ -463,7 +460,7 @@ describe('installCodexConfig (integration)', () => { const hasAgents = fs.existsSync(agentsSrc); (hasAgents ? test : test.skip)('generates config.toml and agent .toml files', () => { - const { installCodexConfig } = require('../bin/install.js'); + const { installCodexConfig } = require('../bin/lib/codex.js'); const count = installCodexConfig(tmpTarget, agentsSrc); assert.ok(count >= 11, `installed ${count} agents (expected >= 11)`); diff --git a/tests/install-converters.test.cjs b/tests/install-converters.test.cjs new file mode 100644 index 000000000..59f7d49b8 --- /dev/null +++ b/tests/install-converters.test.cjs @@ -0,0 +1,666 @@ +/** + * GSD Tools Tests - install-converters.test.cjs + * + * Tests for all runtime converter functions in bin/install.js: + * - convertToolName (Claude -> OpenCode tool names) + * - convertGeminiToolName (Claude -> Gemini tool names) + * - convertClaudeToOpencodeFrontmatter (full agent/command conversion for OpenCode) + * - convertClaudeToGeminiAgent (full agent conversion for Gemini CLI) + * - convertClaudeToGeminiToml (command conversion to Gemini TOML format) + * - convertClaudeToCodexMarkdown (slash command conversion for Codex) + * - convertClaudeCommandToCodexSkill (wraps command with skill adapter header) + * - stripSubTags (removes HTML sub tags) + * - extractFrontmatterAndBody (splits content at --- delimiters) + * - toSingleLine / yamlQuote (string helpers) + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); + +const { + convertToolName, + convertClaudeToOpencodeFrontmatter, +} = require('../bin/lib/opencode.js'); + +const { + convertGeminiToolName, + stripSubTags, + convertClaudeToGeminiAgent, + convertClaudeToGeminiToml, +} = require('../bin/lib/gemini.js'); + +const { + convertClaudeToCodexMarkdown, + convertClaudeCommandToCodexSkill, + toSingleLine, + yamlQuote, +} = require('../bin/lib/codex.js'); + +const { + extractFrontmatterAndBody, + colorNameToHex, + claudeToOpencodeTools, + claudeToGeminiTools, +} = require('../bin/lib/core.js'); + +// ─── convertToolName ───────────────────────────────────────────────────────── + +describe('convertToolName', () => { + test('maps AskUserQuestion to question', () => { + assert.strictEqual(convertToolName('AskUserQuestion'), 'question'); + }); + + test('maps SlashCommand to skill', () => { + assert.strictEqual(convertToolName('SlashCommand'), 'skill'); + }); + + test('maps TodoWrite to todowrite', () => { + assert.strictEqual(convertToolName('TodoWrite'), 'todowrite'); + }); + + test('maps WebFetch to webfetch', () => { + assert.strictEqual(convertToolName('WebFetch'), 'webfetch'); + }); + + test('passes MCP tools through unchanged', () => { + assert.strictEqual(convertToolName('mcp__filesystem__read_file'), 'mcp__filesystem__read_file'); + assert.strictEqual(convertToolName('mcp__github__create_pr'), 'mcp__github__create_pr'); + }); + + test('lowercases standard tools', () => { + assert.strictEqual(convertToolName('Read'), 'read'); + assert.strictEqual(convertToolName('Write'), 'write'); + assert.strictEqual(convertToolName('Bash'), 'bash'); + assert.strictEqual(convertToolName('Grep'), 'grep'); + assert.strictEqual(convertToolName('Glob'), 'glob'); + }); + + test('lowercases unknown tools', () => { + assert.strictEqual(convertToolName('SomeFutureTool'), 'somefuturetool'); + }); +}); + +// ─── convertGeminiToolName ──────────────────────────────────────────────────── + +describe('convertGeminiToolName', () => { + test('maps Read to read_file', () => { + assert.strictEqual(convertGeminiToolName('Read'), 'read_file'); + }); + + test('maps Write to write_file', () => { + assert.strictEqual(convertGeminiToolName('Write'), 'write_file'); + }); + + test('maps Edit to replace', () => { + assert.strictEqual(convertGeminiToolName('Edit'), 'replace'); + }); + + test('maps Bash to run_shell_command', () => { + assert.strictEqual(convertGeminiToolName('Bash'), 'run_shell_command'); + }); + + test('maps Grep to search_file_content', () => { + assert.strictEqual(convertGeminiToolName('Grep'), 'search_file_content'); + }); + + test('maps WebSearch to google_web_search', () => { + assert.strictEqual(convertGeminiToolName('WebSearch'), 'google_web_search'); + }); + + test('maps WebFetch to web_fetch', () => { + assert.strictEqual(convertGeminiToolName('WebFetch'), 'web_fetch'); + }); + + test('returns null for mcp__* tools (excluded)', () => { + assert.strictEqual(convertGeminiToolName('mcp__filesystem__read'), null); + assert.strictEqual(convertGeminiToolName('mcp__github__list_prs'), null); + }); + + test('returns null for Task (excluded)', () => { + assert.strictEqual(convertGeminiToolName('Task'), null); + }); + + test('lowercases unknown tools', () => { + assert.strictEqual(convertGeminiToolName('SomeTool'), 'sometool'); + }); +}); + +// ─── convertClaudeToOpencodeFrontmatter ────────────────────────────────────── + +describe('convertClaudeToOpencodeFrontmatter', () => { + const agentWithInlineTools = `--- +name: gsd-executor +description: Executes GSD plans with atomic commits +tools: Read, Write, Edit, Bash, Grep, Glob +color: yellow +--- + + +You are a GSD plan executor. Run /gsd:execute-phase to proceed. +Use AskUserQuestion to ask the user a question. +Config lives in ~/.claude directory. +`; + + test('converts inline tools list to tools map with boolean values', () => { + const result = convertClaudeToOpencodeFrontmatter(agentWithInlineTools); + assert.ok(result.includes('tools:'), 'has tools section'); + assert.ok(result.includes(' read: true'), 'has read: true'); + assert.ok(result.includes(' write: true'), 'has write: true'); + assert.ok(result.includes(' bash: true'), 'has bash: true'); + }); + + test('strips name: field from frontmatter', () => { + const result = convertClaudeToOpencodeFrontmatter(agentWithInlineTools); + // Check frontmatter section specifically + const fmEnd = result.indexOf('---', 4); + const frontmatter = result.substring(0, fmEnd); + assert.ok(!frontmatter.includes('name:'), 'name field removed from frontmatter'); + }); + + test('converts color name to hex', () => { + const result = convertClaudeToOpencodeFrontmatter(agentWithInlineTools); + assert.ok(result.includes('color: "#FFFF00"'), 'yellow converted to hex #FFFF00'); + assert.ok(!result.includes('color: yellow'), 'original color name removed'); + }); + + test('converts cyan color name to hex', () => { + const input = `---\nname: test\ndescription: Test\ntools: Read\ncolor: cyan\n---\nBody`; + const result = convertClaudeToOpencodeFrontmatter(input); + assert.ok(result.includes('color: "#00FFFF"'), 'cyan converted to #00FFFF'); + }); + + test('skips unknown color values', () => { + const input = `---\nname: test\ndescription: Test\ntools: Read\ncolor: turquoise\n---\nBody`; + const result = convertClaudeToOpencodeFrontmatter(input); + assert.ok(!result.includes('color:'), 'unknown color name dropped'); + }); + + test('replaces AskUserQuestion with question in body', () => { + const result = convertClaudeToOpencodeFrontmatter(agentWithInlineTools); + assert.ok(result.includes('question'), 'AskUserQuestion replaced with question'); + assert.ok(!result.includes('AskUserQuestion'), 'original AskUserQuestion removed'); + }); + + test('replaces /gsd: with /gsd- in body', () => { + const result = convertClaudeToOpencodeFrontmatter(agentWithInlineTools); + assert.ok(result.includes('/gsd-execute-phase'), 'slash command prefix converted'); + assert.ok(!result.includes('/gsd:execute-phase'), 'original slash command prefix removed'); + }); + + test('replaces ~/.claude with ~/.config/opencode in body', () => { + const result = convertClaudeToOpencodeFrontmatter(agentWithInlineTools); + assert.ok(result.includes('~/.config/opencode'), 'path converted to opencode config'); + assert.ok(!result.includes('~/.claude'), 'original claude path removed'); + }); + + test('replaces $HOME/.claude with $HOME/.config/opencode', () => { + const input = `---\nname: t\ndescription: T\ntools: Read\n---\nSee $HOME/.claude for config.`; + const result = convertClaudeToOpencodeFrontmatter(input); + assert.ok(result.includes('$HOME/.config/opencode'), '$HOME path converted'); + assert.ok(!result.includes('$HOME/.claude'), 'original $HOME/.claude removed'); + }); + + test('returns content unchanged if no frontmatter', () => { + const input = 'Just some content without frontmatter.'; + const result = convertClaudeToOpencodeFrontmatter(input); + assert.strictEqual(result, input, 'no-frontmatter content unchanged'); + }); + + test('converts allowed-tools YAML array to tools map', () => { + const input = `--- +name: gsd-planner +description: Plans GSD phases +allowed-tools: + - Read + - Write + - Bash +--- + +Body content here.`; + const result = convertClaudeToOpencodeFrontmatter(input); + assert.ok(result.includes(' read: true'), 'allowed-tools Read converted'); + assert.ok(result.includes(' write: true'), 'allowed-tools Write converted'); + assert.ok(result.includes(' bash: true'), 'allowed-tools Bash converted'); + }); + + test('converts special tool names in tools map', () => { + const input = `---\nname: t\ndescription: T\ntools: AskUserQuestion, SlashCommand\n---\nBody`; + const result = convertClaudeToOpencodeFrontmatter(input); + assert.ok(result.includes(' question: true'), 'AskUserQuestion mapped to question'); + assert.ok(result.includes(' skill: true'), 'SlashCommand mapped to skill'); + }); +}); + +// ─── convertClaudeToGeminiAgent ─────────────────────────────────────────────── + +describe('convertClaudeToGeminiAgent', () => { + const sampleAgent = `--- +name: gsd-executor +description: Executes GSD plans +tools: Read, Write, Edit, Bash, Grep, Glob +color: yellow +--- + + +You are a GSD executor. Use ${'{PHASE}'} variable. +`; + + test('maps tools via convertGeminiToolName', () => { + const result = convertClaudeToGeminiAgent(sampleAgent); + assert.ok(result.includes(' - read_file'), 'Read mapped to read_file'); + assert.ok(result.includes(' - write_file'), 'Write mapped to write_file'); + assert.ok(result.includes(' - replace'), 'Edit mapped to replace'); + assert.ok(result.includes(' - run_shell_command'), 'Bash mapped to run_shell_command'); + assert.ok(result.includes(' - search_file_content'), 'Grep mapped to search_file_content'); + }); + + test('builds tools as YAML array format', () => { + const result = convertClaudeToGeminiAgent(sampleAgent); + // tools: should be followed by array items, not inline comma-separated + assert.ok(result.includes('tools:\n - '), 'tools formatted as YAML array'); + }); + + test('strips color field', () => { + const result = convertClaudeToGeminiAgent(sampleAgent); + assert.ok(!result.includes('color:'), 'color field removed'); + }); + + test('escapes ${VAR} patterns to $VAR in body', () => { + const inputWithVar = '---\nname: gsd-test\ndescription: Test\ntools: Read\n---\n\nUse ${PHASE} variable.'.replace('${PHASE}', '$' + '{PHASE}'); + const result = convertClaudeToGeminiAgent(inputWithVar); + assert.ok(result.includes('$PHASE'), 'template variable braces removed'); + assert.ok(!result.includes('$' + '{PHASE}'), 'original ${PHASE} pattern removed'); + }); + + test('returns content unchanged if no frontmatter', () => { + const input = 'No frontmatter content.'; + const result = convertClaudeToGeminiAgent(input); + assert.strictEqual(result, input, 'no-frontmatter content unchanged'); + }); + + test('excludes MCP tools from output', () => { + const input = `--- +name: gsd-test +description: Test +tools: Read, mcp__filesystem__read +--- + +Body`; + const result = convertClaudeToGeminiAgent(input); + assert.ok(!result.includes('mcp__'), 'MCP tools excluded'); + assert.ok(result.includes('read_file'), 'standard tools kept'); + }); + + test('excludes Task tool from output', () => { + const input = `--- +name: gsd-test +description: Test +tools: Read, Task +--- + +Body`; + const result = convertClaudeToGeminiAgent(input); + assert.ok(!result.includes(' - task'), 'Task tool excluded'); + }); + + test('calls stripSubTags on body', () => { + const input = `--- +name: gsd-test +description: Test +tools: Read +--- + +Use subscript text here.`; + const result = convertClaudeToGeminiAgent(input); + assert.ok(result.includes('*(subscript text)*'), 'sub tags converted to italic'); + assert.ok(!result.includes(''), 'opening sub tag removed'); + assert.ok(!result.includes(''), 'closing sub tag removed'); + }); + + test('converts allowed-tools YAML array format', () => { + const input = `--- +name: gsd-test +description: Test +allowed-tools: + - Read + - Bash +--- + +Body`; + const result = convertClaudeToGeminiAgent(input); + assert.ok(result.includes(' - read_file'), 'allowed-tools Read converted'); + assert.ok(result.includes(' - run_shell_command'), 'allowed-tools Bash converted'); + }); +}); + +// ─── convertClaudeToGeminiToml ──────────────────────────────────────────────── + +describe('convertClaudeToGeminiToml', () => { + test('extracts description from frontmatter into TOML description field', () => { + const input = `--- +description: Execute a GSD plan phase +--- + +Run the plan and commit each task.`; + const result = convertClaudeToGeminiToml(input); + assert.ok(result.includes('description = "Execute a GSD plan phase"'), 'description in TOML'); + }); + + test('puts body content into prompt field with JSON.stringify quoting', () => { + const input = `--- +description: Test command +--- + +Do something important.`; + const result = convertClaudeToGeminiToml(input); + assert.ok(result.includes('prompt = '), 'has prompt field'); + // Body should be JSON-stringified (quoted) + assert.ok(result.match(/prompt = ".*"/s) || result.match(/prompt = '[^']*'/), 'prompt is quoted'); + }); + + test('returns just prompt field if no description', () => { + const input = `--- +name: gsd-execute +--- + +Execute the plan.`; + const result = convertClaudeToGeminiToml(input); + assert.ok(!result.includes('description ='), 'no description field when absent'); + assert.ok(result.includes('prompt = '), 'has prompt field'); + }); + + test('returns prompt-wrapped content if no frontmatter', () => { + const input = 'Plain content without frontmatter.'; + const result = convertClaudeToGeminiToml(input); + assert.ok(result.includes('prompt = '), 'wraps plain content in prompt field'); + assert.ok(result.includes('Plain content without frontmatter.'), 'content preserved in prompt'); + }); + + test('handles multiline body content', () => { + const input = `--- +description: Multi-line test +--- + +Line 1. +Line 2. +Line 3.`; + const result = convertClaudeToGeminiToml(input); + assert.ok(result.includes('description = "Multi-line test"'), 'description present'); + assert.ok(result.includes('prompt = '), 'prompt present'); + }); + + test('JSON.stringify escapes special characters in prompt', () => { + const input = `--- +description: Test +--- + +Has "quotes" and backslashes \\ here.`; + const result = convertClaudeToGeminiToml(input); + // JSON.stringify wraps the value in double quotes and escapes internal quotes + assert.ok(result.includes('prompt = '), 'prompt field present'); + }); +}); + +// ─── convertClaudeToCodexMarkdown ───────────────────────────────────────────── + +describe('convertClaudeToCodexMarkdown', () => { + test('converts /gsd: to $gsd- in content', () => { + const input = 'Run /gsd:execute-phase to proceed.'; + const result = convertClaudeToCodexMarkdown(input); + assert.ok(result.includes('$gsd-execute-phase'), 'slash command converted'); + assert.ok(!result.includes('/gsd:execute-phase'), 'original removed'); + }); + + test('converts multiple slash commands', () => { + const input = 'Use /gsd:plan-phase then /gsd:execute-phase.'; + const result = convertClaudeToCodexMarkdown(input); + assert.ok(result.includes('$gsd-plan-phase'), 'first command converted'); + assert.ok(result.includes('$gsd-execute-phase'), 'second command converted'); + }); + + test('converts $ARGUMENTS to {{GSD_ARGS}}', () => { + const input = 'Pass $ARGUMENTS to the command.'; + const result = convertClaudeToCodexMarkdown(input); + assert.ok(result.includes('{{GSD_ARGS}}'), 'ARGUMENTS converted to GSD_ARGS'); + assert.ok(!result.includes('$ARGUMENTS'), 'original removed'); + }); + + test('leaves non-gsd content unchanged', () => { + const input = 'Regular content with no GSD commands.'; + const result = convertClaudeToCodexMarkdown(input); + assert.strictEqual(result, input, 'unchanged content returned as-is'); + }); + + test('handles /gsd:command in frontmatter body', () => { + const input = `--- +name: test +description: Test command +--- + +Invoke /gsd:quick to start a quick plan.`; + const result = convertClaudeToCodexMarkdown(input); + assert.ok(result.includes('$gsd-quick'), 'command in body converted'); + }); +}); + +// ─── convertClaudeCommandToCodexSkill ───────────────────────────────────────── + +describe('convertClaudeCommandToCodexSkill', () => { + const sampleCommand = `--- +name: gsd-execute-phase +description: Execute a GSD phase by running all plans in sequence +--- + +Run /gsd:execute-phase with $ARGUMENTS to proceed.`; + + test('prepends skill adapter header before the command content', () => { + const result = convertClaudeCommandToCodexSkill(sampleCommand, 'gsd-execute-phase'); + assert.ok(result.includes(''), 'has skill adapter opening tag'); + assert.ok(result.includes(''), 'has skill adapter closing tag'); + }); + + test('skill name appears in the adapter header', () => { + const result = convertClaudeCommandToCodexSkill(sampleCommand, 'gsd-execute-phase'); + assert.ok(result.includes('$gsd-execute-phase'), 'skill name in invocation syntax'); + }); + + test('includes rebuilt frontmatter with name and description', () => { + const result = convertClaudeCommandToCodexSkill(sampleCommand, 'gsd-execute-phase'); + assert.ok(result.startsWith('---\n'), 'starts with frontmatter'); + assert.ok(result.includes('"gsd-execute-phase"'), 'skill name in frontmatter'); + assert.ok(result.includes('Execute a GSD phase'), 'description in frontmatter'); + }); + + test('converts /gsd: to $gsd- in the body', () => { + const result = convertClaudeCommandToCodexSkill(sampleCommand, 'gsd-execute-phase'); + assert.ok(result.includes('$gsd-execute-phase'), 'slash command converted in body'); + }); + + test('converts $ARGUMENTS to {{GSD_ARGS}} in the body', () => { + const result = convertClaudeCommandToCodexSkill(sampleCommand, 'gsd-execute-phase'); + assert.ok(result.includes('{{GSD_ARGS}}'), 'ARGUMENTS converted in body'); + }); + + test('uses command description as skill description', () => { + const result = convertClaudeCommandToCodexSkill(sampleCommand, 'gsd-execute-phase'); + assert.ok(result.includes('Execute a GSD phase by running all plans in sequence'), 'description preserved'); + }); + + test('uses default description when no frontmatter description', () => { + const input = `--- +name: gsd-test +--- + +Body content.`; + const result = convertClaudeCommandToCodexSkill(input, 'gsd-test'); + assert.ok(result.includes('gsd-test'), 'skill name in output'); + }); +}); + +// ─── stripSubTags ───────────────────────────────────────────────────────────── + +describe('stripSubTags', () => { + test('converts sub tags to italic notation', () => { + const input = 'See subscript text.'; + const result = stripSubTags(input); + assert.ok(result.includes('*(subscript)*'), 'sub tag converted to italic'); + assert.ok(!result.includes(''), 'opening tag removed'); + assert.ok(!result.includes(''), 'closing tag removed'); + }); + + test('preserves surrounding content', () => { + const input = 'Before middle after.'; + const result = stripSubTags(input); + assert.ok(result.includes('Before'), 'content before preserved'); + assert.ok(result.includes('after.'), 'content after preserved'); + }); + + test('handles multiple sub tags', () => { + const input = 'first and second'; + const result = stripSubTags(input); + assert.ok(result.includes('*(first)*'), 'first sub tag converted'); + assert.ok(result.includes('*(second)*'), 'second sub tag converted'); + }); + + test('returns content unchanged when no sub tags', () => { + const input = 'No sub tags here.'; + const result = stripSubTags(input); + assert.strictEqual(result, input, 'unchanged content returned as-is'); + }); +}); + +// ─── extractFrontmatterAndBody ───────────────────────────────────────────────── + +describe('extractFrontmatterAndBody', () => { + test('splits content at --- delimiters into frontmatter and body', () => { + const input = `--- +name: test +description: Test +--- + +Body content here.`; + const { frontmatter, body } = extractFrontmatterAndBody(input); + assert.ok(frontmatter !== null, 'frontmatter extracted'); + assert.ok(frontmatter.includes('name: test'), 'frontmatter has name field'); + assert.ok(body.includes('Body content here.'), 'body extracted'); + }); + + test('returns null frontmatter if content does not start with ---', () => { + const input = 'No frontmatter here.'; + const { frontmatter, body } = extractFrontmatterAndBody(input); + assert.strictEqual(frontmatter, null, 'frontmatter is null'); + assert.strictEqual(body, input, 'body is the full content'); + }); + + test('handles missing closing ---', () => { + const input = '---\nname: test\n'; + const { frontmatter } = extractFrontmatterAndBody(input); + assert.strictEqual(frontmatter, null, 'null frontmatter when no closing ---'); + }); + + test('handles empty frontmatter section', () => { + const input = `--- +--- + +Body only.`; + const { frontmatter, body } = extractFrontmatterAndBody(input); + // With empty frontmatter, either null or empty string is acceptable + assert.ok(body.includes('Body only.'), 'body extracted'); + }); +}); + +// ─── toSingleLine ───────────────────────────────────────────────────────────── + +describe('toSingleLine', () => { + test('collapses newlines into single spaces', () => { + const input = 'First line\nSecond line'; + const result = toSingleLine(input); + assert.strictEqual(result, 'First line Second line'); + }); + + test('collapses tabs into single spaces', () => { + const input = 'Tab\there'; + const result = toSingleLine(input); + assert.strictEqual(result, 'Tab here'); + }); + + test('collapses multiple spaces into one', () => { + const input = 'Too many spaces'; + const result = toSingleLine(input); + assert.strictEqual(result, 'Too many spaces'); + }); + + test('trims leading and trailing whitespace', () => { + const input = ' leading and trailing '; + const result = toSingleLine(input); + assert.strictEqual(result, 'leading and trailing'); + }); + + test('handles already single-line content', () => { + const input = 'Already single line.'; + const result = toSingleLine(input); + assert.strictEqual(result, 'Already single line.'); + }); +}); + +// ─── yamlQuote ──────────────────────────────────────────────────────────────── + +describe('yamlQuote', () => { + test('wraps values in double quotes', () => { + const result = yamlQuote('hello world'); + assert.strictEqual(result, '"hello world"'); + }); + + test('escapes internal double quotes', () => { + const result = yamlQuote('say "hello"'); + assert.ok(result.startsWith('"'), 'starts with quote'); + assert.ok(result.endsWith('"'), 'ends with quote'); + assert.ok(result.includes('\\"hello\\"'), 'internal quotes escaped'); + }); + + test('handles empty string', () => { + const result = yamlQuote(''); + assert.strictEqual(result, '""'); + }); + + test('handles string with special characters', () => { + const result = yamlQuote('path/to/file.md'); + assert.strictEqual(result, '"path/to/file.md"'); + }); +}); + +// ─── Constants sanity checks ────────────────────────────────────────────────── + +describe('colorNameToHex', () => { + test('maps standard color names to hex codes', () => { + assert.strictEqual(colorNameToHex.cyan, '#00FFFF'); + assert.strictEqual(colorNameToHex.red, '#FF0000'); + assert.strictEqual(colorNameToHex.green, '#00FF00'); + assert.strictEqual(colorNameToHex.yellow, '#FFFF00'); + assert.strictEqual(colorNameToHex.blue, '#0000FF'); + }); + + test('is an object (not null)', () => { + assert.ok(colorNameToHex !== null && typeof colorNameToHex === 'object'); + }); +}); + +describe('claudeToOpencodeTools', () => { + test('maps AskUserQuestion to question', () => { + assert.strictEqual(claudeToOpencodeTools.AskUserQuestion, 'question'); + }); + + test('maps SlashCommand to skill', () => { + assert.strictEqual(claudeToOpencodeTools.SlashCommand, 'skill'); + }); +}); + +describe('claudeToGeminiTools', () => { + test('maps Read to read_file', () => { + assert.strictEqual(claudeToGeminiTools.Read, 'read_file'); + }); + + test('maps Bash to run_shell_command', () => { + assert.strictEqual(claudeToGeminiTools.Bash, 'run_shell_command'); + }); +}); diff --git a/tests/install-flow.test.cjs b/tests/install-flow.test.cjs new file mode 100644 index 000000000..5639dce02 --- /dev/null +++ b/tests/install-flow.test.cjs @@ -0,0 +1,415 @@ +/** + * GSD Tools Tests - install-flow.test.cjs + * + * Tests for install/uninstall flow functions: copyWithPathReplacement, + * copyFlattenedCommands, cleanupOrphanedFiles, and getDirName. + * Uses temp directories for all file-system operations. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { + getDirName, + cleanupOrphanedFiles, +} = require('../bin/lib/core.js'); + +// copyWithPathReplacement and copyFlattenedCommands are orchestration-level functions +// that dispatch to runtime converters — they remain in install.js +process.env.GSD_TEST_MODE = '1'; +const { + copyWithPathReplacement, + copyFlattenedCommands, +} = require('../bin/install.js'); + +// ─── getDirName ────────────────────────────────────────────────────────────── + +describe('getDirName', () => { + test("'claude' returns '.claude'", () => { + assert.strictEqual(getDirName('claude'), '.claude'); + }); + + test("'opencode' returns '.opencode'", () => { + assert.strictEqual(getDirName('opencode'), '.opencode'); + }); + + test("'gemini' returns '.gemini'", () => { + assert.strictEqual(getDirName('gemini'), '.gemini'); + }); + + test("'codex' returns '.codex'", () => { + assert.strictEqual(getDirName('codex'), '.codex'); + }); +}); + +// ─── copyWithPathReplacement ───────────────────────────────────────────────── + +describe('copyWithPathReplacement (claude runtime)', () => { + let tmpDir, srcDir, destDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-flow-')); + srcDir = path.join(tmpDir, 'src'); + destDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(srcDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('copies .md file and replaces ~/.claude/ with pathPrefix', () => { + fs.writeFileSync(path.join(srcDir, 'test.md'), 'See ~/.claude/foo'); + copyWithPathReplacement(srcDir, destDir, '/test/path/', 'claude'); + + const content = fs.readFileSync(path.join(destDir, 'test.md'), 'utf8'); + assert.ok(content.includes('/test/path/foo'), 'replaces ~/.claude/ with pathPrefix'); + assert.ok(!content.includes('~/.claude/'), 'removes original ~/.claude/ reference'); + }); + + test('copies .md file and replaces ./.claude/ with ./dirName/', () => { + fs.writeFileSync(path.join(srcDir, 'test.md'), 'Path: ./.claude/commands'); + copyWithPathReplacement(srcDir, destDir, '/test/path/', 'claude'); + + const content = fs.readFileSync(path.join(destDir, 'test.md'), 'utf8'); + assert.ok(content.includes('./.claude/commands'), 'claude keeps ./.claude/ (same dir name)'); + }); + + test('copies non-.md files as-is (binary copy)', () => { + const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff]); + fs.writeFileSync(path.join(srcDir, 'icon.png'), binaryData); + copyWithPathReplacement(srcDir, destDir, '/test/path/', 'claude'); + + const result = fs.readFileSync(path.join(destDir, 'icon.png')); + assert.deepStrictEqual(result, binaryData, 'binary file copied unchanged'); + }); + + test('copies .txt file as-is', () => { + const txtContent = 'Just a text file with ~/.claude/ reference'; + fs.writeFileSync(path.join(srcDir, 'readme.txt'), txtContent); + copyWithPathReplacement(srcDir, destDir, '/test/path/', 'claude'); + + const result = fs.readFileSync(path.join(destDir, 'readme.txt'), 'utf8'); + assert.strictEqual(result, txtContent, 'non-.md text file copied without path replacement'); + }); + + test('recurses into subdirectories', () => { + const subDir = path.join(srcDir, 'subdir'); + fs.mkdirSync(subDir); + fs.writeFileSync(path.join(subDir, 'nested.md'), 'See ~/.claude/bar'); + copyWithPathReplacement(srcDir, destDir, '/test/path/', 'claude'); + + const nestedPath = path.join(destDir, 'subdir', 'nested.md'); + assert.ok(fs.existsSync(nestedPath), 'nested file exists in dest'); + const content = fs.readFileSync(nestedPath, 'utf8'); + assert.ok(content.includes('/test/path/bar'), 'path replacement applied in nested file'); + }); + + test('cleans dest directory before copying (removes old files)', () => { + // Create a stale file in destDir before the copy + fs.mkdirSync(destDir, { recursive: true }); + fs.writeFileSync(path.join(destDir, 'stale.md'), 'old content'); + fs.writeFileSync(path.join(srcDir, 'new.md'), 'new content'); + + copyWithPathReplacement(srcDir, destDir, '/test/path/', 'claude'); + + assert.ok(!fs.existsSync(path.join(destDir, 'stale.md')), 'stale file removed'); + assert.ok(fs.existsSync(path.join(destDir, 'new.md')), 'new file exists'); + }); +}); + +describe('copyWithPathReplacement (opencode runtime)', () => { + let tmpDir, srcDir, destDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-flow-oc-')); + srcDir = path.join(tmpDir, 'src'); + destDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(srcDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('applies convertClaudeToOpencodeFrontmatter and path replacement', () => { + const content = [ + '---', + 'description: Test command', + 'tools: AskUserQuestion', + '---', + 'Body with ~/.claude/ path', + ].join('\n'); + fs.writeFileSync(path.join(srcDir, 'cmd.md'), content); + + copyWithPathReplacement(srcDir, destDir, '/oc/path/', 'opencode'); + + const result = fs.readFileSync(path.join(destDir, 'cmd.md'), 'utf8'); + // OpenCode frontmatter conversion changes AskUserQuestion -> question + assert.ok(result.includes('question: true'), 'tool name converted to opencode format'); + // Path replacement still occurs + assert.ok(result.includes('/oc/path/'), 'path replacement applied'); + assert.ok(!result.includes('~/.claude/'), 'original path removed'); + }); + + test('replaces ./.claude/ with ./.opencode/ for opencode', () => { + fs.writeFileSync(path.join(srcDir, 'test.md'), 'local ref: ./.claude/foo'); + copyWithPathReplacement(srcDir, destDir, '/oc/path/', 'opencode'); + + const result = fs.readFileSync(path.join(destDir, 'test.md'), 'utf8'); + assert.ok(result.includes('./.opencode/foo'), 'local path uses .opencode dir'); + }); +}); + +describe('copyWithPathReplacement (gemini runtime, isCommand=true)', () => { + let tmpDir, srcDir, destDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-flow-gem-')); + srcDir = path.join(tmpDir, 'src'); + destDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(srcDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('converts .md to .toml file (isCommand=true)', () => { + const content = [ + '---', + 'description: Test Gemini command', + '---', + 'Body text with ~/.claude/ path', + ].join('\n'); + fs.writeFileSync(path.join(srcDir, 'cmd.md'), content); + + copyWithPathReplacement(srcDir, destDir, '/gem/path/', 'gemini', true); + + // Source .md -> dest .toml + assert.ok(!fs.existsSync(path.join(destDir, 'cmd.md')), '.md file not in dest'); + assert.ok(fs.existsSync(path.join(destDir, 'cmd.toml')), '.toml file created'); + + const tomlContent = fs.readFileSync(path.join(destDir, 'cmd.toml'), 'utf8'); + assert.ok(tomlContent.includes('description ='), 'toml has description field'); + assert.ok(tomlContent.includes('prompt ='), 'toml has prompt field'); + }); +}); + +describe('copyWithPathReplacement (gemini runtime, isCommand=false)', () => { + let tmpDir, srcDir, destDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-flow-gem2-')); + srcDir = path.join(tmpDir, 'src'); + destDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(srcDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('copies .md files with path replacement (no TOML conversion)', () => { + fs.writeFileSync(path.join(srcDir, 'agent.md'), 'See ~/.claude/agents'); + copyWithPathReplacement(srcDir, destDir, '/gem/path/', 'gemini', false); + + assert.ok(fs.existsSync(path.join(destDir, 'agent.md')), '.md file preserved'); + assert.ok(!fs.existsSync(path.join(destDir, 'agent.toml')), 'no .toml created'); + + const content = fs.readFileSync(path.join(destDir, 'agent.md'), 'utf8'); + assert.ok(content.includes('/gem/path/agents'), 'path replacement applied'); + }); +}); + +describe('copyWithPathReplacement (codex runtime)', () => { + let tmpDir, srcDir, destDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-flow-cx-')); + srcDir = path.join(tmpDir, 'src'); + destDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(srcDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('applies convertClaudeToCodexMarkdown (/gsd: -> $gsd-)', () => { + fs.writeFileSync(path.join(srcDir, 'skill.md'), 'Run /gsd:execute-phase to proceed'); + copyWithPathReplacement(srcDir, destDir, '/cx/path/', 'codex'); + + const result = fs.readFileSync(path.join(destDir, 'skill.md'), 'utf8'); + assert.ok(result.includes('$gsd-execute-phase'), 'slash command converted to skill mention'); + assert.ok(!result.includes('/gsd:execute-phase'), 'original slash command removed'); + }); + + test('path replacement applied before codex conversion', () => { + fs.writeFileSync(path.join(srcDir, 'skill.md'), 'Path: ~/.claude/foo'); + copyWithPathReplacement(srcDir, destDir, '/cx/path/', 'codex'); + + const result = fs.readFileSync(path.join(destDir, 'skill.md'), 'utf8'); + assert.ok(result.includes('/cx/path/foo'), 'path replacement applied'); + }); + + test('replaces ./.claude/ with ./.codex/ for codex', () => { + fs.writeFileSync(path.join(srcDir, 'test.md'), 'local ref: ./.claude/foo'); + copyWithPathReplacement(srcDir, destDir, '/cx/path/', 'codex'); + + const result = fs.readFileSync(path.join(destDir, 'test.md'), 'utf8'); + assert.ok(result.includes('./.codex/foo'), 'local path uses .codex dir'); + }); +}); + +// ─── copyFlattenedCommands ──────────────────────────────────────────────────── + +describe('copyFlattenedCommands', () => { + let tmpDir, srcDir, destDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-flow-flat-')); + srcDir = path.join(tmpDir, 'src'); + destDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(srcDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('flattens help.md to gsd-help.md', () => { + const mdContent = '---\ndescription: Test\n---\nBody with ~/.claude/ path'; + fs.writeFileSync(path.join(srcDir, 'help.md'), mdContent); + + copyFlattenedCommands(srcDir, destDir, 'gsd', '/test/', 'claude'); + + assert.ok(fs.existsSync(path.join(destDir, 'gsd-help.md')), 'gsd-help.md created'); + }); + + test('flattens nested debug/start.md to gsd-debug-start.md', () => { + const debugDir = path.join(srcDir, 'debug'); + fs.mkdirSync(debugDir); + fs.writeFileSync(path.join(debugDir, 'start.md'), '---\ndescription: Start\n---\nContent'); + + copyFlattenedCommands(srcDir, destDir, 'gsd', '/test/', 'claude'); + + assert.ok(fs.existsSync(path.join(destDir, 'gsd-debug-start.md')), 'gsd-debug-start.md created'); + }); + + test('applies path replacement during flatten', () => { + fs.writeFileSync(path.join(srcDir, 'cmd.md'), '---\ndescription: Test\n---\nSee ~/.claude/foo'); + + copyFlattenedCommands(srcDir, destDir, 'gsd', '/test/', 'claude'); + + const content = fs.readFileSync(path.join(destDir, 'gsd-cmd.md'), 'utf8'); + assert.ok(content.includes('/test/foo'), 'path replaced in flattened file'); + assert.ok(!content.includes('~/.claude/'), 'original path removed'); + }); + + test('removes old gsd-*.md before copying (clean install)', () => { + // Create a stale gsd-old.md in destDir + fs.mkdirSync(destDir, { recursive: true }); + fs.writeFileSync(path.join(destDir, 'gsd-old.md'), 'stale command'); + // Create a non-gsd file that should survive + fs.writeFileSync(path.join(destDir, 'other-cmd.md'), 'user command'); + + fs.writeFileSync(path.join(srcDir, 'new.md'), '---\ndescription: New\n---\nContent'); + + copyFlattenedCommands(srcDir, destDir, 'gsd', '/test/', 'claude'); + + assert.ok(!fs.existsSync(path.join(destDir, 'gsd-old.md')), 'stale gsd-*.md removed'); + assert.ok(fs.existsSync(path.join(destDir, 'other-cmd.md')), 'non-gsd file preserved'); + assert.ok(fs.existsSync(path.join(destDir, 'gsd-new.md')), 'new gsd file created'); + }); + + test('creates dest directory if it does not exist', () => { + fs.writeFileSync(path.join(srcDir, 'help.md'), '---\ndescription: Help\n---\nContent'); + + // Ensure destDir does NOT exist + assert.ok(!fs.existsSync(destDir), 'destDir should not exist before call'); + + copyFlattenedCommands(srcDir, destDir, 'gsd', '/test/', 'claude'); + + assert.ok(fs.existsSync(destDir), 'destDir created by function'); + assert.ok(fs.existsSync(path.join(destDir, 'gsd-help.md')), 'file written to new destDir'); + }); + + test('does nothing if srcDir does not exist', () => { + const nonExistentSrc = path.join(tmpDir, 'no-such-dir'); + // Should not throw + assert.doesNotThrow(() => { + copyFlattenedCommands(nonExistentSrc, destDir, 'gsd', '/test/', 'claude'); + }); + }); +}); + +// ─── cleanupOrphanedFiles ──────────────────────────────────────────────────── + +describe('cleanupOrphanedFiles', () => { + let tmpDir, configDir, hooksDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-flow-orphan-')); + configDir = path.join(tmpDir, 'config'); + hooksDir = path.join(configDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('removes hooks/gsd-notify.sh if it exists', () => { + const filePath = path.join(hooksDir, 'gsd-notify.sh'); + fs.writeFileSync(filePath, '#!/bin/bash\necho notify'); + + cleanupOrphanedFiles(configDir); + + assert.ok(!fs.existsSync(filePath), 'gsd-notify.sh removed'); + }); + + test('removes hooks/statusline.js if it exists', () => { + const filePath = path.join(hooksDir, 'statusline.js'); + fs.writeFileSync(filePath, '// old statusline'); + + cleanupOrphanedFiles(configDir); + + assert.ok(!fs.existsSync(filePath), 'statusline.js removed'); + }); + + test('does not error on missing files', () => { + // hooksDir exists but orphaned files do not + assert.doesNotThrow(() => { + cleanupOrphanedFiles(configDir); + }); + }); + + test('does not remove other hook files (gsd-statusline.js preserved)', () => { + const keepPath = path.join(hooksDir, 'gsd-statusline.js'); + fs.writeFileSync(keepPath, '// current statusline'); + + cleanupOrphanedFiles(configDir); + + assert.ok(fs.existsSync(keepPath), 'gsd-statusline.js preserved'); + }); + + test('removes both orphaned files when both exist', () => { + const notifyPath = path.join(hooksDir, 'gsd-notify.sh'); + const statuslinePath = path.join(hooksDir, 'statusline.js'); + const keepPath = path.join(hooksDir, 'gsd-statusline.js'); + + fs.writeFileSync(notifyPath, '#!/bin/bash'); + fs.writeFileSync(statuslinePath, '// old'); + fs.writeFileSync(keepPath, '// current'); + + cleanupOrphanedFiles(configDir); + + assert.ok(!fs.existsSync(notifyPath), 'gsd-notify.sh removed'); + assert.ok(!fs.existsSync(statuslinePath), 'statusline.js removed'); + assert.ok(fs.existsSync(keepPath), 'gsd-statusline.js kept'); + }); +}); diff --git a/tests/install-utils.test.cjs b/tests/install-utils.test.cjs new file mode 100644 index 000000000..ccd3e80c8 --- /dev/null +++ b/tests/install-utils.test.cjs @@ -0,0 +1,599 @@ +/** + * GSD Tools Tests - install-utils.test.cjs + * + * Tests for shared utility functions in bin/install.js that will become bin/lib/core.js: + * path helpers, attribution, frontmatter extraction, settings I/O, JSONC parsing, + * manifest generation, and orphaned hook cleanup. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { + expandTilde, + toHomePrefix, + buildHookCommand, + readSettings, + writeSettings, + processAttribution, + parseJsonc, + extractFrontmatterAndBody, + extractFrontmatterField, + cleanupOrphanedHooks, + fileHash, + generateManifest, +} = require('../bin/lib/core.js'); + +// ─── expandTilde ───────────────────────────────────────────────────────────── + +describe('expandTilde', () => { + test('~/foo/bar returns homedir + /foo/bar', () => { + const result = expandTilde('~/foo/bar'); + assert.strictEqual(result, path.join(os.homedir(), 'foo/bar')); + }); + + test('/absolute/path returns unchanged', () => { + const result = expandTilde('/absolute/path'); + assert.strictEqual(result, '/absolute/path'); + }); + + test('null returns null', () => { + const result = expandTilde(null); + assert.strictEqual(result, null); + }); + + test('undefined returns undefined', () => { + const result = expandTilde(undefined); + assert.strictEqual(result, undefined); + }); + + test('~ alone returns unchanged (no slash after ~)', () => { + const result = expandTilde('~'); + assert.strictEqual(result, '~'); + }); + + test('relative path without tilde returns unchanged', () => { + const result = expandTilde('relative/path'); + assert.strictEqual(result, 'relative/path'); + }); +}); + +// ─── toHomePrefix ───────────────────────────────────────────────────────────── + +describe('toHomePrefix', () => { + test('path starting with homedir becomes $HOME-relative', () => { + const home = os.homedir(); + const input = home + '/.claude/'; + const result = toHomePrefix(input); + assert.strictEqual(result, '$HOME/.claude/'); + }); + + test('path not starting with homedir returns unchanged', () => { + const result = toHomePrefix('/some/other/path'); + assert.strictEqual(result, '/some/other/path'); + }); + + test('backslashes in path are normalized to forward slashes (non-home path)', () => { + const result = toHomePrefix('/some\\path\\with\\backslashes'); + assert.strictEqual(result, '/some/path/with/backslashes'); + }); + + test('backslashes in home-relative path are normalized', () => { + const home = os.homedir().replace(/\//g, '\\'); + const input = home + '\\.claude\\'; + const result = toHomePrefix(input); + // On non-Windows the homedir won't have backslashes, so this is mostly a sanity check + assert.ok(typeof result === 'string'); + }); + + test('exact homedir with trailing slash becomes $HOME/', () => { + const home = os.homedir(); + const result = toHomePrefix(home + '/'); + assert.strictEqual(result, '$HOME/'); + }); +}); + +// ─── buildHookCommand ───────────────────────────────────────────────────────── + +describe('buildHookCommand', () => { + test('returns node "configDir/hooks/hookName"', () => { + const result = buildHookCommand('/home/user/.claude', 'stop.js'); + assert.strictEqual(result, 'node "/home/user/.claude/hooks/stop.js"'); + }); + + test('backslashes in configDir converted to forward slashes', () => { + const result = buildHookCommand('C:\\Users\\user\\.claude', 'stop.js'); + assert.strictEqual(result, 'node "C:/Users/user/.claude/hooks/stop.js"'); + }); + + test('works with different hook names', () => { + const result = buildHookCommand('/home/user/.claude', 'gsd-statusline.js'); + assert.strictEqual(result, 'node "/home/user/.claude/hooks/gsd-statusline.js"'); + }); +}); + +// ─── readSettings ───────────────────────────────────────────────────────────── + +describe('readSettings', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-read-settings-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('returns parsed object from valid JSON file', () => { + const filePath = path.join(tmpDir, 'settings.json'); + fs.writeFileSync(filePath, JSON.stringify({ foo: 'bar', count: 42 })); + const result = readSettings(filePath); + assert.deepStrictEqual(result, { foo: 'bar', count: 42 }); + }); + + test('returns {} when file does not exist', () => { + const filePath = path.join(tmpDir, 'nonexistent.json'); + const result = readSettings(filePath); + assert.deepStrictEqual(result, {}); + }); + + test('returns {} when file has invalid JSON', () => { + const filePath = path.join(tmpDir, 'corrupt.json'); + fs.writeFileSync(filePath, 'this is not valid JSON { broken'); + const result = readSettings(filePath); + assert.deepStrictEqual(result, {}); + }); + + test('returns nested objects correctly', () => { + const filePath = path.join(tmpDir, 'settings.json'); + const data = { hooks: { Stop: [{ hooks: [{ command: 'node /path/to/hook.js' }] }] } }; + fs.writeFileSync(filePath, JSON.stringify(data)); + const result = readSettings(filePath); + assert.deepStrictEqual(result, data); + }); +}); + +// ─── writeSettings ───────────────────────────────────────────────────────────── + +describe('writeSettings', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-write-settings-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('writes JSON with 2-space indentation', () => { + const filePath = path.join(tmpDir, 'settings.json'); + writeSettings(filePath, { key: 'value' }); + const raw = fs.readFileSync(filePath, 'utf8'); + assert.ok(raw.includes(' "key"'), 'has 2-space indentation'); + assert.strictEqual(JSON.parse(raw).key, 'value'); + }); + + test('appends trailing newline', () => { + const filePath = path.join(tmpDir, 'settings.json'); + writeSettings(filePath, { key: 'value' }); + const raw = fs.readFileSync(filePath, 'utf8'); + assert.ok(raw.endsWith('\n'), 'ends with newline'); + }); + + test('round-trip: readable with readSettings afterward', () => { + const filePath = path.join(tmpDir, 'settings.json'); + const original = { name: 'test', hooks: { Stop: [] }, nested: { a: 1, b: true } }; + writeSettings(filePath, original); + const result = readSettings(filePath); + assert.deepStrictEqual(result, original); + }); + + test('overwrites existing file', () => { + const filePath = path.join(tmpDir, 'settings.json'); + writeSettings(filePath, { version: 1 }); + writeSettings(filePath, { version: 2 }); + const result = readSettings(filePath); + assert.strictEqual(result.version, 2); + }); +}); + +// ─── processAttribution ──────────────────────────────────────────────────────── + +describe('processAttribution', () => { + const sampleContent = `Some commit message text + +Co-Authored-By: Claude Opus 4.5 `; + + test('attribution=null removes Co-Authored-By line and preceding blank line', () => { + const result = processAttribution(sampleContent, null); + assert.ok(!result.includes('Co-Authored-By'), 'Co-Authored-By removed'); + assert.ok(!result.endsWith('\n\n'), 'no trailing double newline'); + assert.ok(result.includes('Some commit message text'), 'body preserved'); + }); + + test('attribution=undefined returns content unchanged', () => { + const result = processAttribution(sampleContent, undefined); + assert.strictEqual(result, sampleContent); + }); + + test('attribution string replaces the Co-Authored-By value', () => { + const result = processAttribution(sampleContent, 'My Custom Author '); + assert.ok(result.includes('Co-Authored-By: My Custom Author '), 'replaced attribution'); + assert.ok(!result.includes('Claude Opus'), 'old attribution removed'); + }); + + test('handles multiple Co-Authored-By lines (null removes all)', () => { + const multi = `Message\n\nCo-Authored-By: Author One\n\nCo-Authored-By: Author Two`; + const result = processAttribution(multi, null); + assert.ok(!result.includes('Co-Authored-By'), 'all Co-Authored-By removed'); + }); + + test('handles content with no Co-Authored-By lines (null is no-op)', () => { + const noAttrib = 'Just a message with no attribution.'; + const result = processAttribution(noAttrib, null); + assert.strictEqual(result, noAttrib); + }); + + test('custom attribution with $ in replacement does not break regex', () => { + const result = processAttribution(sampleContent, 'Safe$Author'); + assert.ok(result.includes('Co-Authored-By: Safe$Author'), 'dollar sign preserved'); + }); + + test('handles multiple Co-Authored-By lines (string replaces all)', () => { + const multi = `Message\n\nCo-Authored-By: Author One\n\nCo-Authored-By: Author Two`; + const result = processAttribution(multi, 'New Author'); + const matches = result.match(/Co-Authored-By: New Author/g); + assert.strictEqual(matches.length, 2, 'both lines replaced'); + }); +}); + +// ─── parseJsonc ──────────────────────────────────────────────────────────────── + +describe('parseJsonc', () => { + test('parses standard JSON', () => { + const result = parseJsonc('{"key": "value", "num": 42}'); + assert.deepStrictEqual(result, { key: 'value', num: 42 }); + }); + + test('strips single-line comments', () => { + const jsonc = `{ + // This is a comment + "key": "value" +}`; + const result = parseJsonc(jsonc); + assert.deepStrictEqual(result, { key: 'value' }); + }); + + test('strips block comments', () => { + const jsonc = `{ + /* block comment */ + "key": "value" +}`; + const result = parseJsonc(jsonc); + assert.deepStrictEqual(result, { key: 'value' }); + }); + + test('removes trailing commas before }', () => { + const jsonc = '{"key": "value",}'; + const result = parseJsonc(jsonc); + assert.deepStrictEqual(result, { key: 'value' }); + }); + + test('removes trailing commas before ]', () => { + const jsonc = '{"arr": [1, 2, 3,]}'; + const result = parseJsonc(jsonc); + assert.deepStrictEqual(result, { arr: [1, 2, 3] }); + }); + + test('strips BOM character', () => { + const jsonc = '\uFEFF{"key": "value"}'; + const result = parseJsonc(jsonc); + assert.deepStrictEqual(result, { key: 'value' }); + }); + + test('preserves strings containing // (does not strip // inside strings)', () => { + const jsonc = '{"url": "https://example.com/path"}'; + const result = parseJsonc(jsonc); + assert.deepStrictEqual(result, { url: 'https://example.com/path' }); + }); + + test('handles multi-line block comments', () => { + const jsonc = `{ + /* + * Multi-line + * comment + */ + "key": "value" +}`; + const result = parseJsonc(jsonc); + assert.deepStrictEqual(result, { key: 'value' }); + }); + + test('handles complex JSONC with comments and trailing commas', () => { + const jsonc = `{ + // Settings config + "enable": true, // inline comment + "items": [ + "a", + "b", // last item + ], +}`; + const result = parseJsonc(jsonc); + assert.deepStrictEqual(result, { enable: true, items: ['a', 'b'] }); + }); +}); + +// ─── extractFrontmatterAndBody ───────────────────────────────────────────────── + +describe('extractFrontmatterAndBody', () => { + test('splits "---\\nfoo: bar\\n---\\nbody" into frontmatter and body', () => { + const content = '---\nfoo: bar\n---\nThis is the body.'; + const { frontmatter, body } = extractFrontmatterAndBody(content); + assert.ok(frontmatter.includes('foo: bar'), 'frontmatter has field'); + assert.ok(body.includes('This is the body.'), 'body preserved'); + }); + + test('returns null frontmatter when content does not start with ---', () => { + const content = 'No frontmatter here.'; + const { frontmatter, body } = extractFrontmatterAndBody(content); + assert.strictEqual(frontmatter, null); + assert.strictEqual(body, content); + }); + + test('returns null frontmatter when no closing ---', () => { + const content = '---\nfoo: bar\nno closing delimiter'; + const { frontmatter, body } = extractFrontmatterAndBody(content); + assert.strictEqual(frontmatter, null); + assert.strictEqual(body, content); + }); + + test('extracts multi-field frontmatter correctly', () => { + const content = '---\nname: test\ndescription: A test agent\ntools: Read, Write\n---\nbody text'; + const { frontmatter } = extractFrontmatterAndBody(content); + assert.ok(frontmatter.includes('name: test')); + assert.ok(frontmatter.includes('description: A test agent')); + assert.ok(frontmatter.includes('tools: Read, Write')); + }); + + test('body content after closing --- is preserved', () => { + const content = '---\nfield: val\n---\n\n## Section\n\nBody paragraph.'; + const { body } = extractFrontmatterAndBody(content); + assert.ok(body.includes('## Section')); + assert.ok(body.includes('Body paragraph.')); + }); +}); + +// ─── extractFrontmatterField ─────────────────────────────────────────────────── + +describe('extractFrontmatterField', () => { + const frontmatter = 'name: test-agent\ndescription: Does things\ntools: Read, Write'; + + test('extracts simple key: value', () => { + const result = extractFrontmatterField(frontmatter, 'name'); + assert.strictEqual(result, 'test-agent'); + }); + + test('extracts description field', () => { + const result = extractFrontmatterField(frontmatter, 'description'); + assert.strictEqual(result, 'Does things'); + }); + + test('extracts tools field', () => { + const result = extractFrontmatterField(frontmatter, 'tools'); + assert.strictEqual(result, 'Read, Write'); + }); + + test('returns null for nonexistent field', () => { + const result = extractFrontmatterField(frontmatter, 'nonexistent'); + assert.strictEqual(result, null); + }); + + test('strips surrounding quotes from values', () => { + const fm = 'name: "quoted-name"\ndescription: \'single-quoted\''; + assert.strictEqual(extractFrontmatterField(fm, 'name'), 'quoted-name'); + assert.strictEqual(extractFrontmatterField(fm, 'description'), 'single-quoted'); + }); +}); + +// ─── cleanupOrphanedHooks ────────────────────────────────────────────────────── + +describe('cleanupOrphanedHooks', () => { + test('removes entries containing gsd-notify.sh', () => { + const settings = { + hooks: { + Stop: [ + { hooks: [{ command: 'node "/home/user/.claude/hooks/gsd-notify.sh"' }] }, + { hooks: [{ command: 'node "/home/user/.claude/hooks/keep-this.js"' }] }, + ], + }, + }; + const result = cleanupOrphanedHooks(settings); + assert.strictEqual(result.hooks.Stop.length, 1); + assert.ok(result.hooks.Stop[0].hooks[0].command.includes('keep-this.js')); + }); + + test('removes entries containing hooks/statusline.js (old path)', () => { + const settings = { + hooks: { + Stop: [ + { hooks: [{ command: 'node "/home/user/.claude/hooks/statusline.js"' }] }, + ], + }, + }; + const result = cleanupOrphanedHooks(settings); + assert.strictEqual(result.hooks.Stop.length, 0); + }); + + test('removes entries containing gsd-intel-index.js', () => { + const settings = { + hooks: { + SessionStart: [ + { hooks: [{ command: 'node "/home/user/.claude/hooks/gsd-intel-index.js"' }] }, + ], + }, + }; + const result = cleanupOrphanedHooks(settings); + assert.strictEqual(result.hooks.SessionStart.length, 0); + }); + + test('preserves non-orphaned hook entries', () => { + const settings = { + hooks: { + Stop: [ + { hooks: [{ command: 'node "/home/user/.claude/hooks/gsd-statusline.js"' }] }, + { hooks: [{ command: 'node "/home/user/.claude/hooks/my-custom-hook.js"' }] }, + ], + }, + }; + const result = cleanupOrphanedHooks(settings); + assert.strictEqual(result.hooks.Stop.length, 2); + }); + + test('updates statusLine command path from hooks/statusline.js to hooks/gsd-statusline.js', () => { + const settings = { + hooks: {}, + statusLine: { command: 'node "/home/user/.claude/hooks/statusline.js"' }, + }; + const result = cleanupOrphanedHooks(settings); + assert.ok(result.statusLine.command.includes('hooks/gsd-statusline.js'), 'path updated'); + assert.ok(!result.statusLine.command.includes('hooks/statusline.js'.replace('gsd-', '')), 'old path gone'); + }); + + test('returns settings with empty hooks cleaned up (no errors on empty)', () => { + const settings = { hooks: { Stop: [], SessionStart: [] } }; + const result = cleanupOrphanedHooks(settings); + assert.ok(result.hooks); + assert.strictEqual(result.hooks.Stop.length, 0); + }); + + test('returns settings unchanged when no hooks key', () => { + const settings = { someOtherKey: true }; + const result = cleanupOrphanedHooks(settings); + assert.deepStrictEqual(result, { someOtherKey: true }); + }); + + test('removes entries across multiple event types', () => { + const settings = { + hooks: { + Stop: [ + { hooks: [{ command: 'node "/path/hooks/gsd-notify.sh"' }] }, + ], + SessionStart: [ + { hooks: [{ command: 'node "/path/hooks/gsd-intel-index.js"' }] }, + ], + PreToolUse: [ + { hooks: [{ command: 'node "/path/hooks/keep-me.js"' }] }, + ], + }, + }; + const result = cleanupOrphanedHooks(settings); + assert.strictEqual(result.hooks.Stop.length, 0); + assert.strictEqual(result.hooks.SessionStart.length, 0); + assert.strictEqual(result.hooks.PreToolUse.length, 1); + }); +}); + +// ─── fileHash ────────────────────────────────────────────────────────────────── + +describe('fileHash', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-file-hash-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('returns consistent SHA-256 hex string for same content', () => { + const filePath = path.join(tmpDir, 'test.txt'); + fs.writeFileSync(filePath, 'hello world'); + const hash1 = fileHash(filePath); + const hash2 = fileHash(filePath); + assert.strictEqual(hash1, hash2); + assert.match(hash1, /^[0-9a-f]{64}$/, 'is 64-char hex string'); + }); + + test('different content produces different hash', () => { + const file1 = path.join(tmpDir, 'a.txt'); + const file2 = path.join(tmpDir, 'b.txt'); + fs.writeFileSync(file1, 'content one'); + fs.writeFileSync(file2, 'content two'); + const hash1 = fileHash(file1); + const hash2 = fileHash(file2); + assert.notStrictEqual(hash1, hash2); + }); + + test('returns 64-char hex string', () => { + const filePath = path.join(tmpDir, 'test.txt'); + fs.writeFileSync(filePath, 'some content'); + const hash = fileHash(filePath); + assert.strictEqual(hash.length, 64); + assert.match(hash, /^[0-9a-f]+$/); + }); +}); + +// ─── generateManifest ───────────────────────────────────────────────────────── + +describe('generateManifest', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('returns { "relative/path": hash } for all files in directory', () => { + fs.writeFileSync(path.join(tmpDir, 'file1.txt'), 'content1'); + fs.writeFileSync(path.join(tmpDir, 'file2.txt'), 'content2'); + const manifest = generateManifest(tmpDir); + assert.ok('file1.txt' in manifest, 'file1.txt in manifest'); + assert.ok('file2.txt' in manifest, 'file2.txt in manifest'); + assert.match(manifest['file1.txt'], /^[0-9a-f]{64}$/); + assert.match(manifest['file2.txt'], /^[0-9a-f]{64}$/); + }); + + test('recurses into subdirectories', () => { + const subDir = path.join(tmpDir, 'subdir'); + fs.mkdirSync(subDir); + fs.writeFileSync(path.join(tmpDir, 'root.txt'), 'root'); + fs.writeFileSync(path.join(subDir, 'nested.txt'), 'nested'); + const manifest = generateManifest(tmpDir); + assert.ok('root.txt' in manifest, 'root file present'); + assert.ok('subdir/nested.txt' in manifest, 'nested file present with forward slash'); + }); + + test('returns empty object for nonexistent directory', () => { + const manifest = generateManifest(path.join(tmpDir, 'nonexistent')); + assert.deepStrictEqual(manifest, {}); + }); + + test('uses forward slashes in relative paths', () => { + const subDir = path.join(tmpDir, 'a', 'b'); + fs.mkdirSync(subDir, { recursive: true }); + fs.writeFileSync(path.join(subDir, 'deep.txt'), 'deep'); + const manifest = generateManifest(tmpDir); + const keys = Object.keys(manifest); + for (const key of keys) { + assert.ok(!key.includes('\\'), `key "${key}" uses forward slashes`); + } + assert.ok('a/b/deep.txt' in manifest, 'deeply nested file present'); + }); + + test('file hashes match fileHash function output', () => { + const filePath = path.join(tmpDir, 'check.txt'); + fs.writeFileSync(filePath, 'check content'); + const manifest = generateManifest(tmpDir); + const expectedHash = fileHash(filePath); + assert.strictEqual(manifest['check.txt'], expectedHash); + }); +});