diff --git a/CHANGELOG.md b/CHANGELOG.md index 52fb36917..0c6985e80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- **Antigravity runtime support** — Full installation support for the Antigravity AI agent runtime (`--antigravity`), alongside Claude Code, OpenCode, Gemini, Codex, and Copilot. + +### Fixed +- **Antigravity skills** — `processAttribution` was missing from `copyCommandsAsAntigravitySkills`, causing SKILL.md files to be written without commit attribution metadata. +- **Copilot install tests** — updated agent count (12→15) and CLI assertions to account for the three UI agents added in v1.23.0. + ## [1.24.0] - 2026-03-15 ### Added diff --git a/README.md b/README.md index ea5b9e208..7c2504952 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **English** · [简体中文](README.zh-CN.md) -**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, and Codex.** +**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Codex, Copilot, and Antigravity.** **Solves context rot — the quality degradation that happens as Claude fills its context window.** @@ -82,7 +82,7 @@ npx get-shit-done-cc@latest ``` The installer prompts you to choose: -1. **Runtime** — Claude Code, OpenCode, Gemini, Codex, Copilot, or all +1. **Runtime** — Claude Code, OpenCode, Gemini, Codex, Copilot, Antigravity, or all 2. **Location** — Global (all projects) or local (current project only) Verify with: @@ -124,12 +124,16 @@ npx get-shit-done-cc --codex --local # Install to ./.codex/ npx get-shit-done-cc --copilot --global # Install to ~/.github/ npx get-shit-done-cc --copilot --local # Install to ./.github/ +# Antigravity (Google, skills-first, Gemini-based) +npx get-shit-done-cc --antigravity --global # Install to ~/.gemini/antigravity/ +npx get-shit-done-cc --antigravity --local # Install to ./.agent/ + # All runtimes npx get-shit-done-cc --all --global # Install to all directories ``` Use `--global` (`-g`) or `--local` (`-l`) to skip the location prompt. -Use `--claude`, `--opencode`, `--gemini`, `--codex`, `--copilot`, or `--all` to skip the runtime prompt. +Use `--claude`, `--opencode`, `--gemini`, `--codex`, `--copilot`, `--antigravity`, or `--all` to skip the runtime prompt. diff --git a/bin/install.js b/bin/install.js index 45dffc24a..cff9bb5f8 100755 --- a/bin/install.js +++ b/bin/install.js @@ -63,6 +63,7 @@ const hasClaude = args.includes('--claude'); const hasGemini = args.includes('--gemini'); const hasCodex = args.includes('--codex'); const hasCopilot = args.includes('--copilot'); +const hasAntigravity = args.includes('--antigravity'); const hasBoth = args.includes('--both'); // Legacy flag, keeps working const hasAll = args.includes('--all'); const hasUninstall = args.includes('--uninstall') || args.includes('-u'); @@ -70,7 +71,7 @@ const hasUninstall = args.includes('--uninstall') || args.includes('-u'); // Runtime selection - can be set by flags or interactive prompt let selectedRuntimes = []; if (hasAll) { - selectedRuntimes = ['claude', 'opencode', 'gemini', 'codex', 'copilot']; + selectedRuntimes = ['claude', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity']; } else if (hasBoth) { selectedRuntimes = ['claude', 'opencode']; } else { @@ -79,6 +80,7 @@ if (hasAll) { if (hasGemini) selectedRuntimes.push('gemini'); if (hasCodex) selectedRuntimes.push('codex'); if (hasCopilot) selectedRuntimes.push('copilot'); + if (hasAntigravity) selectedRuntimes.push('antigravity'); } // WSL + Windows Node.js detection @@ -121,6 +123,7 @@ function getDirName(runtime) { if (runtime === 'opencode') return '.opencode'; if (runtime === 'gemini') return '.gemini'; if (runtime === 'codex') return '.codex'; + if (runtime === 'antigravity') return '.agent'; return '.claude'; } @@ -144,6 +147,10 @@ function getConfigDirFromHome(runtime, isGlobal) { } if (runtime === 'gemini') return "'.gemini'"; if (runtime === 'codex') return "'.codex'"; + if (runtime === 'antigravity') { + if (!isGlobal) return "'.agent'"; + return "'.gemini', 'antigravity'"; + } return "'.claude'"; } @@ -218,7 +225,18 @@ function getGlobalDir(runtime, explicitDir = null) { } return path.join(os.homedir(), '.copilot'); } - + + if (runtime === 'antigravity') { + // Antigravity: --config-dir > ANTIGRAVITY_CONFIG_DIR > ~/.gemini/antigravity + if (explicitDir) { + return expandTilde(explicitDir); + } + if (process.env.ANTIGRAVITY_CONFIG_DIR) { + return expandTilde(process.env.ANTIGRAVITY_CONFIG_DIR); + } + return path.join(os.homedir(), '.gemini', 'antigravity'); + } + // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude if (explicitDir) { return expandTilde(explicitDir); @@ -239,7 +257,7 @@ const banner = '\n' + '\n' + ' Get Shit Done ' + dim + 'v' + pkg.version + reset + '\n' + ' A meta-prompting, context engineering and spec-driven\n' + - ' development system for Claude Code, OpenCode, Gemini, Codex, and Copilot by TÂCHES.\n'; + ' development system for Claude Code, OpenCode, Gemini, Codex, Copilot, and Antigravity by TÂCHES.\n'; // Parse --config-dir argument function parseConfigDirArg() { @@ -277,7 +295,7 @@ if (hasUninstall) { // Show help if requested if (hasHelp) { - console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --codex --global --config-dir ~/.codex-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Codex globally${reset}\n npx get-shit-done-cc --codex --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR environment variables.\n`); + console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --codex --global --config-dir ~/.codex-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Codex globally${reset}\n npx get-shit-done-cc --codex --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR environment variables.\n`); process.exit(0); } @@ -601,6 +619,73 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) { return `${fm}\n${body}`; } +/** + * Apply Antigravity-specific content conversion — path replacement + command name conversion. + * Path mappings depend on install mode: + * Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agent/ + * Local: ~/.claude/ → .agent/, ./.claude/ → ./.agent/ + * Applied to ALL Antigravity content (skills, agents, engine files). + * @param {string} content - Source content to convert + * @param {boolean} [isGlobal=false] - Whether this is a global install + */ +function convertClaudeToAntigravityContent(content, isGlobal = false) { + let c = content; + if (isGlobal) { + c = c.replace(/\$HOME\/\.claude\//g, '$HOME/.gemini/antigravity/'); + c = c.replace(/~\/\.claude\//g, '~/.gemini/antigravity/'); + } else { + c = c.replace(/\$HOME\/\.claude\//g, '.agent/'); + c = c.replace(/~\/\.claude\//g, '.agent/'); + } + c = c.replace(/\.\/\.claude\//g, './.agent/'); + c = c.replace(/\.claude\//g, '.agent/'); + // Command name conversion (all gsd: references → gsd-) + c = c.replace(/gsd:/g, 'gsd-'); + return c; +} + +/** + * Convert a Claude command (.md) to an Antigravity skill (SKILL.md). + * Transforms frontmatter to minimal name + description only. + * Body passes through with path/command conversions applied. + */ +function convertClaudeCommandToAntigravitySkill(content, skillName, isGlobal = false) { + const converted = convertClaudeToAntigravityContent(content, isGlobal); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = skillName || extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + const fm = `---\nname: ${name}\ndescription: ${description}\n---`; + return `${fm}\n${body}`; +} + +/** + * Convert a Claude agent (.md) to an Antigravity agent. + * Uses Gemini tool names since Antigravity runs on Gemini 3 backend. + */ +function convertClaudeAgentToAntigravityAgent(content, isGlobal = false) { + const converted = convertClaudeToAntigravityContent(content, isGlobal); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const color = extractFrontmatterField(frontmatter, 'color'); + const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || ''; + + // Map tools to Gemini equivalents (reuse existing convertGeminiToolName) + const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean); + const mappedTools = claudeTools.map(t => convertGeminiToolName(t)).filter(Boolean); + + let fm = `---\nname: ${name}\ndescription: ${description}\ntools: ${mappedTools.join(', ')}\n`; + if (color) fm += `color: ${color}\n`; + fm += '---'; + + return `${fm}\n${body}`; +} + function toSingleLine(value) { return value.replace(/\s+/g, ' ').trim(); } @@ -1409,6 +1494,60 @@ function copyCommandsAsCopilotSkills(srcDir, skillsDir, prefix, isGlobal = false recurse(srcDir, prefix); } +/** + * Recursively install GSD commands as Antigravity skills. + * Each command becomes a skill-name/ folder containing SKILL.md. + * Mirrors copyCommandsAsCopilotSkills but uses Antigravity converters. + * @param {string} srcDir - Source commands directory + * @param {string} skillsDir - Target skills directory + * @param {string} prefix - Skill name prefix (e.g. 'gsd') + * @param {boolean} isGlobal - Whether this is a global install + */ +function copyCommandsAsAntigravitySkills(srcDir, skillsDir, prefix, isGlobal = false) { + if (!fs.existsSync(srcDir)) { + return; + } + + fs.mkdirSync(skillsDir, { recursive: true }); + + // Remove previous GSD Antigravity 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'); + content = convertClaudeCommandToAntigravitySkill(content, skillName, isGlobal); + content = processAttribution(content, getCommitAttribution('antigravity')); + + 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 @@ -1421,6 +1560,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand const isOpencode = runtime === 'opencode'; const isCodex = runtime === 'codex'; const isCopilot = runtime === 'copilot'; + const isAntigravity = runtime === 'antigravity'; const dirName = getDirName(runtime); // Clean install: remove existing destination to prevent orphaned files @@ -1441,7 +1581,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand // Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths // Skip generic replacement for Copilot — convertClaudeToCopilotContent handles all paths let content = fs.readFileSync(srcPath, 'utf8'); - if (!isCopilot) { + if (!isCopilot && !isAntigravity) { const globalClaudeRegex = /~\/\.claude\//g; const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; const localClaudeRegex = /\.\/\.claude\//g; @@ -1473,6 +1613,10 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand content = convertClaudeToCopilotContent(content, isGlobal); content = processAttribution(content, getCommitAttribution(runtime)); fs.writeFileSync(destPath, content); + } else if (isAntigravity) { + content = convertClaudeToAntigravityContent(content, isGlobal); + content = processAttribution(content, getCommitAttribution(runtime)); + fs.writeFileSync(destPath, content); } else { fs.writeFileSync(destPath, content); } @@ -1481,6 +1625,11 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand let content = fs.readFileSync(srcPath, 'utf8'); content = convertClaudeToCopilotContent(content, isGlobal); fs.writeFileSync(destPath, content); + } else if (isAntigravity && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { + // Antigravity: also transform .cjs/.js files for path/command conversions + let content = fs.readFileSync(srcPath, 'utf8'); + content = convertClaudeToAntigravityContent(content, isGlobal); + fs.writeFileSync(destPath, content); } else { fs.copyFileSync(srcPath, destPath); } @@ -1572,6 +1721,7 @@ function uninstall(isGlobal, runtime = 'claude') { const isOpencode = runtime === 'opencode'; const isCodex = runtime === 'codex'; const isCopilot = runtime === 'copilot'; + const isAntigravity = runtime === 'antigravity'; const dirName = getDirName(runtime); // Get the target directory based on runtime and install type @@ -1588,6 +1738,7 @@ function uninstall(isGlobal, runtime = 'claude') { if (runtime === 'gemini') runtimeLabel = 'Gemini'; if (runtime === 'codex') runtimeLabel = 'Codex'; if (runtime === 'copilot') runtimeLabel = 'Copilot'; + if (runtime === 'antigravity') runtimeLabel = 'Antigravity'; console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`); @@ -1698,6 +1849,23 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Cleaned GSD section from copilot-instructions.md`); } } + } else if (isAntigravity) { + // Antigravity: remove skills/gsd-*/ directories (same layout as Copilot skills) + const skillsDir = path.join(targetDir, 'skills'); + if (fs.existsSync(skillsDir)) { + let skillCount = 0; + const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('gsd-')) { + fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); + skillCount++; + } + } + if (skillCount > 0) { + removedCount++; + console.log(` ${green}✓${reset} Removed ${skillCount} Antigravity skills`); + } + } } else { const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(gsdCommandsDir)) { @@ -2105,6 +2273,7 @@ function writeManifest(configDir, runtime = 'claude') { const isOpencode = runtime === 'opencode'; const isCodex = runtime === 'codex'; const isCopilot = runtime === 'copilot'; + const isAntigravity = runtime === 'antigravity'; const gsdDir = path.join(configDir, 'get-shit-done'); const commandsDir = path.join(configDir, 'commands', 'gsd'); const opencodeCommandDir = path.join(configDir, 'command'); @@ -2116,7 +2285,7 @@ function writeManifest(configDir, runtime = 'claude') { for (const [rel, hash] of Object.entries(gsdHashes)) { manifest.files['get-shit-done/' + rel] = hash; } - if (!isOpencode && !isCodex && !isCopilot && fs.existsSync(commandsDir)) { + if (!isOpencode && !isCodex && !isCopilot && !isAntigravity && fs.existsSync(commandsDir)) { const cmdHashes = generateManifest(commandsDir); for (const [rel, hash] of Object.entries(cmdHashes)) { manifest.files['commands/gsd/' + rel] = hash; @@ -2129,7 +2298,7 @@ function writeManifest(configDir, runtime = 'claude') { } } } - if ((isCodex || isCopilot) && fs.existsSync(codexSkillsDir)) { + if ((isCodex || isCopilot || isAntigravity) && fs.existsSync(codexSkillsDir)) { for (const skillName of listCodexSkillNames(codexSkillsDir)) { const skillRoot = path.join(codexSkillsDir, skillName); const skillHashes = generateManifest(skillRoot); @@ -2227,6 +2396,7 @@ function install(isGlobal, runtime = 'claude') { const isGemini = runtime === 'gemini'; const isCodex = runtime === 'codex'; const isCopilot = runtime === 'copilot'; + const isAntigravity = runtime === 'antigravity'; const dirName = getDirName(runtime); const src = path.join(__dirname, '..'); @@ -2250,6 +2420,7 @@ function install(isGlobal, runtime = 'claude') { if (isGemini) runtimeLabel = 'Gemini'; if (isCodex) runtimeLabel = 'Codex'; if (isCopilot) runtimeLabel = 'Copilot'; + if (isAntigravity) runtimeLabel = 'Antigravity'; console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`); @@ -2302,6 +2473,21 @@ function install(isGlobal, runtime = 'claude') { } else { failures.push('skills/gsd-*'); } + } else if (isAntigravity) { + const skillsDir = path.join(targetDir, 'skills'); + const gsdSrc = path.join(src, 'commands', 'gsd'); + copyCommandsAsAntigravitySkills(gsdSrc, skillsDir, 'gsd', isGlobal); + if (fs.existsSync(skillsDir)) { + const count = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + if (count > 0) { + console.log(` ${green}✓${reset} Installed ${count} skills to skills/`); + } else { + failures.push('skills/gsd-*'); + } + } else { + failures.push('skills/gsd-*'); + } } else { // Claude Code & Gemini: nested structure in commands/ directory const commandsDir = path.join(targetDir, 'commands'); @@ -2350,7 +2536,7 @@ function install(isGlobal, runtime = 'claude') { // Replace ~/.claude/ and $HOME/.claude/ as they are the source of truth in the repo const dirRegex = /~\/\.claude\//g; const homeDirRegex = /\$HOME\/\.claude\//g; - if (!isCopilot) { + if (!isCopilot && !isAntigravity) { content = content.replace(dirRegex, pathPrefix); content = content.replace(homeDirRegex, pathPrefix); } @@ -2364,6 +2550,8 @@ function install(isGlobal, runtime = 'claude') { content = convertClaudeAgentToCodexAgent(content); } else if (isCopilot) { content = convertClaudeAgentToCopilotAgent(content, isGlobal); + } else if (isAntigravity) { + content = convertClaudeAgentToAntigravityAgent(content, isGlobal); } const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name; fs.writeFileSync(path.join(agentsDest, destName), content); @@ -2518,8 +2706,8 @@ 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'; + // Gemini and Antigravity use AfterTool instead of PostToolUse for post-tool hooks + const postToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse'; const settingsPath = path.join(targetDir, 'settings.json'); const settings = cleanupOrphanedHooks(readSettings(settingsPath)); const statuslineCommand = isGlobal @@ -2624,11 +2812,13 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS if (runtime === 'gemini') program = 'Gemini'; if (runtime === 'codex') program = 'Codex'; if (runtime === 'copilot') program = 'Copilot'; + if (runtime === 'antigravity') program = 'Antigravity'; let command = '/gsd:new-project'; if (runtime === 'opencode') command = '/gsd-new-project'; if (runtime === 'codex') command = '$gsd-new-project'; if (runtime === 'copilot') command = '/gsd-new-project'; + if (runtime === 'antigravity') command = '/gsd-new-project'; console.log(` ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}. @@ -2706,20 +2896,23 @@ function promptRuntime(callback) { } }); - console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset} - ${cyan}2${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - open source, free models - ${cyan}3${reset}) Gemini ${dim}(~/.gemini)${reset} - ${cyan}4${reset}) Codex ${dim}(~/.codex)${reset} - ${cyan}5${reset}) Copilot ${dim}(~/.copilot)${reset} - ${cyan}6${reset}) All + console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset} + ${cyan}2${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - open source, free models + ${cyan}3${reset}) Gemini ${dim}(~/.gemini)${reset} + ${cyan}4${reset}) Codex ${dim}(~/.codex)${reset} + ${cyan}5${reset}) Copilot ${dim}(~/.copilot)${reset} + ${cyan}6${reset}) Antigravity ${dim}(~/.gemini/antigravity)${reset} + ${cyan}7${reset}) All `); rl.question(` Choice ${dim}[1]${reset}: `, (answer) => { answered = true; rl.close(); const choice = answer.trim() || '1'; - if (choice === '6') { - callback(['claude', 'opencode', 'gemini', 'codex', 'copilot']); + if (choice === '7') { + callback(['claude', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity']); + } else if (choice === '6') { + callback(['antigravity']); } else if (choice === '5') { callback(['copilot']); } else if (choice === '4') { @@ -2842,6 +3035,10 @@ if (process.env.GSD_TEST_MODE) { GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER, mergeCopilotInstructions, stripGsdFromCopilotInstructions, + convertClaudeToAntigravityContent, + convertClaudeCommandToAntigravitySkill, + convertClaudeAgentToAntigravityAgent, + copyCommandsAsAntigravitySkills, writeManifest, reportLocalPatches, }; diff --git a/tests/antigravity-install.test.cjs b/tests/antigravity-install.test.cjs new file mode 100644 index 000000000..baa04ed10 --- /dev/null +++ b/tests/antigravity-install.test.cjs @@ -0,0 +1,423 @@ +/** + * GSD Tools Tests - Antigravity Install Plumbing + * + * Tests for Antigravity runtime directory resolution, config paths, + * content conversion functions, and integration with the multi-runtime installer. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const { + getDirName, + getGlobalDir, + getConfigDirFromHome, + convertClaudeToAntigravityContent, + convertClaudeCommandToAntigravitySkill, + convertClaudeAgentToAntigravityAgent, + copyCommandsAsAntigravitySkills, + writeManifest, +} = require('../bin/install.js'); + +// ─── getDirName ───────────────────────────────────────────────────────────────── + +describe('getDirName (Antigravity)', () => { + test('returns .agent for antigravity', () => { + assert.strictEqual(getDirName('antigravity'), '.agent'); + }); + + test('does not break existing runtimes', () => { + assert.strictEqual(getDirName('claude'), '.claude'); + assert.strictEqual(getDirName('opencode'), '.opencode'); + assert.strictEqual(getDirName('gemini'), '.gemini'); + assert.strictEqual(getDirName('codex'), '.codex'); + assert.strictEqual(getDirName('copilot'), '.github'); + }); +}); + +// ─── getGlobalDir ─────────────────────────────────────────────────────────────── + +describe('getGlobalDir (Antigravity)', () => { + let savedEnv; + + beforeEach(() => { + savedEnv = process.env.ANTIGRAVITY_CONFIG_DIR; + delete process.env.ANTIGRAVITY_CONFIG_DIR; + }); + + afterEach(() => { + if (savedEnv !== undefined) { + process.env.ANTIGRAVITY_CONFIG_DIR = savedEnv; + } else { + delete process.env.ANTIGRAVITY_CONFIG_DIR; + } + }); + + test('returns ~/.gemini/antigravity by default', () => { + const result = getGlobalDir('antigravity'); + assert.strictEqual(result, path.join(os.homedir(), '.gemini', 'antigravity')); + }); + + test('respects ANTIGRAVITY_CONFIG_DIR env var', () => { + const customDir = path.join(os.homedir(), 'custom-ag'); + process.env.ANTIGRAVITY_CONFIG_DIR = customDir; + const result = getGlobalDir('antigravity'); + assert.strictEqual(result, customDir); + }); + + test('explicit config-dir overrides env var', () => { + process.env.ANTIGRAVITY_CONFIG_DIR = path.join(os.homedir(), 'from-env'); + const explicit = path.join(os.homedir(), 'explicit-ag'); + const result = getGlobalDir('antigravity', explicit); + assert.strictEqual(result, explicit); + }); + + test('does not change Claude Code global dir', () => { + assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude')); + }); +}); + +// ─── getConfigDirFromHome ─────────────────────────────────────────────────────── + +describe('getConfigDirFromHome (Antigravity)', () => { + test('returns .agent for local installs', () => { + assert.strictEqual(getConfigDirFromHome('antigravity', false), "'.agent'"); + }); + + test('returns .gemini, antigravity for global installs', () => { + assert.strictEqual(getConfigDirFromHome('antigravity', true), "'.gemini', 'antigravity'"); + }); + + test('does not change other runtimes', () => { + assert.strictEqual(getConfigDirFromHome('claude', true), "'.claude'"); + assert.strictEqual(getConfigDirFromHome('gemini', true), "'.gemini'"); + assert.strictEqual(getConfigDirFromHome('copilot', true), "'.copilot'"); + }); +}); + +// ─── convertClaudeToAntigravityContent ───────────────────────────────────────── + +describe('convertClaudeToAntigravityContent', () => { + describe('global install path replacements', () => { + test('replaces ~/. claude/ with ~/.gemini/antigravity/', () => { + const input = 'See ~/.claude/get-shit-done/workflows/'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok(result.includes('~/.gemini/antigravity/get-shit-done/workflows/'), result); + assert.ok(!result.includes('~/.claude/'), result); + }); + + test('replaces $HOME/.claude/ with $HOME/.gemini/antigravity/', () => { + const input = 'path.join($HOME/.claude/get-shit-done)'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok(result.includes('$HOME/.gemini/antigravity/'), result); + assert.ok(!result.includes('$HOME/.claude/'), result); + }); + }); + + describe('local install path replacements', () => { + test('replaces ~/.claude/ with .agent/ for local installs', () => { + const input = 'See ~/.claude/get-shit-done/'; + const result = convertClaudeToAntigravityContent(input, false); + assert.ok(result.includes('.agent/get-shit-done/'), result); + assert.ok(!result.includes('~/.claude/'), result); + }); + + test('replaces ./.claude/ with ./.agent/', () => { + const input = 'path ./.claude/hooks/gsd-check-update.js'; + const result = convertClaudeToAntigravityContent(input, false); + assert.ok(result.includes('./.agent/hooks/'), result); + assert.ok(!result.includes('./.claude/'), result); + }); + + test('replaces .claude/ with .agent/', () => { + const input = 'node .claude/hooks/gsd-statusline.js'; + const result = convertClaudeToAntigravityContent(input, false); + assert.ok(result.includes('.agent/hooks/gsd-statusline.js'), result); + assert.ok(!result.includes('.claude/'), result); + }); + }); + + describe('command name conversion', () => { + test('converts /gsd:command to /gsd-command', () => { + const input = 'Run /gsd:new-project to start'; + const result = convertClaudeToAntigravityContent(input, true); + assert.ok(result.includes('/gsd-new-project'), result); + assert.ok(!result.includes('gsd:'), result); + }); + + test('converts all gsd: references', () => { + const input = '/gsd:plan-phase and /gsd:execute-phase'; + const result = convertClaudeToAntigravityContent(input, false); + assert.ok(result.includes('/gsd-plan-phase'), result); + assert.ok(result.includes('/gsd-execute-phase'), result); + }); + }); + + test('does not modify unrelated content', () => { + const input = 'This is a plain text description with no paths.'; + const result = convertClaudeToAntigravityContent(input, false); + assert.strictEqual(result, input); + }); +}); + +// ─── convertClaudeCommandToAntigravitySkill ───────────────────────────────────── + +describe('convertClaudeCommandToAntigravitySkill', () => { + const claudeCommand = `--- +name: gsd:new-project +description: Initialize a new GSD project with requirements and roadmap +argument-hint: "[project-name]" +allowed-tools: + - Read + - Write + - Bash + - Agent +--- + +Initialize new project at ~/.claude/get-shit-done/workflows/new-project.md +`; + + test('produces name and description only in frontmatter', () => { + const result = convertClaudeCommandToAntigravitySkill(claudeCommand, 'gsd-new-project', false); + assert.ok(result.startsWith('---\n'), result); + assert.ok(result.includes('name: gsd-new-project'), result); + assert.ok(result.includes('description: Initialize a new GSD project'), result); + // No allowed-tools in output + assert.ok(!result.includes('allowed-tools'), result); + // No argument-hint in output + assert.ok(!result.includes('argument-hint'), result); + }); + + test('applies path replacement in body', () => { + const result = convertClaudeCommandToAntigravitySkill(claudeCommand, 'gsd-new-project', false); + assert.ok(result.includes('.agent/get-shit-done/'), result); + assert.ok(!result.includes('~/.claude/'), result); + }); + + test('uses provided skillName for name field', () => { + const result = convertClaudeCommandToAntigravitySkill(claudeCommand, 'gsd-custom-name', false); + assert.ok(result.includes('name: gsd-custom-name'), result); + }); + + test('converts gsd: command references in body', () => { + const content = `--- +name: test +description: test skill +--- +Run /gsd:new-project to get started. +`; + const result = convertClaudeCommandToAntigravitySkill(content, 'gsd-test', false); + assert.ok(result.includes('/gsd-new-project'), result); + assert.ok(!result.includes('gsd:'), result); + }); + + test('returns unchanged content when no frontmatter', () => { + const noFm = 'Just some text without frontmatter.'; + const result = convertClaudeCommandToAntigravitySkill(noFm, 'gsd-test', false); + // Path replacements still apply, but no frontmatter transformation + assert.ok(!result.startsWith('---'), result); + }); +}); + +// ─── convertClaudeAgentToAntigravityAgent ────────────────────────────────────── + +describe('convertClaudeAgentToAntigravityAgent', () => { + const claudeAgent = `--- +name: gsd-executor +description: Executes GSD plans with atomic commits +tools: Read, Write, Edit, Bash, Glob, Grep, Task +color: blue +--- + +Execute plans from ~/.claude/get-shit-done/workflows/execute-phase.md +`; + + test('preserves name and description', () => { + const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false); + assert.ok(result.includes('name: gsd-executor'), result); + assert.ok(result.includes('description: Executes GSD plans'), result); + }); + + test('maps Claude tools to Gemini tool names', () => { + const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false); + // Read → read_file, Bash → run_shell_command + assert.ok(result.includes('read_file'), result); + assert.ok(result.includes('run_shell_command'), result); + // Original Claude names should not appear in tools line + const fmEnd = result.indexOf('---', 3); + const frontmatter = result.slice(0, fmEnd); + assert.ok(!frontmatter.includes('tools: Read,'), frontmatter); + }); + + test('preserves color field', () => { + const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false); + assert.ok(result.includes('color: blue'), result); + }); + + test('applies path replacement in body', () => { + const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false); + assert.ok(result.includes('.agent/get-shit-done/'), result); + assert.ok(!result.includes('~/.claude/'), result); + }); + + test('uses global path for global installs', () => { + const result = convertClaudeAgentToAntigravityAgent(claudeAgent, true); + assert.ok(result.includes('~/.gemini/antigravity/get-shit-done/'), result); + }); + + test('excludes Task tool (filtered by convertGeminiToolName)', () => { + const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false); + // Task is excluded by convertGeminiToolName (returns null for Task) + const fmEnd = result.indexOf('---', 3); + const frontmatter = result.slice(0, fmEnd); + assert.ok(!frontmatter.includes('Task'), frontmatter); + }); +}); + +// ─── copyCommandsAsAntigravitySkills ─────────────────────────────────────────── + +describe('copyCommandsAsAntigravitySkills', () => { + let tmpDir; + let srcDir; + let skillsDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ag-test-')); + srcDir = path.join(tmpDir, 'commands', 'gsd'); + skillsDir = path.join(tmpDir, 'skills'); + fs.mkdirSync(srcDir, { recursive: true }); + + // Create a sample command file + fs.writeFileSync(path.join(srcDir, 'new-project.md'), `--- +name: gsd:new-project +description: Initialize a new project +allowed-tools: + - Read + - Write +--- +Run /gsd:new-project to start. +`); + + // Create a subdirectory command + const subDir = path.join(srcDir, 'subdir'); + fs.mkdirSync(subDir, { recursive: true }); + fs.writeFileSync(path.join(subDir, 'sub-command.md'), `--- +name: gsd:sub-command +description: A sub-command +allowed-tools: + - Read +--- +Body text. +`); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('creates skills directory', () => { + copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false); + assert.ok(fs.existsSync(skillsDir)); + }); + + test('creates one skill directory per command with SKILL.md', () => { + copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false); + const skillDir = path.join(skillsDir, 'gsd-new-project'); + assert.ok(fs.existsSync(skillDir), 'skill dir should exist'); + assert.ok(fs.existsSync(path.join(skillDir, 'SKILL.md')), 'SKILL.md should exist'); + }); + + test('handles subdirectory commands with prefixed names', () => { + copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false); + const subSkillDir = path.join(skillsDir, 'gsd-subdir-sub-command'); + assert.ok(fs.existsSync(subSkillDir), 'subdirectory skill dir should exist'); + }); + + test('SKILL.md has minimal frontmatter (name + description only)', () => { + copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false); + const content = fs.readFileSync(path.join(skillsDir, 'gsd-new-project', 'SKILL.md'), 'utf8'); + assert.ok(content.includes('name: gsd-new-project'), content); + assert.ok(content.includes('description: Initialize a new project'), content); + assert.ok(!content.includes('allowed-tools'), content); + }); + + test('SKILL.md body has paths converted for local install', () => { + copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false); + const content = fs.readFileSync(path.join(skillsDir, 'gsd-new-project', 'SKILL.md'), 'utf8'); + // gsd: → gsd- conversion + assert.ok(!content.includes('gsd:'), content); + }); + + test('removes old gsd-* skill dirs before reinstalling', () => { + // Create a stale skill dir + const staleDir = path.join(skillsDir, 'gsd-old-skill'); + fs.mkdirSync(staleDir, { recursive: true }); + fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '---\nname: old\n---\n'); + + copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false); + + assert.ok(!fs.existsSync(staleDir), 'stale skill dir should be removed'); + }); + + test('does not remove non-gsd skill dirs', () => { + // Create a non-GSD skill dir + const otherDir = path.join(skillsDir, 'my-custom-skill'); + fs.mkdirSync(otherDir, { recursive: true }); + + copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false); + + assert.ok(fs.existsSync(otherDir), 'non-GSD skill dir should be preserved'); + }); +}); + +// ─── writeManifest (Antigravity) ─────────────────────────────────────────────── + +describe('writeManifest (Antigravity)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-ag-')); + // Create minimal structure + const skillsDir = path.join(tmpDir, 'skills', 'gsd-help'); + fs.mkdirSync(skillsDir, { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'SKILL.md'), '---\nname: gsd-help\ndescription: Help\n---\n'); + const gsdDir = path.join(tmpDir, 'get-shit-done'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(path.join(gsdDir, 'VERSION'), '1.0.0'); + const agentsDir = path.join(tmpDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), '---\nname: gsd-executor\n---\n'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('writes manifest JSON file', () => { + writeManifest(tmpDir, 'antigravity'); + const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json'); + assert.ok(fs.existsSync(manifestPath), 'manifest file should exist'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + assert.ok(manifest.version, 'should have version'); + assert.ok(manifest.files, 'should have files'); + }); + + test('manifest includes skills in skills/ directory', () => { + writeManifest(tmpDir, 'antigravity'); + const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8')); + const skillFiles = Object.keys(manifest.files).filter(f => f.startsWith('skills/')); + assert.ok(skillFiles.length > 0, 'should have skill files in manifest'); + }); + + test('manifest includes agent files', () => { + writeManifest(tmpDir, 'antigravity'); + const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8')); + const agentFiles = Object.keys(manifest.files).filter(f => f.startsWith('agents/')); + assert.ok(agentFiles.length > 0, 'should have agent files in manifest'); + }); +}); diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index 0f7d50faf..2c3d9cae2 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -137,13 +137,13 @@ describe('Source code integration (Copilot)', () => { test('CLI-03: --all array includes copilot', () => { assert.ok( - src.includes("'claude', 'opencode', 'gemini', 'codex', 'copilot'"), - '--all includes copilot as 5th runtime' + src.includes("'copilot'") && src.includes('selectedRuntimes = ['), + '--all includes copilot runtime' ); }); test('CLI-06: banner text includes Copilot', () => { - assert.ok(src.includes('Copilot by'), 'banner mentions Copilot'); + assert.ok(src.includes('Copilot'), 'banner mentions Copilot'); }); test('CLI-06: help text includes --copilot', () => { @@ -158,11 +158,10 @@ describe('Source code integration (Copilot)', () => { assert.ok(nextLines.includes('copilot'), 'choice 5 maps to copilot'); }); - test('CLI-02: promptRuntime has All as option 6', () => { - assert.ok(src.includes("choice === '6'"), 'choice 6 exists'); - const choice6Index = src.indexOf("choice === '6'"); - const nextLines = src.substring(choice6Index, choice6Index + 150); - assert.ok(nextLines.includes('copilot'), 'choice 6 (All) includes copilot'); + test('CLI-02: promptRuntime has All option including copilot', () => { + // All option callback includes copilot in the runtimes array + const allCallbackMatch = src.match(/callback\(\[(['a-z', ]+)\]\)/g); + assert.ok(allCallbackMatch && allCallbackMatch.some(m => m.includes('copilot')), 'All option includes copilot'); }); test('isCopilot variable exists in install function', () => { @@ -747,7 +746,7 @@ describe('Copilot agent conversion - real files', () => { assert.ok(toolsLine.includes("'read'"), 'Read mapped'); }); - test('all 12 agents convert without error', () => { + test('all 15 agents convert without error', () => { const agents = fs.readdirSync(agentsSrc) .filter(f => f.startsWith('gsd-') && f.endsWith('.md')); assert.strictEqual(agents.length, 15, `expected 15 agents, got ${agents.length}`);