From dc3698f8771886b7844d919a7ce22160f498e006 Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Fri, 24 Apr 2026 17:40:37 -0400 Subject: [PATCH 01/11] feat(gemini): add Gemini CLI as supported target with integration tests Add full Gemini CLI support including target profile, runtime setup scripts, golden scenario E2E test, and offline integration tests verifying command/instruction/skill/MCP deployment to .gemini/. Co-Authored-By: Claude Opus 4.6 --- README.md | 6 +- .../docs/enterprise/making-the-case.md | 6 +- .../docs/getting-started/quick-start.md | 6 +- docs/src/content/docs/guides/compilation.md | 6 +- docs/src/content/docs/guides/mcp-servers.md | 4 +- docs/src/content/docs/index.mdx | 4 +- .../docs/integrations/ide-tool-integration.md | 6 +- docs/src/content/docs/introduction/why-apm.md | 4 +- scripts/runtime/setup-gemini.ps1 | 151 +++++++ scripts/runtime/setup-gemini.sh | 193 +++++++++ src/apm_cli/adapters/client/gemini.py | 132 ++++++ src/apm_cli/commands/runtime.py | 4 +- src/apm_cli/compilation/agents_compiler.py | 12 +- src/apm_cli/core/script_runner.py | 53 ++- src/apm_cli/core/target_detection.py | 42 +- src/apm_cli/factory.py | 2 + src/apm_cli/integration/command_integrator.py | 43 +- src/apm_cli/integration/hook_integrator.py | 5 + .../integration/instruction_integrator.py | 45 +- src/apm_cli/integration/mcp_integrator.py | 51 ++- src/apm_cli/integration/targets.py | 29 ++ src/apm_cli/runtime/manager.py | 19 +- tests/integration/test_gemini_integration.py | 397 ++++++++++++++++++ tests/integration/test_golden_scenario_e2e.py | 161 ++++++- tests/unit/core/test_scope.py | 2 +- tests/unit/core/test_target_detection.py | 2 +- .../integration/test_command_integrator.py | 177 ++++++++ .../integration/test_data_driven_dispatch.py | 2 + .../test_instruction_integrator.py | 111 +++++ tests/unit/integration/test_targets.py | 24 ++ tests/unit/test_gemini_mcp.py | 166 ++++++++ tests/unit/test_global_mcp_scope.py | 16 +- tests/unit/test_runtime_manager.py | 6 +- 33 files changed, 1803 insertions(+), 84 deletions(-) create mode 100644 scripts/runtime/setup-gemini.ps1 create mode 100755 scripts/runtime/setup-gemini.sh create mode 100644 src/apm_cli/adapters/client/gemini.py create mode 100644 tests/integration/test_gemini_integration.py create mode 100644 tests/unit/test_gemini_mcp.py diff --git a/README.md b/README.md index e0fd8a2b7..32117c842 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Think `package.json`, `requirements.txt`, or `Cargo.toml` — but for AI agent configuration. -GitHub Copilot · Claude Code · Cursor · OpenCode · Codex +GitHub Copilot · Claude Code · Cursor · OpenCode · Codex · Gemini CLI **[Documentation](https://microsoft.github.io/apm/)** · **[Quick Start](https://microsoft.github.io/apm/getting-started/quick-start/)** · **[CLI Reference](https://microsoft.github.io/apm/reference/cli-commands/)** · **[Roadmap](https://github.com/orgs/microsoft/projects/2304)** @@ -50,7 +50,7 @@ apm install # every agent is configured One `apm.yml` describes every primitive your agents need — instructions, skills, prompts, agents, hooks, plugins, MCP servers — and `apm install` reproduces the exact same setup across every client on every machine. `apm.lock.yaml` pins the resolved tree the way `package-lock.json` does for npm. -- **[One manifest for everything](https://microsoft.github.io/apm/reference/primitive-types/)** — declared once, deployed across Copilot, Claude, Cursor, OpenCode, Codex +- **[One manifest for everything](https://microsoft.github.io/apm/reference/primitive-types/)** — declared once, deployed across Copilot, Claude, Cursor, OpenCode, Codex, Gemini - **[Install from anywhere](https://microsoft.github.io/apm/guides/dependencies/)** — GitHub, GitLab, Bitbucket, Azure DevOps, GitHub Enterprise, any git host - **[Transitive dependencies](https://microsoft.github.io/apm/guides/dependencies/)** — packages can depend on packages; APM resolves the full tree - **[Author plugins](https://microsoft.github.io/apm/guides/plugins/)** — build Copilot, Claude, and Cursor plugins with dependency management, then export standard `plugin.json` @@ -128,7 +128,7 @@ apm marketplace add github/awesome-copilot apm install azure-cloud-development@awesome-copilot ``` -Or add an MCP server (wired into Copilot, Claude, Cursor, Codex, and OpenCode): +Or add an MCP server (wired into Copilot, Claude, Cursor, Codex, OpenCode, and Gemini): ```bash apm install --mcp io.github.github/github-mcp-server --transport http # connects over HTTPS diff --git a/docs/src/content/docs/enterprise/making-the-case.md b/docs/src/content/docs/enterprise/making-the-case.md index 815420e24..6e4bd0778 100644 --- a/docs/src/content/docs/enterprise/making-the-case.md +++ b/docs/src/content/docs/enterprise/making-the-case.md @@ -11,7 +11,7 @@ An internal advocacy toolkit. The lead section frames the problem; the rest is d ## The problem at scale -Consider a mid-to-large engineering organization: 50 repositories, 200 developers, four AI coding tools (Copilot, Claude, Cursor, OpenCode). +Consider a mid-to-large engineering organization: 50 repositories, 200 developers, five AI coding tools (Copilot, Claude, Cursor, OpenCode, Gemini). Without centralized configuration management, a predictable set of problems emerges: @@ -49,7 +49,7 @@ Running `apm install` resolves every dependency and writes `apm.lock.yaml`, whic ### Install -`apm install` reads the lock file and deploys configuration into the native formats expected by each tool -- `.github/` for Copilot, `.claude/` for Claude, `.cursor/` for Cursor, `.opencode/` for OpenCode. APM generates static files and then gets out of the way. There is no runtime, no daemon, no background process. +`apm install` reads the lock file and deploys configuration into the native formats expected by each tool -- `.github/` for Copilot, `.claude/` for Claude, `.cursor/` for Cursor, `.opencode/` for OpenCode, `.gemini/` for Gemini. APM generates static files and then gets out of the way. There is no runtime, no daemon, no background process. ### Audit @@ -109,7 +109,7 @@ For the full forensic and compliance recipes, see the [Lock File Specification]( Plugins handle single-tool installation for a single AI platform. APM adds capabilities that plugins do not provide: -- **Cross-tool composition.** One manifest manages configuration for Copilot, Claude, Cursor, OpenCode, and any other agent runtime simultaneously. +- **Cross-tool composition.** One manifest manages configuration for Copilot, Claude, Cursor, OpenCode, Gemini, and any other agent runtime simultaneously. - **Consumer-side lock files.** Plugins install the latest version. APM pins exact versions so your team stays synchronized. - **CI enforcement.** Content scanning is built into `apm install` -- no plugin equivalent exists. `apm audit --ci` adds lockfile consistency checks and `--policy org` enforces organizational rules. - **Multi-source dependency resolution.** APM resolves transitive dependencies across packages from multiple git hosts. diff --git a/docs/src/content/docs/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md index 09adb1dd0..f7b2fe21c 100644 --- a/docs/src/content/docs/getting-started/quick-start.md +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -97,7 +97,7 @@ my-project/ Three things happened: 1. The package was downloaded into `apm_modules/` (like `node_modules/`). -2. Instructions, agents, and skills were deployed to `.github/`, `.claude/`, `.cursor/`, and `.opencode/` (when present) -- the native directories that GitHub Copilot, Claude, Cursor, and OpenCode read from. If the project has its own `.apm/` content, that is deployed too (local content takes priority over dependencies on collision). +2. Instructions, agents, and skills were deployed to `.github/`, `.claude/`, `.cursor/`, `.opencode/`, and `.gemini/` (when present) -- the native directories that GitHub Copilot, Claude, Cursor, OpenCode, and Gemini read from. If the project has its own `.apm/` content, that is deployed too (local content takes priority over dependencies on collision). 3. A lockfile (`apm.lock.yaml`) was created, pinning the exact commit so every team member gets identical configuration. Your `apm.yml` now tracks the dependency: @@ -112,7 +112,7 @@ dependencies: ## That's it -Open your editor. GitHub Copilot, Claude, Cursor, and OpenCode pick up the new context immediately -- no extra configuration, no compile step, no restart. The agent now knows your project's design standards, can run your prompt templates, and follows the conventions defined in the package. +Open your editor. GitHub Copilot, Claude, Cursor, OpenCode, and Gemini pick up the new context immediately -- no extra configuration, no compile step, no restart. The agent now knows your project's design standards, can run your prompt templates, and follows the conventions defined in the package. This is the core idea: **packages define what your AI agent knows, and `apm install` puts that knowledge exactly where your tools expect it.** @@ -157,7 +157,7 @@ APM also manages MCP servers -- the tools your AI agent calls at runtime. apm install --mcp io.github.github/github-mcp-server ``` -This wires the server into every detected client (Copilot, Claude, Cursor, Codex, OpenCode). See the [MCP Servers guide](../../guides/mcp-servers/) for stdio and remote shapes. +This wires the server into every detected client (Copilot, Claude, Cursor, Codex, OpenCode, Gemini). See the [MCP Servers guide](../../guides/mcp-servers/) for stdio and remote shapes. ## Next steps diff --git a/docs/src/content/docs/guides/compilation.md b/docs/src/content/docs/guides/compilation.md index 201656ff3..289f76f08 100644 --- a/docs/src/content/docs/guides/compilation.md +++ b/docs/src/content/docs/guides/compilation.md @@ -4,7 +4,7 @@ sidebar: order: 1 --- -Compilation is **optional for most users**. If your team uses GitHub Copilot, Claude, or Cursor, `apm install` deploys all primitives in their native format -- you can skip this guide entirely. For OpenCode, `apm install` deploys agents, commands, skills, and MCP, but instructions require `apm compile` to generate the `AGENTS.md` that OpenCode reads. For Codex, `apm install` deploys skills, agents, and hooks natively, but instructions require `apm compile`. `apm compile` is also needed for Gemini or other tools that read single-root-file formats. +Compilation is **optional for most users**. If your team uses GitHub Copilot, Claude, Cursor, or Gemini, `apm install` deploys all primitives in their native format -- you can skip this guide entirely. For OpenCode, `apm install` deploys agents, commands, skills, and MCP, but instructions require `apm compile` to generate the `AGENTS.md` that OpenCode reads. For Codex, `apm install` deploys skills, agents, and hooks natively, but instructions require `apm compile`. **Solving the AI agent scalability problem through constraint satisfaction optimization** @@ -447,9 +447,9 @@ Different AI tools get different levels of support from `apm install` vs `apm co | Cursor | `.cursor/rules/`, `.cursor/agents/`, `.cursor/skills/`, `.cursor/hooks.json`, `.cursor/mcp.json` | `AGENTS.md` (optional) | **Full** | | OpenCode | `.opencode/agents/`, `.opencode/commands/`, `.opencode/skills/`, `opencode.json` (MCP) | Via `AGENTS.md` | **Full** | | Codex CLI | `.agents/skills/`, `.codex/agents/`, `.codex/hooks.json` | `AGENTS.md` (instructions) | **Full** | -| Gemini | -- | `GEMINI.md` | Instructions via compile | +| Gemini | `.gemini/rules/`, `.gemini/commands/`, `.gemini/skills/`, `.gemini/settings.json` (MCP, hooks) | Via `GEMINI.md` | **Full** | -For Copilot, Claude, and Cursor users, `apm install` handles everything natively. OpenCode and Codex users should also run `apm compile` to generate `AGENTS.md` for instructions. Compilation is the bridge that brings instruction support to tools that do not yet have first-class APM integration. +For Copilot, Claude, Cursor, and Gemini users, `apm install` handles everything natively. OpenCode and Codex users should also run `apm compile` to generate `AGENTS.md` for instructions. Compilation is the bridge that brings instruction support to tools that do not yet have first-class APM integration. ## Theoretical Foundations diff --git a/docs/src/content/docs/guides/mcp-servers.md b/docs/src/content/docs/guides/mcp-servers.md index ae5b9d60a..1b3fb5955 100644 --- a/docs/src/content/docs/guides/mcp-servers.md +++ b/docs/src/content/docs/guides/mcp-servers.md @@ -1,13 +1,13 @@ --- title: "MCP Servers" -description: "Add MCP servers to your project with apm install --mcp. Supports stdio, registry, and remote HTTP servers across Copilot, Claude, Cursor, Codex, and OpenCode." +description: "Add MCP servers to your project with apm install --mcp. Supports stdio, registry, and remote HTTP servers across Copilot, Claude, Cursor, Codex, OpenCode, and Gemini." sidebar: order: 6 --- APM manages your agent configuration in `apm.yml` -- think `package.json` for AI. MCP servers are dependencies in that manifest. -`apm install --mcp` adds a server to `apm.yml` and wires it into every detected client (Copilot, Claude, Cursor, Codex, OpenCode) in one step. +`apm install --mcp` adds a server to `apm.yml` and wires it into every detected client (Copilot, Claude, Cursor, Codex, OpenCode, Gemini) in one step. ## Quick Start diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 88bf50459..ad5c36298 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -26,7 +26,7 @@ APM fixes this. You declare your project's agent configuration once in `apm.yml` - One `apm.yml` declares skills, instructions, prompts, agents, hooks, plugins, and MCP servers. Transitive dependencies resolve like npm or pip; `apm.lock.yaml` pins exact versions for reproducible installs across Copilot, Claude Code, Cursor, OpenCode, and Codex. + One `apm.yml` declares skills, instructions, prompts, agents, hooks, plugins, and MCP servers. Transitive dependencies resolve like npm or pip; `apm.lock.yaml` pins exact versions for reproducible installs across Copilot, Claude Code, Cursor, OpenCode, Codex, and Gemini. Skills, prompts, instructions, hooks — everything agents execute is an attack surface. `apm install` scans packages for hidden Unicode and other tampering before they reach your agents; `apm audit` reports the full chain of trust. @@ -92,7 +92,7 @@ New developer joins the team: git clone && cd && apm install ``` -**That's it.** Copilot, Claude, Cursor, OpenCode, Codex — every harness is configured with the right context and capabilities. The manifest defines the project's custom and portable Agentic SDLC setup installable in a single command. +**That's it.** Copilot, Claude, Cursor, OpenCode, Codex, Gemini — every harness is configured with the right context and capabilities. The manifest defines the project's custom and portable Agentic SDLC setup installable in a single command. ## Open Source & Community diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index 114e29994..5ff4c42e6 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -150,7 +150,7 @@ apm install microsoft/apm-sample-package ### Optional: Compiled Context with AGENTS.md -For tools that do not support granular primitive discovery (such as Gemini), `apm compile` produces an `AGENTS.md` file that merges instructions into a single document. This is not needed for GitHub Copilot, Claude, or Cursor, which read per-file instructions natively. OpenCode and Codex also read `AGENTS.md`, so run `apm compile` to deploy instructions there. +For tools that do not support granular primitive discovery, `apm compile` produces an `AGENTS.md` file that merges instructions into a single document. This is not needed for GitHub Copilot, Claude, Cursor, or Gemini, which read per-file instructions natively. OpenCode and Codex also read `AGENTS.md`, so run `apm compile` to deploy instructions there. ```bash # Compile all local and dependency instructions into AGENTS.md @@ -286,7 +286,7 @@ apm install ComposioHQ/awesome-claude-skills/mcp-builder ### Automatic Hook Integration -APM automatically integrates hooks from installed packages. Hooks define lifecycle event handlers (e.g., `PreToolUse`, `PostToolUse`, `Stop`) supported by VS Code Copilot, Claude Code, and Cursor. +APM automatically integrates hooks from installed packages. Hooks define lifecycle event handlers (e.g., `PreToolUse`, `PostToolUse`, `Stop`) supported by VS Code Copilot, Claude Code, Cursor, and Gemini. > **Note:** Hook packages must be authored in the target platform's native format. APM handles path rewriting and file placement but does not translate between hook schema formats (e.g., Claude's `command` key vs GitHub Copilot's `bash`/`powershell` keys, or event name casing differences). @@ -319,7 +319,7 @@ apm install anthropics/claude-plugins-official/plugins/hookify ### Optional: Target-Specific Compilation -Compilation is optional for Copilot, Claude, and Cursor, which read per-file instructions natively. For OpenCode and Codex, run `apm compile` to generate `AGENTS.md` for instructions. Also use it when targeting Gemini: +Compilation is optional for Copilot, Claude, Cursor, and Gemini, which read per-file instructions natively. For OpenCode and Codex, run `apm compile` to generate `AGENTS.md` for instructions: ```bash # Generate all formats (default) diff --git a/docs/src/content/docs/introduction/why-apm.md b/docs/src/content/docs/introduction/why-apm.md index 3f8280cc9..3854839be 100644 --- a/docs/src/content/docs/introduction/why-apm.md +++ b/docs/src/content/docs/introduction/why-apm.md @@ -35,8 +35,8 @@ dependencies: Run `apm install` and APM: - **Resolves transitive dependencies** — if package A depends on package B, both are installed automatically. -- **Integrates primitives** -- instructions, prompts, agents, and skills are deployed to `.github/`, `.claude/`, `.cursor/`, `.opencode/`, and `.codex/` based on which directories exist. GitHub Copilot, Claude, Cursor, OpenCode, and Codex read these natively. -- **Bridges other tools** — for Gemini and other tools without native integration, `apm compile` generates compatible instruction files (`AGENTS.md`, `CLAUDE.md`). +- **Integrates primitives** -- instructions, prompts, agents, and skills are deployed to `.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.codex/`, and `.gemini/` based on which directories exist. GitHub Copilot, Claude, Cursor, OpenCode, Codex, and Gemini read these natively. +- **Bridges other tools** — for tools without native integration, `apm compile` generates compatible instruction files (`AGENTS.md`, `CLAUDE.md`). ## APM vs. Manual Setup diff --git a/scripts/runtime/setup-gemini.ps1 b/scripts/runtime/setup-gemini.ps1 new file mode 100644 index 000000000..4b90b0d2d --- /dev/null +++ b/scripts/runtime/setup-gemini.ps1 @@ -0,0 +1,151 @@ +# Setup script for Google Gemini CLI runtime (Windows) +# Installs @google/gemini-cli with MCP configuration support + +param( + [switch]$Vanilla +) + +$ErrorActionPreference = "Stop" + +# Source common utilities +. "$PSScriptRoot\setup-common.ps1" + +# Configuration +$GeminiPackage = "@google/gemini-cli" +$NodeMinVersion = 20 +$NpmMinVersion = 10 + +function Test-NodeVersion { + Write-Info "Checking Node.js version..." + + $node = Get-Command node -ErrorAction SilentlyContinue + if (-not $node) { + Write-ErrorText "Node.js is not installed" + Write-Info "Please install Node.js version $NodeMinVersion or higher from https://nodejs.org/" + exit 1 + } + + $nodeVersion = (node --version) -replace '^v', '' + $nodeMajor = [int]($nodeVersion.Split('.')[0]) + + if ($nodeMajor -lt $NodeMinVersion) { + Write-ErrorText "Node.js version $nodeVersion is too old. Required: v$NodeMinVersion or higher" + Write-Info "Please update Node.js from https://nodejs.org/" + exit 1 + } + + Write-Success "Node.js version $nodeVersion" +} + +function Test-NpmVersion { + Write-Info "Checking npm version..." + + $npm = Get-Command npm -ErrorAction SilentlyContinue + if (-not $npm) { + Write-ErrorText "npm is not installed" + Write-Info "Please install npm version $NpmMinVersion or higher" + exit 1 + } + + $npmVersion = npm --version + $npmMajor = [int]($npmVersion.Split('.')[0]) + + if ($npmMajor -lt $NpmMinVersion) { + Write-ErrorText "npm version $npmVersion is too old. Required: v$NpmMinVersion or higher" + Write-Info "Please update npm with: npm install -g npm@latest" + exit 1 + } + + Write-Success "npm version $npmVersion" +} + +function Install-GeminiCli { + Write-Info "Installing Google Gemini CLI..." + + try { + npm install -g $GeminiPackage + Write-Success "Successfully installed $GeminiPackage" + } catch { + Write-ErrorText "Failed to install $GeminiPackage" + Write-Info "This might be due to:" + Write-Info " - Insufficient permissions (try running as Administrator)" + Write-Info " - Network connectivity issues" + Write-Info " - Node.js/npm version compatibility" + exit 1 + } +} + +function Initialize-GeminiDirectory { + Write-Info "Setting up Gemini CLI directory structure..." + + $geminiConfigDir = Join-Path $env:USERPROFILE ".gemini" + $settingsFile = Join-Path $geminiConfigDir "settings.json" + + if (-not (Test-Path $geminiConfigDir)) { + Write-Info "Creating Gemini config directory: $geminiConfigDir" + New-Item -ItemType Directory -Force -Path $geminiConfigDir | Out-Null + } + + if (-not (Test-Path $settingsFile)) { + Write-Info "Creating settings.json template..." + @' +{ + "mcpServers": {} +} +'@ | Set-Content -Path $settingsFile -Encoding UTF8 + Write-Info "Settings created at $settingsFile" + Write-Info "Use 'apm install' to configure MCP servers" + } else { + Write-Info "Settings already exist at $settingsFile" + } +} + +function Test-GeminiInstallation { + Write-Info "Testing Gemini CLI installation..." + + $gemini = Get-Command gemini -ErrorAction SilentlyContinue + if ($gemini) { + try { + $version = gemini --version + Write-Success "Gemini CLI installed successfully! Version: $version" + } catch { + Write-WarningText "Gemini CLI binary found but version check failed" + } + } else { + Write-ErrorText "Gemini CLI not found in PATH after installation" + Write-Info "You may need to restart your terminal or check your npm global installation path" + exit 1 + } +} + +# Main setup +Write-Info "Setting up Google Gemini CLI runtime..." + +Test-NodeVersion +Test-NpmVersion +Install-GeminiCli + +if (-not $Vanilla) { + Initialize-GeminiDirectory +} else { + Write-Info "Vanilla mode: Skipping APM directory setup" + Write-Info "You can configure settings manually in ~/.gemini/settings.json" +} + +Test-GeminiInstallation + +Write-Host "" +Write-Info "Next steps:" +if (-not $Vanilla) { + Write-Host "1. Authenticate with Google:" + Write-Host " - Run 'gemini' and follow the browser login flow (free tier), or" + Write-Host " - Set GOOGLE_API_KEY for Gemini API key authentication, or" + Write-Host " - Set GOOGLE_GENAI_USE_VERTEXAI=true + GOOGLE_CLOUD_PROJECT for Vertex AI" + Write-Host "2. Set up your APM project with MCP dependencies:" + Write-Host " - Initialize project: apm init my-project" + Write-Host " - Install MCP servers: apm install" + Write-Host "3. Run: apm run start --param name=YourName" +} else { + Write-Host "1. Configure Gemini CLI manually" + Write-Host "2. Then run with APM: apm run start" +} diff --git a/scripts/runtime/setup-gemini.sh b/scripts/runtime/setup-gemini.sh new file mode 100755 index 000000000..9c74ba58b --- /dev/null +++ b/scripts/runtime/setup-gemini.sh @@ -0,0 +1,193 @@ +#!/bin/bash +# Setup script for Google Gemini CLI runtime +# Installs @google/gemini-cli with MCP configuration support + +set -euo pipefail + +# Get the directory of this script for sourcing common utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/setup-common.sh" + +# Configuration +GEMINI_PACKAGE="@google/gemini-cli" +VANILLA_MODE=false +NODE_MIN_VERSION="20" +NPM_MIN_VERSION="10" + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --vanilla) + VANILLA_MODE=true + shift + ;; + *) + shift + ;; + esac +done + +# Check Node.js version +check_node_version() { + log_info "Checking Node.js version..." + + if ! command -v node >/dev/null 2>&1; then + log_error "Node.js is not installed" + log_info "Please install Node.js version $NODE_MIN_VERSION or higher from https://nodejs.org/" + exit 1 + fi + + local node_version=$(node --version | sed 's/v//') + local node_major=$(echo "$node_version" | cut -d. -f1) + + if [[ "$node_major" -lt "$NODE_MIN_VERSION" ]]; then + log_error "Node.js version $node_version is too old. Required: v$NODE_MIN_VERSION or higher" + log_info "Please update Node.js from https://nodejs.org/" + exit 1 + fi + + log_success "Node.js version $node_version" +} + +# Check npm version +check_npm_version() { + log_info "Checking npm version..." + + if ! command -v npm >/dev/null 2>&1; then + log_error "npm is not installed" + log_info "Please install npm version $NPM_MIN_VERSION or higher" + exit 1 + fi + + local npm_version=$(npm --version) + local npm_major=$(echo "$npm_version" | cut -d. -f1) + + if [[ "$npm_major" -lt "$NPM_MIN_VERSION" ]]; then + log_error "npm version $npm_version is too old. Required: v$NPM_MIN_VERSION or higher" + log_info "Please update npm with: npm install -g npm@latest" + exit 1 + fi + + log_success "npm version $npm_version" +} + +# Install Gemini CLI via npm +install_gemini_cli() { + log_info "Installing Google Gemini CLI..." + + if npm install -g "$GEMINI_PACKAGE"; then + log_success "Successfully installed $GEMINI_PACKAGE" + else + log_error "Failed to install $GEMINI_PACKAGE" + log_info "This might be due to:" + log_info " - Insufficient permissions for global npm install (try with sudo)" + log_info " - Network connectivity issues" + log_info " - Node.js/npm version compatibility" + exit 1 + fi +} + +# Create Gemini CLI directory structure and config +setup_gemini_directory() { + log_info "Setting up Gemini CLI directory structure..." + + local gemini_config_dir="$HOME/.gemini" + local settings_file="$gemini_config_dir/settings.json" + + # Create config directory if it doesn't exist + if [[ ! -d "$gemini_config_dir" ]]; then + log_info "Creating Gemini config directory: $gemini_config_dir" + mkdir -p "$gemini_config_dir" + fi + + # Create empty settings.json with MCP section only if file doesn't exist + if [[ ! -f "$settings_file" ]]; then + log_info "Creating settings.json template..." + cat > "$settings_file" << 'EOF' +{ + "mcpServers": {} +} +EOF + log_info "Settings created at $settings_file" + log_info "Use 'apm install' to configure MCP servers" + else + log_info "Settings already exist at $settings_file" + fi +} + +# Test Gemini CLI installation +test_gemini_installation() { + log_info "Testing Gemini CLI installation..." + + if command -v gemini >/dev/null 2>&1; then + if gemini --version >/dev/null 2>&1; then + local version=$(gemini --version) + log_success "Gemini CLI installed successfully! Version: $version" + else + log_warning "Gemini CLI binary found but version check failed" + log_info "It may still work, but there might be configuration issues" + fi + else + log_error "Gemini CLI not found in PATH after installation" + log_info "You may need to restart your terminal or check your npm global installation path" + exit 1 + fi +} + +# Main setup function +setup_gemini() { + log_info "Setting up Google Gemini CLI runtime..." + + # Check prerequisites + check_node_version + check_npm_version + + # Install Gemini CLI + install_gemini_cli + + # Setup directory structure (unless vanilla mode) + if [[ "$VANILLA_MODE" == "false" ]]; then + setup_gemini_directory + else + log_info "Vanilla mode: Skipping APM directory setup" + log_info "You can configure settings manually in ~/.gemini/settings.json" + fi + + # Test installation + test_gemini_installation + + # Show next steps + echo "" + log_info "Next steps:" + + if [[ "$VANILLA_MODE" == "false" ]]; then + echo "1. Authenticate with Google:" + echo " - Run 'gemini' and follow the browser login flow (free tier), or" + echo " - Set GOOGLE_API_KEY for Gemini API key authentication, or" + echo " - Set GOOGLE_GENAI_USE_VERTEXAI=true + GOOGLE_CLOUD_PROJECT for Vertex AI" + echo "2. Set up your APM project with MCP dependencies:" + echo " - Initialize project: apm init my-project" + echo " - Install MCP servers: apm install" + echo "3. Then run: apm run start --param name=YourName" + echo "" + log_success "Gemini CLI installed and configured!" + echo " - Use 'apm install' to configure MCP servers for your projects" + echo " - Free tier: 60 requests/min with a personal Google account" + echo " - Interactive mode available: just run 'gemini'" + else + echo "1. Configure Gemini CLI as needed (run 'gemini' for interactive setup)" + echo "2. Then run with APM: apm run start" + fi + + echo "" + log_info "Gemini CLI Features:" + echo " - Interactive mode: gemini" + echo " - Sandboxed mode: gemini -s" + echo " - Custom model: gemini -m gemini-3.1-pro-preview" + echo " - Yolo mode (auto-approve): gemini -y" +} + +# Run setup if script is executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + setup_gemini "$@" +fi diff --git a/src/apm_cli/adapters/client/gemini.py b/src/apm_cli/adapters/client/gemini.py new file mode 100644 index 000000000..ed723b33c --- /dev/null +++ b/src/apm_cli/adapters/client/gemini.py @@ -0,0 +1,132 @@ +"""Gemini CLI implementation of MCP client adapter. + +Gemini CLI uses ``.gemini/settings.json`` at the project root with an +``mcpServers`` key. The schema is nearly identical to Copilot's: + +.. code-block:: json + + { + "mcpServers": { + "server-name": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-foo"], + "env": { "KEY": "value" } + } + } + } + +APM only writes to ``.gemini/settings.json`` when the ``.gemini/`` +directory already exists — Gemini CLI support is opt-in. + +Ref: https://geminicli.com/docs/reference/configuration/ +""" + +import json +import os +from pathlib import Path + +from .copilot import CopilotClientAdapter + + +class GeminiClientAdapter(CopilotClientAdapter): + """Gemini CLI MCP client adapter. + + Reuses Copilot's config formatting (``mcpServers`` schema is + compatible) and writes to ``.gemini/settings.json`` in the + project root. + """ + + supports_user_scope: bool = True + + def get_config_path(self): + """Return the path to ``.gemini/settings.json`` in the repository root.""" + return str(Path(os.getcwd()) / ".gemini" / "settings.json") + + def update_config(self, config_updates): + """Merge *config_updates* into the ``mcpServers`` section of settings.json. + + The ``.gemini/`` directory must already exist; if it does not, this + method returns silently (opt-in behaviour). + + Preserves all other top-level keys in settings.json (theme, tools, + hooks, etc.). + """ + gemini_dir = Path(os.getcwd()) / ".gemini" + if not gemini_dir.is_dir(): + return + + config_path = Path(self.get_config_path()) + current_config = self.get_current_config() + if "mcpServers" not in current_config: + current_config["mcpServers"] = {} + + for name, entry in config_updates.items(): + current_config["mcpServers"][name] = entry + + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + json.dump(current_config, f, indent=2) + + def get_current_config(self): + """Read the current ``.gemini/settings.json`` contents.""" + config_path = self.get_config_path() + if not os.path.exists(config_path): + return {} + try: + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + + def configure_mcp_server( + self, + server_url, + server_name=None, + enabled=True, + env_overrides=None, + server_info_cache=None, + runtime_vars=None, + ): + """Configure an MCP server in ``.gemini/settings.json``. + + Delegates to the parent for config formatting, then writes to + the Gemini CLI settings file. + """ + if not server_url: + print("Error: server_url cannot be empty") + return False + + gemini_dir = Path(os.getcwd()) / ".gemini" + if not gemini_dir.is_dir(): + return True + + try: + if server_info_cache and server_url in server_info_cache: + server_info = server_info_cache[server_url] + else: + server_info = self.registry_client.find_server_by_reference(server_url) + + if not server_info: + print(f"Error: MCP server '{server_url}' not found in registry") + return False + + if server_name: + config_key = server_name + elif "/" in server_url: + config_key = server_url.split("/")[-1] + else: + config_key = server_url + + server_config = self._format_server_config( + server_info, env_overrides, runtime_vars + ) + self.update_config({config_key: server_config}) + + print( + f"Successfully configured MCP server '{config_key}' for Gemini CLI" + ) + return True + + except Exception as e: + print(f"Error configuring MCP server: {e}") + return False diff --git a/src/apm_cli/commands/runtime.py b/src/apm_cli/commands/runtime.py index 40b0f01de..74b327ea5 100644 --- a/src/apm_cli/commands/runtime.py +++ b/src/apm_cli/commands/runtime.py @@ -23,7 +23,7 @@ def runtime(): @runtime.command(help="Set up a runtime") -@click.argument("runtime_name", type=click.Choice(["copilot", "codex", "llm"])) +@click.argument("runtime_name", type=click.Choice(["copilot", "codex", "llm", "gemini"])) @click.option("--version", help="Specific version to install") @click.option( "--vanilla", @@ -123,7 +123,7 @@ def list(): @runtime.command(help="Remove an installed runtime") -@click.argument("runtime_name", type=click.Choice(["copilot", "codex", "llm"])) +@click.argument("runtime_name", type=click.Choice(["copilot", "codex", "llm", "gemini"])) @click.confirmation_option(prompt="Are you sure you want to remove this runtime?", help="Confirm the action without prompting") def remove(runtime_name): """Remove an installed runtime from APM management.""" diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 4d76c6a22..bc1d26dce 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -27,7 +27,7 @@ # Kept in sync with target_detection.detect_target(). _VSCODE_TARGET_ALIASES = ("copilot", "agents") _KNOWN_TARGETS = ( - "vscode", "claude", "cursor", "opencode", "codex", "all", "minimal", + "vscode", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal", ) + _VSCODE_TARGET_ALIASES @@ -238,15 +238,11 @@ def compile(self, config: CompilationConfig, primitives: Optional[PrimitiveColle if should_compile_claude_md(routing_target): results.append(self._compile_claude_md(config, primitives)) - # Defensive: should never happen for a known target, but guards - # against future target_detection drift silently producing no-ops. + # Some targets (e.g. gemini, cursor) use the data-driven + # integration layer and don't need AGENTS.md/CLAUDE.md compilation. if not results: - self.errors.append( - f"Target {config.target!r} did not route to any compiler. " - "This is an internal bug in target routing." - ) return CompilationResult( - success=False, + success=True, output_path="", content="", warnings=self.warnings.copy(), diff --git a/src/apm_cli/core/script_runner.py b/src/apm_cli/core/script_runner.py index fcb919fd0..6dc3987a8 100644 --- a/src/apm_cli/core/script_runner.py +++ b/src/apm_cli/core/script_runner.py @@ -259,10 +259,10 @@ def _auto_compile_prompts( with open(compiled_path, "r", encoding="utf-8") as f: compiled_content = f.read().strip() - # Check if this is a runtime command (copilot, codex, llm) before transformation + # Check if this is a runtime command before transformation is_runtime_cmd = any( re.search(r"(?:^|\s)" + runtime + r"(?:\s|$)", command) - for runtime in ["copilot", "codex", "llm"] + for runtime in ["copilot", "codex", "llm", "gemini"] ) and re.search(re.escape(prompt_file), command) # Transform command based on runtime pattern @@ -292,7 +292,7 @@ def _transform_runtime_command( """ # Handle environment variables prefix (e.g., "ENV1=val1 ENV2=val2 codex [args] file.prompt.md") # More robust approach: split by runtime commands to separate env vars from command - runtime_commands = ["codex", "copilot", "llm"] + runtime_commands = ["codex", "copilot", "llm", "gemini"] for runtime_cmd in runtime_commands: runtime_pattern = f" {runtime_cmd} " @@ -327,13 +327,9 @@ def _transform_runtime_command( else: result = f"{env_vars} codex exec" else: - # For copilot and llm, keep the runtime name and args result = f"{env_vars} {runtime_cmd}" if args_before_file: - # Remove any existing -p flag since we'll handle it in execution - cleaned_args = args_before_file.replace( - "-p", "" - ).strip() + cleaned_args = re.sub(r'(^|\s)-p(?=\s|$)', '', args_before_file).strip() if cleaned_args: result += f" {cleaned_args}" @@ -370,8 +366,7 @@ def _transform_runtime_command( result = "copilot" if args_before_file: - # Remove any existing -p flag since we'll handle it in execution - cleaned_args = args_before_file.replace("-p", "").strip() + cleaned_args = re.sub(r'(^|\s)-p(?=\s|$)', '', args_before_file).strip() if cleaned_args: result += f" {cleaned_args}" if args_after_file: @@ -394,6 +389,24 @@ def _transform_runtime_command( result += f" {args_after_file}" return result + # Handle "gemini [args] file.prompt.md [more_args]" -> "gemini [args] [more_args]" + elif re.search(r"^gemini\s+.*" + re.escape(prompt_file), command): + match = re.search( + r"gemini\s+(.*?)(" + re.escape(prompt_file) + r")(.*?)$", command + ) + if match: + args_before_file = match.group(1).strip() + args_after_file = match.group(3).strip() + + result = "gemini" + if args_before_file: + cleaned_args = re.sub(r'(^|\s)-p(?=\s|$)', '', args_before_file).strip() + if cleaned_args: + result += f" {cleaned_args}" + if args_after_file: + result += f" {args_after_file}" + return result + # Handle bare "file.prompt.md" -> "codex exec" (default to codex) elif command.strip() == prompt_file: return "codex exec" @@ -408,7 +421,7 @@ def _detect_runtime(self, command: str) -> str: command: The command to analyze Returns: - Name of the detected runtime (copilot, codex, llm, or unknown) + Name of the detected runtime (copilot, codex, llm, gemini, or unknown) """ command_lower = command.lower().strip() if re.search(r"(?:^|\s)copilot(?:\s|$)", command_lower): @@ -417,6 +430,8 @@ def _detect_runtime(self, command: str) -> str: return "codex" elif re.search(r"(?:^|\s)llm(?:\s|$)", command_lower): return "llm" + elif re.search(r"(?:^|\s)gemini(?:\s|$)", command_lower): + return "gemini" else: return "unknown" @@ -472,6 +487,9 @@ def _execute_runtime_command( elif runtime == "llm": # LLM expects content as argument actual_command_args.append(content) + elif runtime == "gemini": + # Gemini uses -p flag for prompt content + actual_command_args.extend(["-p", content]) else: # Default: assume content as last argument actual_command_args.append(content) @@ -847,7 +865,7 @@ def _create_minimal_config(self) -> None: def _detect_installed_runtime(self) -> str: """Detect installed runtime with priority order. - Priority: copilot > codex > error + Priority: copilot > codex > gemini > error Returns: Name of detected runtime @@ -857,18 +875,21 @@ def _detect_installed_runtime(self) -> str: """ import shutil - # Priority order: copilot first (recommended), then codex if shutil.which("copilot"): return "copilot" elif shutil.which("codex"): return "codex" + elif shutil.which("gemini"): + return "gemini" else: raise RuntimeError( "No compatible runtime found.\n" "Install GitHub Copilot CLI with:\n" " apm runtime setup copilot\n" "Or install Codex CLI with:\n" - " apm runtime setup codex" + " apm runtime setup codex\n" + "Or install Gemini CLI with:\n" + " apm runtime setup gemini" ) def _generate_runtime_command(self, runtime: str, prompt_file: Path) -> str: @@ -882,11 +903,11 @@ def _generate_runtime_command(self, runtime: str, prompt_file: Path) -> str: Full command string with runtime-specific defaults """ if runtime == "copilot": - # GitHub Copilot CLI with all recommended flags return f"copilot --log-level all --log-dir copilot-logs --allow-all-tools -p {prompt_file}" elif runtime == "codex": - # Codex CLI with default sandbox and git repo check skip return f"codex -s workspace-write --skip-git-repo-check {prompt_file}" + elif runtime == "gemini": + return f"gemini -p {prompt_file}" else: raise ValueError(f"Unsupported runtime: {runtime}") diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 85a991bd7..95cdd9796 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -1,8 +1,8 @@ """Target detection for auto-selecting compilation and integration targets. This module implements the auto-detection pattern for determining which agent -targets (Copilot, Claude, Cursor, OpenCode, Codex) should be used based on -existing project structure and configuration. +targets (Copilot, Claude, Cursor, OpenCode, Codex, Gemini) should be used +based on existing project structure and configuration. Detection priority (highest to lowest): 1. Explicit --target flag (always wins) @@ -13,6 +13,7 @@ - .cursor/ only -> cursor - .opencode/ only -> opencode - .codex/ only -> codex + - .gemini/ only -> gemini - Multiple target folders -> all - None exist -> minimal (AGENTS.md only, no folder integration) @@ -26,10 +27,10 @@ import click # Valid target values (internal canonical form) -TargetType = Literal["vscode", "claude", "cursor", "opencode", "codex", "all", "minimal"] +TargetType = Literal["vscode", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal"] # User-facing target values (includes aliases accepted by CLI) -UserTargetType = Literal["copilot", "vscode", "agents", "claude", "cursor", "opencode", "codex", "all", "minimal"] +UserTargetType = Literal["copilot", "vscode", "agents", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal"] def detect_target( @@ -61,9 +62,11 @@ def detect_target( return "opencode", "explicit --target flag" elif explicit_target == "codex": return "codex", "explicit --target flag" + elif explicit_target == "gemini": + return "gemini", "explicit --target flag" elif explicit_target == "all": return "all", "explicit --target flag" - + # Priority 2: apm.yml target setting if config_target: if config_target in ("copilot", "vscode", "agents"): @@ -76,6 +79,8 @@ def detect_target( return "opencode", "apm.yml target" elif config_target == "codex": return "codex", "apm.yml target" + elif config_target == "gemini": + return "gemini", "apm.yml target" elif config_target == "all": return "all", "apm.yml target" @@ -85,6 +90,7 @@ def detect_target( cursor_exists = (project_root / ".cursor").is_dir() opencode_exists = (project_root / ".opencode").is_dir() codex_exists = (project_root / ".codex").is_dir() + gemini_exists = (project_root / ".gemini").is_dir() detected = [] if github_exists: detected.append(".github/") @@ -96,6 +102,8 @@ def detect_target( detected.append(".opencode/") if codex_exists: detected.append(".codex/") + if gemini_exists: + detected.append(".gemini/") if len(detected) >= 2: return "all", f"detected {' and '.join(detected)} folders" @@ -109,9 +117,10 @@ def detect_target( return "opencode", "detected .opencode/ folder" elif codex_exists: return "codex", "detected .codex/ folder" + elif gemini_exists: + return "gemini", "detected .gemini/ folder" else: - # No known target folders exist - minimal output - return "minimal", "no .github/, .claude/, .cursor/, .opencode/, or .codex/ folder found" + return "minimal", "no target folder found" def should_integrate_vscode(target: TargetType) -> bool: @@ -174,6 +183,18 @@ def should_integrate_codex(target: TargetType) -> bool: return target in ("codex", "all") +def should_integrate_gemini(target: TargetType) -> bool: + """Check if Gemini CLI integration should be performed. + + Args: + target: The detected or configured target + + Returns: + bool: True if Gemini integration (commands, rules, skills) should run + """ + return target in ("gemini", "all") + + def should_compile_agents_md(target: TargetType) -> bool: """Check if AGENTS.md should be compiled. @@ -220,8 +241,9 @@ def get_target_description(target: UserTargetType) -> str: "cursor": ".cursor/agents/ + .cursor/skills/ + .cursor/rules/", "opencode": "AGENTS.md + .opencode/agents/ + .opencode/commands/ + .opencode/skills/", "codex": "AGENTS.md + .agents/skills/ + .codex/agents/ + .codex/hooks.json", - "all": "AGENTS.md + CLAUDE.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .agents/", - "minimal": "AGENTS.md only (create .github/ or .claude/ for full integration)", + "gemini": ".gemini/commands/ + .gemini/rules/ + .gemini/skills/", + "all": "AGENTS.md + CLAUDE.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .agents/", + "minimal": "AGENTS.md only (create a target folder for full integration)", } return descriptions.get(normalized, "unknown target") @@ -232,7 +254,7 @@ def get_target_description(target: UserTargetType) -> str: #: The complete set of real (non-pseudo) canonical targets. #: "minimal" is intentionally excluded -- it is a fallback pseudo-target. -ALL_CANONICAL_TARGETS = frozenset({"vscode", "claude", "cursor", "opencode", "codex"}) +ALL_CANONICAL_TARGETS = frozenset({"vscode", "claude", "cursor", "opencode", "codex", "gemini"}) #: Alias mapping: user-facing name -> canonical internal name. TARGET_ALIASES: dict[str, str] = { diff --git a/src/apm_cli/factory.py b/src/apm_cli/factory.py index 4f6295a86..650750a7d 100644 --- a/src/apm_cli/factory.py +++ b/src/apm_cli/factory.py @@ -4,6 +4,7 @@ from .adapters.client.codex import CodexClientAdapter from .adapters.client.copilot import CopilotClientAdapter from .adapters.client.cursor import CursorClientAdapter +from .adapters.client.gemini import GeminiClientAdapter from .adapters.client.opencode import OpenCodeClientAdapter from .adapters.package_manager.default_manager import DefaultMCPPackageManager @@ -29,6 +30,7 @@ def create_client(client_type): "vscode": VSCodeClientAdapter, "codex": CodexClientAdapter, "cursor": CursorClientAdapter, + "gemini": GeminiClientAdapter, "opencode": OpenCodeClientAdapter, # Add more clients as needed } diff --git a/src/apm_cli/integration/command_integrator.py b/src/apm_cli/integration/command_integrator.py index be8be4691..f7e45c1cf 100644 --- a/src/apm_cli/integration/command_integrator.py +++ b/src/apm_cli/integration/command_integrator.py @@ -6,6 +6,7 @@ from __future__ import annotations +import re from pathlib import Path from typing import TYPE_CHECKING, Dict, List import frontmatter @@ -164,9 +165,13 @@ def integrate_commands_for_target( files_skipped += 1 continue - links_resolved = self.integrate_command( - prompt_file, target_path, package_info, prompt_file, - ) + if mapping.format_id == "gemini_command": + self._write_gemini_command(prompt_file, target_path) + links_resolved = 0 + else: + links_resolved = self.integrate_command( + prompt_file, target_path, package_info, prompt_file, + ) files_integrated += 1 total_links_resolved += links_resolved target_paths.append(target_path) @@ -202,6 +207,38 @@ def sync_for_target( targets=[target], ) + # ------------------------------------------------------------------ + # Gemini CLI Commands (.toml format) + # ------------------------------------------------------------------ + + @staticmethod + def _write_gemini_command(source: Path, target: Path) -> None: + """Transform a ``.prompt.md`` file to Gemini CLI ``.toml`` format. + + Parses YAML frontmatter for ``description``, uses the markdown + body as the ``prompt`` field. Replaces ``$ARGUMENTS`` with + ``{{args}}`` (Gemini CLI's argument interpolation syntax). + + Ref: https://geminicli.com/docs/cli/gemini-md/ + """ + import toml as _toml + + post = frontmatter.load(source) + + description = post.metadata.get("description", "") + prompt_text = post.content.strip() + prompt_text = prompt_text.replace("$ARGUMENTS", "{{args}}") + + if re.search(r'(? globs:) * Claude Code: ``.claude/rules/`` (``.md`` format, applyTo: -> paths:) + * Gemini CLI: ``.gemini/rules/`` (``.md`` format, frontmatter stripped) """ def find_instruction_files(self, package_path: Path) -> List[Path]: @@ -71,6 +72,7 @@ def integrate_instructions_for_target( * ``cursor_rules`` -- convert ``applyTo:`` to ``globs:`` frontmatter * ``claude_rules`` -- convert ``applyTo:`` to ``paths:`` frontmatter + * ``gemini_rules`` -- strip frontmatter (Gemini CLI has no path-scoping) * anything else -- copy verbatim (identity transform) """ mapping = target.primitives.get("instructions") @@ -91,7 +93,7 @@ def integrate_instructions_for_target( deploy_dir.mkdir(parents=True, exist_ok=True) fmt = mapping.format_id - needs_rename = fmt in ("cursor_rules", "claude_rules") + needs_rename = fmt in ("cursor_rules", "claude_rules", "gemini_rules") files_integrated = 0 files_skipped = 0 @@ -121,6 +123,8 @@ def integrate_instructions_for_target( links_resolved = self.copy_instruction_cursor(source_file, target_path) elif fmt == "claude_rules": links_resolved = self.copy_instruction_claude(source_file, target_path) + elif fmt == "gemini_rules": + links_resolved = self.copy_instruction_gemini(source_file, target_path) else: links_resolved = self.copy_instruction(source_file, target_path) @@ -152,9 +156,9 @@ def sync_for_target( legacy_dir = project_root / effective_root / mapping.subdir if mapping.format_id == "cursor_rules": legacy_pattern = "*.mdc" - elif mapping.format_id == "claude_rules": - # Do not use a broad legacy glob for Claude rules to avoid - # deleting user-authored .md files under .claude/rules/. + elif mapping.format_id in ("claude_rules", "gemini_rules"): + # Do not use a broad legacy glob for Claude/Gemini rules to avoid + # deleting user-authored .md files under .claude/rules/ or .gemini/rules/. legacy_pattern = None else: legacy_pattern = "*.instructions.md" @@ -382,3 +386,36 @@ def sync_integration_claude( KNOWN_TARGETS["claude"], apm_package, project_root, managed_files=managed_files, ) + + # ------------------------------------------------------------------ + # Gemini CLI Rules (.md, frontmatter stripped) + # ------------------------------------------------------------------ + + @staticmethod + def _convert_to_gemini_rules(content: str) -> str: + """Convert APM instruction content to Gemini CLI rules ``.md`` format. + + Strips APM-specific frontmatter (``applyTo``, ``description``, + etc.) since Gemini CLI has no path-scoping mechanism. Returns + the body as clean Markdown. + + Ref: https://geminicli.com/docs/cli/gemini-md/ + """ + body = content + + fm_match = re.match(r'^---\s*\n(.*?\n)?---\s*\n?', content, re.DOTALL) + if fm_match: + body = content[fm_match.end():] + + return body.lstrip("\n") + + def copy_instruction_gemini(self, source: Path, target: Path) -> int: + """Copy instruction file converted to Gemini CLI rules format. + + Strips frontmatter and resolves links. + """ + content = source.read_text(encoding='utf-8') + content = self._convert_to_gemini_rules(content) + content, links_resolved = self.resolve_links(content, source, target) + target.write_text(content, encoding='utf-8') + return links_resolved diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 85e54c1fa..5da240790 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -462,7 +462,7 @@ def remove_stale( return # Determine which runtimes to clean, mirroring install-time logic. - all_runtimes = {"vscode", "copilot", "codex", "cursor", "opencode"} + all_runtimes = {"vscode", "copilot", "codex", "cursor", "opencode", "gemini"} if runtime: target_runtimes = {runtime} else: @@ -631,6 +631,37 @@ def remove_stale( exc_info=True, ) + # Clean .gemini/settings.json (only if .gemini/ directory exists) + if "gemini" in target_runtimes: + gemini_cfg = Path.cwd() / ".gemini" / "settings.json" + if gemini_cfg.exists(): + try: + import json as _json + + config = _json.loads(gemini_cfg.read_text(encoding="utf-8")) + servers = config.get("mcpServers", {}) + removed = [n for n in expanded_stale if n in servers] + for name in removed: + del servers[name] + if removed: + gemini_cfg.write_text( + _json.dumps(config, indent=2), encoding="utf-8" + ) + for name in removed: + if logger: + logger.progress( + f"Removed stale MCP server '{name}' from .gemini/settings.json" + ) + else: + _rich_info( + f"+ Removed stale MCP server '{name}' from .gemini/settings.json" + ) + except Exception: + _log.debug( + "Failed to clean stale MCP servers from .gemini/settings.json", + exc_info=True, + ) + # ------------------------------------------------------------------ # Lockfile persistence # ------------------------------------------------------------------ @@ -687,6 +718,8 @@ def _detect_runtimes(scripts: dict) -> List[str]: detected.add("copilot") if re.search(r"\bcodex\b", command): detected.add("codex") + if re.search(r"\bgemini\b", command): + detected.add("gemini") if re.search(r"\bllm\b", command): detected.add("llm") @@ -722,7 +755,7 @@ def _filter_runtimes(detected_runtimes: List[str]) -> List[str]: except ImportError: mcp_compatible = [ - rt for rt in detected_runtimes if rt in ["vscode", "copilot", "cursor", "opencode"] + rt for rt in detected_runtimes if rt in ["vscode", "copilot", "cursor", "opencode", "gemini"] ] return [rt for rt in mcp_compatible if shutil.which(rt)] @@ -795,10 +828,10 @@ def _install_for_runtime( except ValueError as e: if logger: logger.warning(f"Runtime {runtime} not supported: {e}") - logger.progress("Supported runtimes: vscode, copilot, codex, cursor, opencode, llm") + logger.progress("Supported runtimes: vscode, copilot, codex, cursor, opencode, gemini, llm") else: _rich_warning(f"Runtime {runtime} not supported: {e}") - _rich_info("Supported runtimes: vscode, copilot, codex, cursor, opencode, llm") + _rich_info("Supported runtimes: vscode, copilot, codex, cursor, opencode, gemini, llm") return False except Exception as e: _log.debug( @@ -930,7 +963,7 @@ def install( manager = RuntimeManager() installed_runtimes = [] - for runtime_name in ["copilot", "codex", "vscode", "cursor", "opencode"]: + for runtime_name in ["copilot", "codex", "vscode", "cursor", "opencode", "gemini"]: try: if runtime_name == "vscode": if _is_vscode_available(): @@ -946,6 +979,11 @@ def install( if (Path.cwd() / ".opencode").is_dir(): ClientFactory.create_client(runtime_name) installed_runtimes.append(runtime_name) + elif runtime_name == "gemini": + # Gemini CLI is opt-in: only target when .gemini/ exists + if (Path.cwd() / ".gemini").is_dir(): + ClientFactory.create_client(runtime_name) + installed_runtimes.append(runtime_name) else: if manager.is_runtime_available(runtime_name): ClientFactory.create_client(runtime_name) @@ -967,6 +1005,9 @@ def install( # OpenCode is directory-presence based if (Path.cwd() / ".opencode").is_dir(): installed_runtimes.append("opencode") + # Gemini CLI is directory-presence based + if (Path.cwd() / ".gemini").is_dir(): + installed_runtimes.append("gemini") # Step 2: Get runtimes referenced in apm.yml scripts script_runtimes = MCPIntegrator._detect_runtimes( diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index 22a8eac74..a2b9602f2 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -260,6 +260,35 @@ def for_scope(self, user_scope: bool = False) -> "TargetProfile | None": user_root_dir=".config/opencode", unsupported_user_primitives=("hooks",), ), + # Gemini CLI -- ~/.gemini/ is the documented user-level config directory. + # Instructions deploy as individual .md files to .gemini/rules/; gemini-cli's + # JIT directory discovery or @import syntax picks them up from GEMINI.md. + # Commands are TOML files under .gemini/commands/. + # Hooks merge into .gemini/settings.json (same pattern as Claude Code). + # Ref: https://geminicli.com/docs/cli/gemini-md/ + # Ref: https://geminicli.com/docs/reference/configuration/ + "gemini": TargetProfile( + name="gemini", + root_dir=".gemini", + primitives={ + "instructions": PrimitiveMapping( + "rules", ".md", "gemini_rules" + ), + "commands": PrimitiveMapping( + "commands", ".toml", "gemini_command" + ), + "skills": PrimitiveMapping( + "skills", "/SKILL.md", "skill_standard" + ), + "hooks": PrimitiveMapping( + "hooks", ".json", "gemini_hooks" + ), + }, + auto_create=False, + detect_by_dir=True, + user_supported=True, + user_root_dir=".gemini", + ), # Codex CLI: skills use the cross-tool .agents/ dir (agent skills standard), # agents are TOML under .codex/agents/, hooks merge into .codex/hooks.json. # Instructions are compile-only (AGENTS.md) -- not installed. diff --git a/src/apm_cli/runtime/manager.py b/src/apm_cli/runtime/manager.py index d6b13c624..479a01468 100644 --- a/src/apm_cli/runtime/manager.py +++ b/src/apm_cli/runtime/manager.py @@ -38,9 +38,14 @@ def __init__(self): "binary": "codex" }, "llm": { - "script": f"setup-llm{ext}", + "script": f"setup-llm{ext}", "description": "Simon Willison's LLM library with multiple providers", "binary": "llm" + }, + "gemini": { + "script": f"setup-gemini{ext}", + "description": "Google Gemini CLI with MCP integration", + "binary": "gemini" } } @@ -290,11 +295,15 @@ def remove_runtime(self, runtime_name: str) -> bool: click.echo(f"{Fore.RED}[x] Unknown runtime: {runtime_name}{Style.RESET_ALL}", err=True) return False - # Handle copilot runtime (npm-based, global install) - if runtime_name == "copilot": + # Handle npm-based runtimes (copilot, gemini) + _npm_packages = { + "copilot": "@github/copilot", + "gemini": "@google/gemini-cli", + } + if runtime_name in _npm_packages: try: result = subprocess.run( - ["npm", "uninstall", "-g", "@github/copilot"], + ["npm", "uninstall", "-g", _npm_packages[runtime_name]], capture_output=True, text=True, encoding="utf-8", @@ -338,7 +347,7 @@ def remove_runtime(self, runtime_name: str) -> bool: def get_runtime_preference(self) -> List[str]: """Get the runtime preference order.""" - return ["copilot", "codex", "llm"] + return ["copilot", "codex", "gemini", "llm"] def get_available_runtime(self) -> Optional[str]: """Get the first available runtime based on preference.""" diff --git a/tests/integration/test_gemini_integration.py b/tests/integration/test_gemini_integration.py new file mode 100644 index 000000000..7beeba264 --- /dev/null +++ b/tests/integration/test_gemini_integration.py @@ -0,0 +1,397 @@ +"""Offline integration tests for Gemini CLI target. + +Verifies that ``apm install`` correctly deploys skills, commands, +instructions, and MCP config to ``.gemini/`` without requiring +network access or API tokens. +""" + +import json +import shutil +import tempfile +from datetime import datetime +from pathlib import Path + +import pytest +import toml + +from apm_cli.adapters.client.gemini import GeminiClientAdapter +from apm_cli.integration import ( + KNOWN_TARGETS, + InstructionIntegrator, + PromptIntegrator, + SkillIntegrator, +) +from apm_cli.integration.command_integrator import CommandIntegrator +from apm_cli.models.apm_package import ( + APMPackage, + GitReferenceType, + PackageInfo, + PackageType, + ResolvedReference, +) + + +def _make_package_info( + package_dir: Path, + name: str = "test-pkg", + package_type: PackageType = None, +) -> PackageInfo: + """Build a minimal ``PackageInfo`` for offline tests.""" + package = APMPackage(name=name, version="1.0.0", package_path=package_dir) + resolved_ref = ResolvedReference( + original_ref="main", + ref_type=GitReferenceType.BRANCH, + resolved_commit="abc123", + ref_name="main", + ) + return PackageInfo( + package=package, + install_path=package_dir, + resolved_reference=resolved_ref, + installed_at=datetime.now().isoformat(), + package_type=package_type, + ) + + +@pytest.mark.integration +class TestGeminiCommandIntegration: + """Commands: .prompt.md -> .gemini/commands/*.toml""" + + def setup_method(self): + self.tmp = tempfile.mkdtemp() + self.root = Path(self.tmp) + (self.root / ".gemini").mkdir() + + def teardown_method(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _create_prompt(self, name: str, description: str, body: str) -> Path: + pkg = self.root / "apm_modules" / "test-pkg" + pkg.mkdir(parents=True, exist_ok=True) + (pkg / "apm.yml").write_text("name: test-pkg\nversion: 1.0.0\n") + prompt = pkg / f"{name}.prompt.md" + prompt.write_text(f"---\ndescription: {description}\n---\n{body}\n") + return pkg + + def test_deploys_toml_with_prompt_and_description(self): + pkg = self._create_prompt("greet", "Say hello", "Hello $ARGUMENTS") + info = _make_package_info(pkg) + target = KNOWN_TARGETS["gemini"] + + result = CommandIntegrator().integrate_commands_for_target( + target, info, self.root + ) + + assert result.files_integrated == 1 + toml_path = self.root / ".gemini" / "commands" / "greet.toml" + assert toml_path.exists() + + doc = toml.loads(toml_path.read_text()) + assert "prompt" in doc + assert "description" in doc + assert doc["description"] == "Say hello" + assert "{{args}}" in doc["prompt"] + assert "$ARGUMENTS" not in doc["prompt"] + + def test_positional_args_get_args_prefix(self): + pkg = self._create_prompt("run", "Run stuff", "Do $1 then $2") + info = _make_package_info(pkg) + target = KNOWN_TARGETS["gemini"] + + CommandIntegrator().integrate_commands_for_target(target, info, self.root) + + doc = toml.loads( + (self.root / ".gemini" / "commands" / "run.toml").read_text() + ) + assert doc["prompt"].startswith("Arguments: {{args}}") + + def test_no_description_omits_key(self): + pkg = self.root / "apm_modules" / "test-pkg" + pkg.mkdir(parents=True, exist_ok=True) + (pkg / "apm.yml").write_text("name: test-pkg\nversion: 1.0.0\n") + prompt = pkg / "bare.prompt.md" + prompt.write_text("Just a prompt body\n") + info = _make_package_info(pkg) + target = KNOWN_TARGETS["gemini"] + + CommandIntegrator().integrate_commands_for_target(target, info, self.root) + + doc = toml.loads( + (self.root / ".gemini" / "commands" / "bare.toml").read_text() + ) + assert "prompt" in doc + assert "description" not in doc + + +@pytest.mark.integration +class TestGeminiInstructionIntegration: + """Instructions: .instructions.md -> .gemini/rules/*.md (frontmatter stripped)""" + + def setup_method(self): + self.tmp = tempfile.mkdtemp() + self.root = Path(self.tmp) + (self.root / ".gemini").mkdir() + + def teardown_method(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _create_instruction(self, name: str, apply_to: str, body: str) -> Path: + pkg = self.root / "apm_modules" / "test-pkg" + pkg.mkdir(parents=True, exist_ok=True) + (pkg / "apm.yml").write_text("name: test-pkg\nversion: 1.0.0\n") + inst_dir = pkg / ".apm" / "instructions" + inst_dir.mkdir(parents=True, exist_ok=True) + inst = inst_dir / f"{name}.instructions.md" + inst.write_text( + f"---\napplyTo: '{apply_to}'\ndescription: test rule\n---\n{body}\n" + ) + return pkg + + def test_deploys_md_with_frontmatter_stripped(self): + body = "Always use snake_case for variables." + pkg = self._create_instruction("naming", "**/*.py", body) + info = _make_package_info(pkg) + target = KNOWN_TARGETS["gemini"] + + result = InstructionIntegrator().integrate_instructions_for_target( + target, info, self.root + ) + + assert result.files_integrated == 1 + rule_path = self.root / ".gemini" / "rules" / "naming.md" + assert rule_path.exists() + + content = rule_path.read_text() + assert "---" not in content + assert "applyTo" not in content + assert body in content + + def test_body_content_preserved(self): + body = "## Heading\n\n- bullet one\n- bullet two\n\nParagraph." + pkg = self._create_instruction("style", "**/*.ts", body) + info = _make_package_info(pkg) + target = KNOWN_TARGETS["gemini"] + + InstructionIntegrator().integrate_instructions_for_target( + target, info, self.root + ) + + content = (self.root / ".gemini" / "rules" / "style.md").read_text() + assert "## Heading" in content + assert "- bullet one" in content + assert "Paragraph." in content + + +@pytest.mark.integration +class TestGeminiSkillIntegration: + """Skills: package dir -> .gemini/skills/{name}/SKILL.md (verbatim copy)""" + + def setup_method(self): + self.tmp = tempfile.mkdtemp() + self.root = Path(self.tmp) + (self.root / ".gemini").mkdir() + + def teardown_method(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_deploys_skill_verbatim(self): + skill_content = "# My Skill\n\nDo something useful." + pkg = self.root / "apm_modules" / "my-skill" + pkg.mkdir(parents=True, exist_ok=True) + (pkg / "apm.yml").write_text( + "name: my-skill\nversion: 1.0.0\ntype: skill\n" + ) + (pkg / "SKILL.md").write_text(skill_content) + + info = _make_package_info(pkg, name="my-skill", package_type=PackageType.HYBRID) + target = KNOWN_TARGETS["gemini"] + + result = SkillIntegrator().integrate_package_skill( + info, self.root, targets=[target] + ) + + assert result.skill_created + skill_md = self.root / ".gemini" / "skills" / "my-skill" / "SKILL.md" + assert skill_md.exists() + assert skill_md.read_text() == skill_content + + +@pytest.mark.integration +class TestGeminiMCPIntegration: + """MCP: update_config merges into .gemini/settings.json preserving other keys.""" + + def setup_method(self): + self.tmp = tempfile.mkdtemp() + self.root = Path(self.tmp) + self.gemini_dir = self.root / ".gemini" + self.gemini_dir.mkdir() + + def teardown_method(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_adds_server_preserving_existing_keys(self, monkeypatch): + monkeypatch.chdir(self.root) + + settings = self.gemini_dir / "settings.json" + settings.write_text(json.dumps({ + "mcpServers": {}, + "theme": "dark", + "tools": {"enabled": True}, + })) + + adapter = GeminiClientAdapter.__new__(GeminiClientAdapter) + adapter.update_config({ + "my-server": { + "command": "npx", + "args": ["-y", "@mcp/test-server"], + "env": {"KEY": "val"}, + } + }) + + result = json.loads(settings.read_text()) + assert "my-server" in result["mcpServers"] + assert result["mcpServers"]["my-server"]["command"] == "npx" + assert result["theme"] == "dark" + assert result["tools"] == {"enabled": True} + + def test_creates_mcp_servers_key_if_missing(self, monkeypatch): + monkeypatch.chdir(self.root) + + settings = self.gemini_dir / "settings.json" + settings.write_text(json.dumps({"theme": "light"})) + + adapter = GeminiClientAdapter.__new__(GeminiClientAdapter) + adapter.update_config({"srv": {"command": "echo"}}) + + result = json.loads(settings.read_text()) + assert "mcpServers" in result + assert "srv" in result["mcpServers"] + assert result["theme"] == "light" + + +@pytest.mark.integration +class TestGeminiOptInBehavior: + """Gemini target is opt-in: nothing deployed when .gemini/ doesn't exist.""" + + def setup_method(self): + self.tmp = tempfile.mkdtemp() + self.root = Path(self.tmp) + + def teardown_method(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _create_package_with_content(self) -> Path: + pkg = self.root / "apm_modules" / "test-pkg" + pkg.mkdir(parents=True, exist_ok=True) + (pkg / "apm.yml").write_text("name: test-pkg\nversion: 1.0.0\n") + (pkg / "hello.prompt.md").write_text("---\ndescription: hi\n---\nHello\n") + inst_dir = pkg / ".apm" / "instructions" + inst_dir.mkdir(parents=True, exist_ok=True) + (inst_dir / "rule.instructions.md").write_text( + "---\napplyTo: '**/*.py'\n---\nBe nice.\n" + ) + return pkg + + def test_commands_not_deployed_without_gemini_dir(self): + pkg = self._create_package_with_content() + info = _make_package_info(pkg) + target = KNOWN_TARGETS["gemini"] + + result = CommandIntegrator().integrate_commands_for_target( + target, info, self.root + ) + + assert result.files_integrated == 0 + assert not (self.root / ".gemini").exists() + + def test_instructions_not_deployed_without_gemini_dir(self): + pkg = self._create_package_with_content() + info = _make_package_info(pkg) + target = KNOWN_TARGETS["gemini"] + + result = InstructionIntegrator().integrate_instructions_for_target( + target, info, self.root + ) + + assert result.files_integrated == 0 + assert not (self.root / ".gemini").exists() + + def test_mcp_update_noop_without_gemini_dir(self, monkeypatch): + monkeypatch.chdir(self.root) + + adapter = GeminiClientAdapter.__new__(GeminiClientAdapter) + adapter.update_config({"srv": {"command": "echo"}}) + + assert not (self.root / ".gemini").exists() + + +@pytest.mark.integration +class TestGeminiMultiTargetCoexistence: + """Both .github/ and .gemini/ present: files deploy to each target.""" + + def setup_method(self): + self.tmp = tempfile.mkdtemp() + self.root = Path(self.tmp) + (self.root / ".github").mkdir() + (self.root / ".gemini").mkdir() + + def teardown_method(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _create_full_package(self) -> Path: + pkg = self.root / "apm_modules" / "test-pkg" + pkg.mkdir(parents=True, exist_ok=True) + (pkg / "apm.yml").write_text("name: test-pkg\nversion: 1.0.0\n") + (pkg / "review.prompt.md").write_text( + "---\ndescription: Code review\n---\nReview $ARGUMENTS\n" + ) + inst_dir = pkg / ".apm" / "instructions" + inst_dir.mkdir(parents=True, exist_ok=True) + (inst_dir / "style.instructions.md").write_text( + "---\napplyTo: '**/*.py'\ndescription: Style guide\n---\nUse black.\n" + ) + return pkg + + def test_prompts_deployed_to_both_targets(self): + pkg = self._create_full_package() + info = _make_package_info(pkg) + copilot = KNOWN_TARGETS["copilot"] + gemini = KNOWN_TARGETS["gemini"] + + r_copilot = PromptIntegrator().integrate_prompts_for_target( + copilot, info, self.root + ) + r_gemini = CommandIntegrator().integrate_commands_for_target( + gemini, info, self.root + ) + + assert r_copilot.files_integrated == 1 + assert r_gemini.files_integrated == 1 + + assert (self.root / ".github" / "prompts" / "review.prompt.md").exists() + assert (self.root / ".gemini" / "commands" / "review.toml").exists() + + def test_instructions_transformed_differently_per_target(self): + pkg = self._create_full_package() + info = _make_package_info(pkg) + copilot = KNOWN_TARGETS["copilot"] + gemini = KNOWN_TARGETS["gemini"] + + inst = InstructionIntegrator() + inst.integrate_instructions_for_target(copilot, info, self.root) + inst.integrate_instructions_for_target(gemini, info, self.root) + + copilot_path = ( + self.root / ".github" / "instructions" / "style.instructions.md" + ) + gemini_path = self.root / ".gemini" / "rules" / "style.md" + assert copilot_path.exists() + assert gemini_path.exists() + + copilot_content = copilot_path.read_text() + gemini_content = gemini_path.read_text() + + assert "applyTo" in copilot_content + assert "applyTo" not in gemini_content + assert "Use black." in copilot_content + assert "Use black." in gemini_content diff --git a/tests/integration/test_golden_scenario_e2e.py b/tests/integration/test_golden_scenario_e2e.py index da45960f6..b5a6990d2 100644 --- a/tests/integration/test_golden_scenario_e2e.py +++ b/tests/integration/test_golden_scenario_e2e.py @@ -108,9 +108,20 @@ def temp_e2e_home(): test_home = os.path.join(temp_dir, 'e2e_home') os.makedirs(test_home) - # Set up test environment + # Set up test environment — stash original HOME so tests can + # recover credentials (e.g. ADC at ~/.config/gcloud/). + os.environ['_APM_ORIGINAL_HOME'] = original_home or '' os.environ['HOME'] = test_home - + + # Copy gcloud ADC credentials into the fake home so runtimes + # that rely on ADC (e.g. gemini-cli with Vertex AI) can auth. + real_gcloud = Path(original_home or '') / ".config" / "gcloud" + if real_gcloud.is_dir(): + fake_gcloud = Path(test_home) / ".config" / "gcloud" + fake_gcloud.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(str(real_gcloud), str(fake_gcloud)) + + # Note: Do NOT override token environment variables here # Let test-integration.sh handle token management properly # It has the correct prioritization: GITHUB_APM_PAT > GITHUB_TOKEN @@ -118,6 +129,7 @@ def temp_e2e_home(): yield test_home # Restore original environment + os.environ.pop('_APM_ORIGINAL_HOME', None) if original_home: os.environ['HOME'] = original_home else: @@ -645,6 +657,151 @@ def test_complete_golden_scenario_llm(self, temp_e2e_home, apm_binary): print(f"Output: {full_output}") pytest.skip("LLM execution failed, likely due to authentication in CI environment") + @pytest.mark.skipif(not PRIMARY_TOKEN, reason="GitHub token (GITHUB_APM_PAT or GITHUB_TOKEN) required for E2E tests") + def test_complete_golden_scenario_gemini(self, temp_e2e_home, apm_binary): + """Test the complete golden scenario using Gemini CLI runtime. + + This test uses the 'review' script which calls Gemini CLI. + Gemini CLI authenticates via Google account (browser flow) or + GOOGLE_API_KEY, so this test will gracefully skip on auth failure. + """ + + # Step 1: Setup Gemini runtime (npm install -g @google/gemini-cli) + print("\n=== Setting up Gemini CLI runtime ===") + result = run_command(f"{apm_binary} runtime setup gemini", timeout=300, show_output=True) + assert result.returncode == 0, f"Gemini runtime setup failed: {result.stderr}" + + # Verify gemini is available (npm global install, not in ~/.apm/runtimes) + print("\n=== Testing Gemini CLI binary ===") + result = run_command("gemini --version", show_output=True, check=False) + if result.returncode == 0: + print(f"Gemini CLI version: {result.stdout}") + else: + print(f"Gemini CLI version check failed: {result.stderr}") + + # Verify config directory was created + gemini_config_dir = Path(temp_e2e_home) / ".gemini" + assert gemini_config_dir.exists(), "Gemini config directory not created" + + gemini_settings = gemini_config_dir / "settings.json" + assert gemini_settings.exists(), "Gemini settings.json not created" + + settings_content = gemini_settings.read_text() + settings = json.loads(settings_content) + assert "mcpServers" in settings, "mcpServers key not in settings.json" + print(f"Gemini settings.json: {settings_content}") + + # Step 2: Create test project + with tempfile.TemporaryDirectory() as project_workspace: + project_dir = Path(project_workspace) / "my-ai-native-project-gemini" + + print("\n=== Initializing Gemini test project ===") + result = run_command(f"{apm_binary} init my-ai-native-project-gemini --yes", cwd=project_workspace) + assert result.returncode == 0, f"Project init failed: {result.stderr}" + + # Create a simple prompt file + prompt_content = """--- +description: Review prompt for Gemini testing +--- + +# Review Prompt + +This is a test prompt for ${input:name}. Reply with exactly: hello developer +""" + (project_dir / "review.prompt.md").write_text(prompt_content) + + # Create .apm directory with instructions + apm_dir = project_dir / ".apm" + apm_dir.mkdir(exist_ok=True) + (apm_dir / "instructions").mkdir(exist_ok=True) + + instruction_content = """--- +applyTo: "**" +description: Gemini test instructions +--- + +# Test Instructions + +Instructions for Gemini CLI E2E testing. +""" + (apm_dir / "instructions" / "test.instructions.md").write_text(instruction_content) + + # Create .gemini directory so target is detected + (project_dir / ".gemini").mkdir(exist_ok=True) + + # Update apm.yml to add review script + import yaml + apm_yml_path = project_dir / "apm.yml" + with open(apm_yml_path, 'r') as f: + config = yaml.safe_load(f) + + if 'scripts' not in config: + config['scripts'] = {} + config['scripts']['review'] = 'gemini -y review.prompt.md' + + with open(apm_yml_path, 'w') as f: + yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False) + + print("Created review.prompt.md, .apm/ directory, .gemini/ directory, and updated apm.yml") + + # Step 3: Compile Agent Primitives + print("\n=== Compiling Agent Primitives ===") + result = run_command(f"{apm_binary} compile", cwd=project_dir) + assert result.returncode == 0, f"Compilation failed: {result.stderr}" + + # Step 4: Install dependencies (targets gemini since .gemini/ exists) + print("\n=== Installing dependencies ===") + env = os.environ.copy() + env['HOME'] = temp_e2e_home + + result = run_command(f"{apm_binary} install", cwd=project_dir, env=env) + assert result.returncode == 0, f"Dependency install failed: {result.stderr}" + + # Step 5: Run with Gemini CLI + print("\n=== Running golden scenario with Gemini CLI ===") + env = os.environ.copy() + env['HOME'] = temp_e2e_home + env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true' + + + cmd = f'{apm_binary} run review --param name="developer"' + process = subprocess.Popen( + cmd, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + cwd=project_dir, + env=env + ) + + output_lines = [] + print("\n--- Gemini CLI Execution Output ---") + for line in iter(process.stdout.readline, ''): + if line: + print(line.rstrip()) + output_lines.append(line) + + return_code = process.wait(timeout=120) + full_output = ''.join(output_lines) + + print("--- End Gemini CLI Output ---\n") + + if return_code == 0: + output = full_output.lower() + assert "developer" in output, "Parameter substitution failed" + assert len(output.strip()) > 50, "Output seems too short" + print(f"\nGemini CLI scenario completed successfully!") + print(f"Output length: {len(full_output)} characters") + else: + print(f"\n=== Gemini CLI execution failed (expected in some environments) ===") + print(f"Output: {full_output}") + + if "authentication" in full_output.lower() or "login" in full_output.lower() or "api_key" in full_output.lower(): + pytest.skip("Gemini CLI execution failed due to authentication - this is expected in CI environments without Google credentials") + else: + pytest.skip(f"Gemini CLI execution failed with return code {return_code}: {full_output}") + def test_runtime_list_command(self, temp_e2e_home, apm_binary): """Test that APM can list installed runtimes.""" print("\\n=== Testing runtime list command ===") diff --git a/tests/unit/core/test_scope.py b/tests/unit/core/test_scope.py index 811457238..6d5fa92ab 100644 --- a/tests/unit/core/test_scope.py +++ b/tests/unit/core/test_scope.py @@ -158,7 +158,7 @@ class TestTargetProfileUserScope: """Validate user-scope metadata on TargetProfile in KNOWN_TARGETS.""" def test_all_known_targets_present(self): - expected = {"copilot", "claude", "cursor", "opencode", "codex"} + expected = {"copilot", "claude", "cursor", "opencode", "codex", "gemini"} assert set(KNOWN_TARGETS.keys()) == expected def test_each_target_has_user_supported(self): diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index 78764857c..4137ac836 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -170,7 +170,7 @@ def test_auto_detect_neither_folder(self, tmp_path): ) assert target == "minimal" - assert "no .github/" in reason + assert "no target folder found" in reason class TestShouldIntegrateVscode: diff --git a/tests/unit/integration/test_command_integrator.py b/tests/unit/integration/test_command_integrator.py index 3e934c267..89f7b4226 100644 --- a/tests/unit/integration/test_command_integrator.py +++ b/tests/unit/integration/test_command_integrator.py @@ -477,3 +477,180 @@ def test_claude_target_dispatches_commands(self): integrators["command_integrator"].integrate_commands_for_target.assert_called_once() finally: shutil.rmtree(temp_dir, ignore_errors=True) + + +# =================================================================== +# Gemini CLI Command Integration (.toml format) +# =================================================================== + + +class TestGeminiCommandIntegration: + """Tests for Gemini CLI command integration (.prompt.md → .toml).""" + + @pytest.fixture + def temp_project(self): + """Create a temporary project with .gemini/ directory.""" + temp_dir = tempfile.mkdtemp() + temp_path = Path(temp_dir) + (temp_path / ".gemini").mkdir() + yield temp_path + shutil.rmtree(temp_dir, ignore_errors=True) + + @pytest.fixture + def temp_project_no_gemini(self): + temp_dir = tempfile.mkdtemp() + temp_path = Path(temp_dir) + yield temp_path + shutil.rmtree(temp_dir, ignore_errors=True) + + def _make_package(self, project_root, prompts): + pkg_dir = project_root / "apm_modules" / "test-pkg" + pkg_dir.mkdir(parents=True) + prompts_dir = pkg_dir / ".apm" / "prompts" + prompts_dir.mkdir(parents=True) + for name, content in prompts.items(): + (prompts_dir / name).write_text(content) + + mock_info = MagicMock() + mock_info.install_path = pkg_dir + mock_info.resolved_reference = None + mock_info.package = MagicMock() + mock_info.package.name = "test-pkg" + return mock_info + + def test_skips_when_no_gemini_dir(self, temp_project_no_gemini): + """Opt-in: skip if .gemini/ does not exist.""" + pkg_info = self._make_package( + temp_project_no_gemini, + {"test.prompt.md": "---\ndescription: Test\n---\n# Test"}, + ) + integrator = CommandIntegrator() + from apm_cli.integration.targets import KNOWN_TARGETS + result = integrator.integrate_commands_for_target( + KNOWN_TARGETS["gemini"], pkg_info, temp_project_no_gemini + ) + assert result.files_integrated == 0 + + def test_deploys_toml_commands(self, temp_project): + """Deploy .prompt.md → .gemini/commands/.toml.""" + pkg_info = self._make_package( + temp_project, + {"review.prompt.md": "---\ndescription: Review code\n---\nReview the code."}, + ) + integrator = CommandIntegrator() + from apm_cli.integration.targets import KNOWN_TARGETS + result = integrator.integrate_commands_for_target( + KNOWN_TARGETS["gemini"], pkg_info, temp_project + ) + assert result.files_integrated == 1 + target = temp_project / ".gemini" / "commands" / "review.toml" + assert target.exists() + content = target.read_text() + assert "Review the code." in content + assert "Review code" in content + + def test_toml_is_valid(self, temp_project): + """Verify generated file is valid TOML.""" + import toml + pkg_info = self._make_package( + temp_project, + {"test.prompt.md": "---\ndescription: A test\n---\nDo the thing."}, + ) + integrator = CommandIntegrator() + from apm_cli.integration.targets import KNOWN_TARGETS + integrator.integrate_commands_for_target( + KNOWN_TARGETS["gemini"], pkg_info, temp_project + ) + target = temp_project / ".gemini" / "commands" / "test.toml" + parsed = toml.loads(target.read_text()) + assert parsed["description"] == "A test" + assert "Do the thing." in parsed["prompt"] + + def test_arguments_replacement(self, temp_project): + """$ARGUMENTS is replaced with {{args}}.""" + pkg_info = self._make_package( + temp_project, + {"cmd.prompt.md": "---\ndescription: Run cmd\n---\nRun with $ARGUMENTS"}, + ) + integrator = CommandIntegrator() + from apm_cli.integration.targets import KNOWN_TARGETS + integrator.integrate_commands_for_target( + KNOWN_TARGETS["gemini"], pkg_info, temp_project + ) + target = temp_project / ".gemini" / "commands" / "cmd.toml" + content = target.read_text() + assert "{{args}}" in content + assert "$ARGUMENTS" not in content + + def test_positional_args_prepends_args_line(self, temp_project): + """When $1 or $2 are found, prepend 'Arguments: {{args}}'.""" + pkg_info = self._make_package( + temp_project, + {"cmd.prompt.md": "---\ndescription: Fix\n---\nFix file $1"}, + ) + integrator = CommandIntegrator() + from apm_cli.integration.targets import KNOWN_TARGETS + integrator.integrate_commands_for_target( + KNOWN_TARGETS["gemini"], pkg_info, temp_project + ) + target = temp_project / ".gemini" / "commands" / "cmd.toml" + import toml + parsed = toml.loads(target.read_text()) + assert parsed["prompt"].startswith("Arguments: {{args}}") + + def test_no_description_omits_key(self, temp_project): + """When no description in frontmatter, TOML omits description key.""" + pkg_info = self._make_package( + temp_project, + {"cmd.prompt.md": "Just do the thing."}, + ) + integrator = CommandIntegrator() + from apm_cli.integration.targets import KNOWN_TARGETS + integrator.integrate_commands_for_target( + KNOWN_TARGETS["gemini"], pkg_info, temp_project + ) + target = temp_project / ".gemini" / "commands" / "cmd.toml" + import toml + parsed = toml.loads(target.read_text()) + assert "description" not in parsed + assert "Just do the thing." in parsed["prompt"] + + +class TestWriteGeminiCommand: + """Direct unit tests for CommandIntegrator._write_gemini_command().""" + + def setup_method(self): + import tempfile + self.temp_dir = tempfile.mkdtemp() + + def teardown_method(self): + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_basic_conversion(self): + source = Path(self.temp_dir) / "test.prompt.md" + target = Path(self.temp_dir) / "test.toml" + source.write_text("---\ndescription: Test command\n---\nDo something.") + CommandIntegrator._write_gemini_command(source, target) + + import toml + parsed = toml.loads(target.read_text()) + assert parsed["description"] == "Test command" + assert parsed["prompt"] == "Do something." + + def test_arguments_replaced(self): + source = Path(self.temp_dir) / "test.prompt.md" + target = Path(self.temp_dir) / "test.toml" + source.write_text("Review $ARGUMENTS") + CommandIntegrator._write_gemini_command(source, target) + + import toml + parsed = toml.loads(target.read_text()) + assert "{{args}}" in parsed["prompt"] + assert "$ARGUMENTS" not in parsed["prompt"] + + def test_creates_parent_dirs(self): + source = Path(self.temp_dir) / "test.prompt.md" + target = Path(self.temp_dir) / "sub" / "dir" / "test.toml" + source.write_text("# Test") + CommandIntegrator._write_gemini_command(source, target) + assert target.exists() diff --git a/tests/unit/integration/test_data_driven_dispatch.py b/tests/unit/integration/test_data_driven_dispatch.py index e5fa911cb..a2ff2c345 100644 --- a/tests/unit/integration/test_data_driven_dispatch.py +++ b/tests/unit/integration/test_data_driven_dispatch.py @@ -278,8 +278,10 @@ def test_partition_parity_with_old_buckets(self): "agents_opencode", "agents_codex", "commands", # was commands_claude, aliased + "commands_gemini", "commands_opencode", "instructions", # was instructions_copilot, aliased + "instructions_gemini", "rules_cursor", # was instructions_cursor, aliased "rules_claude", # was instructions_claude, aliased "skills", # cross-target bucket diff --git a/tests/unit/integration/test_instruction_integrator.py b/tests/unit/integration/test_instruction_integrator.py index 0b14b5bcd..046e0206a 100644 --- a/tests/unit/integration/test_instruction_integrator.py +++ b/tests/unit/integration/test_instruction_integrator.py @@ -1095,3 +1095,114 @@ def test_sync_handles_missing_rules_dir(self): assert result["files_removed"] == 0 assert result["errors"] == 0 + + +# =================================================================== +# Gemini CLI Rules (.md, frontmatter stripped) +# =================================================================== + + +class TestConvertToGeminiRules: + """Test the _convert_to_gemini_rules() frontmatter conversion helper.""" + + def test_strips_apply_to_frontmatter(self): + content = "---\napplyTo: 'src/**/*.py'\n---\n\n# Python rules" + result = InstructionIntegrator._convert_to_gemini_rules(content) + assert "applyTo" not in result + assert "---" not in result + assert "# Python rules" in result + + def test_preserves_body(self): + content = "---\napplyTo: '**/*.ts'\n---\n\n# TypeScript\n\nUse strict mode." + result = InstructionIntegrator._convert_to_gemini_rules(content) + assert "# TypeScript" in result + assert "Use strict mode." in result + + def test_no_frontmatter_returns_body(self): + content = "# Simple rules\n\nJust some guidelines." + result = InstructionIntegrator._convert_to_gemini_rules(content) + assert "# Simple rules" in result + assert "Just some guidelines." in result + + def test_strips_description_frontmatter(self): + content = "---\ndescription: General rules\napplyTo: '**/*.py'\n---\n\n# Rules" + result = InstructionIntegrator._convert_to_gemini_rules(content) + assert "description:" not in result + assert "applyTo" not in result + assert "# Rules" in result + + def test_empty_frontmatter(self): + content = "---\n---\n\n# Body only" + result = InstructionIntegrator._convert_to_gemini_rules(content) + assert "---" not in result + assert "# Body only" in result + + +class TestGeminiRulesIntegration: + """Test integrate_instructions_for_target() with gemini target.""" + + def setup_method(self): + self.temp_dir = tempfile.mkdtemp() + self.project_root = Path(self.temp_dir) + self.integrator = InstructionIntegrator() + + def teardown_method(self): + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_skips_when_no_gemini_dir(self): + """Returns empty result when .gemini/ doesn't exist.""" + pkg = self.project_root / "package" + inst_dir = pkg / ".apm" / "instructions" + inst_dir.mkdir(parents=True) + (inst_dir / "python.instructions.md").write_text("# Python") + + pkg_info = _make_package_info(pkg) + from apm_cli.integration.targets import KNOWN_TARGETS + result = self.integrator.integrate_instructions_for_target( + KNOWN_TARGETS["gemini"], pkg_info, self.project_root + ) + assert result.files_integrated == 0 + + def test_deploys_when_gemini_dir_exists(self): + """Deploys .md files when .gemini/ exists.""" + (self.project_root / ".gemini").mkdir() + + pkg = self.project_root / "package" + inst_dir = pkg / ".apm" / "instructions" + inst_dir.mkdir(parents=True) + (inst_dir / "python.instructions.md").write_text( + "---\napplyTo: 'src/**/*.py'\n---\n\n# Python rules" + ) + + pkg_info = _make_package_info(pkg) + from apm_cli.integration.targets import KNOWN_TARGETS + result = self.integrator.integrate_instructions_for_target( + KNOWN_TARGETS["gemini"], pkg_info, self.project_root + ) + + assert result.files_integrated == 1 + deployed = self.project_root / ".gemini" / "rules" / "python.md" + assert deployed.exists() + content = deployed.read_text() + assert "applyTo" not in content + assert "---" not in content + assert "# Python rules" in content + + def test_renames_instructions_md_to_md(self): + """File extension is changed from .instructions.md to .md.""" + (self.project_root / ".gemini").mkdir() + + pkg = self.project_root / "package" + inst_dir = pkg / ".apm" / "instructions" + inst_dir.mkdir(parents=True) + (inst_dir / "coding-style.instructions.md").write_text("# Coding style") + + pkg_info = _make_package_info(pkg) + from apm_cli.integration.targets import KNOWN_TARGETS + result = self.integrator.integrate_instructions_for_target( + KNOWN_TARGETS["gemini"], pkg_info, self.project_root + ) + + assert result.files_integrated == 1 + assert (self.project_root / ".gemini" / "rules" / "coding-style.md").exists() + assert not (self.project_root / ".gemini" / "rules" / "coding-style.instructions.md").exists() diff --git a/tests/unit/integration/test_targets.py b/tests/unit/integration/test_targets.py index 8951e24bb..29b1b7fe5 100644 --- a/tests/unit/integration/test_targets.py +++ b/tests/unit/integration/test_targets.py @@ -117,6 +117,30 @@ def test_codex_not_detected_when_only_agents_dir_exists(self): assert len(targets) == 1 assert targets[0].name == "copilot" # fallback + # -- gemini detection -- + + def test_only_gemini_returns_gemini(self): + (self.root / ".gemini").mkdir() + targets = active_targets(self.root) + assert [t.name for t in targets] == ["gemini"] + + def test_explicit_gemini(self): + targets = active_targets(self.root, explicit_target="gemini") + assert [t.name for t in targets] == ["gemini"] + + def test_gemini_and_claude_returns_both(self): + (self.root / ".gemini").mkdir() + (self.root / ".claude").mkdir() + targets = active_targets(self.root) + names = {t.name for t in targets} + assert names == {"gemini", "claude"} + + def test_all_six_dirs_returns_all_six(self): + for d in (".github", ".claude", ".cursor", ".opencode", ".codex", ".gemini"): + (self.root / d).mkdir() + targets = active_targets(self.root) + assert len(targets) == 6 + def test_all_five_dirs_returns_all_five(self): for d in (".github", ".claude", ".cursor", ".opencode", ".codex"): (self.root / d).mkdir() diff --git a/tests/unit/test_gemini_mcp.py b/tests/unit/test_gemini_mcp.py new file mode 100644 index 000000000..b247e52e9 --- /dev/null +++ b/tests/unit/test_gemini_mcp.py @@ -0,0 +1,166 @@ +"""Tests for the Gemini CLI MCP client adapter.""" + +import json +import os +import tempfile +import shutil +import unittest +from pathlib import Path +from unittest.mock import patch, MagicMock + +from apm_cli.adapters.client.gemini import GeminiClientAdapter +from apm_cli.factory import ClientFactory + + +class TestGeminiClientFactory: + """Verify GeminiClientAdapter is registered in ClientFactory.""" + + def test_factory_creates_gemini_adapter(self): + adapter = ClientFactory.create_client("gemini") + assert isinstance(adapter, GeminiClientAdapter) + + +class TestGeminiClientAdapter(unittest.TestCase): + """Core config operations for GeminiClientAdapter.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.gemini_dir = Path(self.tmp.name) / ".gemini" + self.gemini_dir.mkdir() + self.settings_json = self.gemini_dir / "settings.json" + self._cwd_patcher = patch("os.getcwd", return_value=self.tmp.name) + self._cwd_patcher.start() + self.adapter = GeminiClientAdapter() + + def tearDown(self): + self._cwd_patcher.stop() + self.tmp.cleanup() + + def test_config_path(self): + expected = str(Path(self.tmp.name) / ".gemini" / "settings.json") + self.assertEqual(self.adapter.get_config_path(), expected) + + def test_get_current_config_empty(self): + config = self.adapter.get_current_config() + self.assertEqual(config, {}) + + def test_get_current_config_existing(self): + self.settings_json.write_text('{"theme": "dark"}') + config = self.adapter.get_current_config() + self.assertEqual(config, {"theme": "dark"}) + + def test_get_current_config_invalid_json(self): + self.settings_json.write_text("not json") + config = self.adapter.get_current_config() + self.assertEqual(config, {}) + + def test_update_config_creates_file(self): + self.adapter.update_config({"my-server": {"command": "npx", "args": ["-y", "pkg"]}}) + data = json.loads(self.settings_json.read_text()) + self.assertIn("mcpServers", data) + self.assertIn("my-server", data["mcpServers"]) + self.assertEqual(data["mcpServers"]["my-server"]["command"], "npx") + + def test_update_config_preserves_existing_keys(self): + self.settings_json.write_text(json.dumps({ + "theme": "dark", + "tools": {"sandbox": "docker"}, + })) + self.adapter.update_config({"server-a": {"command": "node", "args": ["server.js"]}}) + data = json.loads(self.settings_json.read_text()) + self.assertEqual(data["theme"], "dark") + self.assertEqual(data["tools"], {"sandbox": "docker"}) + self.assertIn("server-a", data["mcpServers"]) + + def test_update_config_merges_servers(self): + self.settings_json.write_text(json.dumps({ + "mcpServers": {"existing": {"command": "old"}} + })) + self.adapter.update_config({"new-server": {"command": "new"}}) + data = json.loads(self.settings_json.read_text()) + self.assertIn("existing", data["mcpServers"]) + self.assertIn("new-server", data["mcpServers"]) + + def test_update_config_noop_when_no_gemini_dir(self): + shutil.rmtree(self.gemini_dir) + self.adapter.update_config({"server": {"command": "npx"}}) + self.assertFalse(self.settings_json.exists()) + + +class TestGeminiConfigureMCPServer(unittest.TestCase): + """Test configure_mcp_server() for GeminiClientAdapter.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.gemini_dir = Path(self.tmp.name) / ".gemini" + self.gemini_dir.mkdir() + self.settings_json = self.gemini_dir / "settings.json" + self._cwd_patcher = patch("os.getcwd", return_value=self.tmp.name) + self._cwd_patcher.start() + + self.mock_registry_patcher = patch( + "apm_cli.adapters.client.copilot.SimpleRegistryClient" + ) + self.mock_registry_class = self.mock_registry_patcher.start() + self.mock_registry = MagicMock() + self.mock_registry_class.return_value = self.mock_registry + + self.mock_integration_patcher = patch( + "apm_cli.adapters.client.copilot.RegistryIntegration" + ) + self.mock_integration_class = self.mock_integration_patcher.start() + + self.adapter = GeminiClientAdapter() + + def tearDown(self): + self._cwd_patcher.stop() + self.mock_registry_patcher.stop() + self.mock_integration_patcher.stop() + self.tmp.cleanup() + + def test_configure_mcp_server_skips_when_no_gemini_dir(self): + """Should return True (not an error) when .gemini/ doesn't exist.""" + shutil.rmtree(self.gemini_dir) + result = self.adapter.configure_mcp_server("some/server") + self.assertTrue(result) + + def test_returns_false_for_empty_url(self): + result = self.adapter.configure_mcp_server("") + self.assertFalse(result) + + def test_returns_false_when_server_not_found(self): + self.mock_registry.find_server_by_reference.return_value = None + result = self.adapter.configure_mcp_server("unknown/server") + self.assertFalse(result) + + def test_uses_cached_server_info(self): + cached = {"some/server": {"packages": [{"name": "pkg", "registry_name": "npm", "runtime_hint": "npx"}]}} + result = self.adapter.configure_mcp_server( + "some/server", + server_info_cache=cached, + ) + self.assertTrue(result) + self.mock_registry.find_server_by_reference.assert_not_called() + + def test_extracts_server_name_from_url(self): + self.mock_registry.find_server_by_reference.return_value = { + "packages": [{"name": "@scope/mcp-server", "registry_name": "npm", "runtime_hint": "npx"}] + } + result = self.adapter.configure_mcp_server("scope/mcp-server") + self.assertTrue(result) + data = json.loads(self.settings_json.read_text()) + self.assertIn("mcp-server", data["mcpServers"]) + + def test_uses_explicit_server_name(self): + self.mock_registry.find_server_by_reference.return_value = { + "packages": [{"name": "pkg", "registry_name": "npm", "runtime_hint": "npx"}] + } + result = self.adapter.configure_mcp_server( + "some/server", server_name="custom-name" + ) + self.assertTrue(result) + data = json.loads(self.settings_json.read_text()) + self.assertIn("custom-name", data["mcpServers"]) + + def test_supports_user_scope_is_true(self): + self.assertTrue(self.adapter.supports_user_scope) diff --git a/tests/unit/test_global_mcp_scope.py b/tests/unit/test_global_mcp_scope.py index 4c8d6c3bf..17025d311 100644 --- a/tests/unit/test_global_mcp_scope.py +++ b/tests/unit/test_global_mcp_scope.py @@ -93,21 +93,31 @@ def test_factory_created_adapters_scope(self): class TestMCPIntegratorScopeFiltering(unittest.TestCase): """Verify MCPIntegrator.install() filters runtimes by scope.""" + @patch("apm_cli.registry.operations.MCPServerOperations") @patch("apm_cli.integration.mcp_integrator.MCPIntegrator._install_for_runtime") @patch("apm_cli.integration.mcp_integrator._is_vscode_available", return_value=False) @patch("apm_cli.integration.mcp_integrator.shutil.which", return_value=None) def test_user_scope_skips_workspace_runtimes( - self, mock_which, mock_vscode, mock_install_rt + self, mock_which, mock_vscode, mock_install_rt, mock_ops_cls ): """At USER scope, workspace-only runtimes are not targeted.""" from apm_cli.integration.mcp_integrator import MCPIntegrator mock_install_rt.return_value = True + mock_ops = MagicMock() + mock_ops.validate_servers_exist.return_value = (["test/server"], []) + mock_ops.check_servers_needing_installation.return_value = ["test/server"] + mock_ops_cls.return_value = mock_ops - # Explicitly target copilot + vscode with patch.object( MCPIntegrator, "_detect_runtimes", return_value=set() - ): + ), patch( + "apm_cli.runtime.manager.RuntimeManager" + ) as mock_mgr_cls: + mock_mgr = MagicMock() + mock_mgr.is_runtime_available.return_value = True + mock_mgr_cls.return_value = mock_mgr + MCPIntegrator.install( mcp_deps=["test/server"], runtime=None, diff --git a/tests/unit/test_runtime_manager.py b/tests/unit/test_runtime_manager.py index 8fb21e46b..e7597e883 100644 --- a/tests/unit/test_runtime_manager.py +++ b/tests/unit/test_runtime_manager.py @@ -24,7 +24,7 @@ def test_init_sets_runtime_dir(self): def test_init_supported_runtimes_keys(self): manager = RuntimeManager() - assert set(manager.supported_runtimes.keys()) == {"copilot", "codex", "llm"} + assert set(manager.supported_runtimes.keys()) == {"copilot", "codex", "llm", "gemini"} def test_init_script_extension_unix(self): with patch("apm_cli.runtime.manager.sys") as mock_sys: @@ -45,7 +45,7 @@ class TestRuntimeManagerGetRuntimePreference: def test_returns_expected_order(self): manager = RuntimeManager() pref = manager.get_runtime_preference() - assert pref == ["copilot", "codex", "llm"] + assert pref == ["copilot", "codex", "gemini", "llm"] class TestRuntimeManagerIsRuntimeAvailable: @@ -110,7 +110,7 @@ def test_all_not_installed_when_nothing_found(self, tmp_path): manager.runtime_dir = tmp_path with patch("apm_cli.runtime.manager.shutil.which", return_value=None): result = manager.list_runtimes() - assert set(result.keys()) == {"copilot", "codex", "llm"} + assert set(result.keys()) == {"copilot", "codex", "llm", "gemini"} for name, info in result.items(): assert info["installed"] is False assert info["path"] is None From a9d5ebff1138e5a992927c35dacbe142b02d77c5 Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Fri, 24 Apr 2026 20:08:06 -0400 Subject: [PATCH 02/11] fix(gemini): replace bare print() with rich console helpers and update docs Replace 4 bare print() calls in GeminiClientAdapter.configure_mcp_server with _rich_error/_rich_success from apm_cli.utils.console. Sanitize the exception handler so str(e) internals are not exposed to users. Add gemini to --target enumerations, runtime setup/remove synopses, and target-detection table in cli-commands.md and the shipped apm-usage skill resource. Add CHANGELOG entry under [Unreleased] > Added. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + .../src/content/docs/reference/cli-commands.md | 18 ++++++++++-------- .../.apm/skills/apm-usage/commands.md | 4 ++-- src/apm_cli/adapters/client/gemini.py | 15 ++++++++++----- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa4784b9..c0217efee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - PGS project board sync: `scripts/project/sync_item.py` and `.github/workflows/project-sync.yml` keep https://github.com/orgs/microsoft/projects/2304 in lockstep with `theme/*`, `area/*`, `type/*`, `priority/*` labels and milestones. Backfill helper at `scripts/project/backfill.sh`. (#919) +- **Gemini CLI** as a supported APM target (`--target gemini`). APM auto-detects `.gemini/` directories and writes MCP server configuration to `.gemini/settings.json`. Includes `apm runtime setup gemini` / `apm runtime remove gemini` support. - New `pr-description-skill` skill bundle: enforces a 10-section PR body shape (TL;DR / Problem / Approach / Implementation / Diagrams / Trade-offs / Benefits / Validation / How to test, plus the `Co-authored-by` trailer) with a cite-or-omit rule for every WHY-claim, GFM-rendered output, ASCII-only template source, and validated mermaid diagrams. Captures the meta-pattern from PR #882 as a reusable scaffold so future PR bodies meet the same bar without per-PR specialist subagent intervention. (#884) - `apm experimental` command group -- a feature-flag registry with `list` / `enable` / `disable` / `reset` subcommands. Opt in to new behaviour before it graduates to default. Ships with one built-in flag (`verbose-version`) and a contributor recipe for proposing new flags. (#849) - `includes:` manifest field (auto | list) for explicit governance of local `.apm/` content. Closes audit-blindness gap (#887). diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index cd1981167..63c32627a 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -87,7 +87,7 @@ apm install [PACKAGES...] [OPTIONS] - `--runtime TEXT` - Target specific runtime only (copilot, codex, vscode) - `--exclude TEXT` - Exclude specific runtime from installation - `--only [apm|mcp]` - Install only specific dependency type -- `--target [copilot|claude|cursor|codex|opencode|all]` - Force deployment to specific target(s). Accepts comma-separated values for multiple targets (e.g., `-t claude,copilot`). Overrides auto-detection +- `--target [copilot|claude|cursor|codex|opencode|gemini|all]` - Force deployment to specific target(s). Accepts comma-separated values for multiple targets (e.g., `-t claude,copilot`). Overrides auto-detection - `--update` - Update dependencies to latest Git references - `--force` - Overwrite locally-authored files on collision; bypass security scan blocks - `--dry-run` - Show what would be installed without installing @@ -555,7 +555,7 @@ apm pack [OPTIONS] **Options:** - `-o, --output PATH` - Output directory (default: `./build`) -- `-t, --target [copilot|vscode|claude|cursor|codex|opencode|all]` - Filter files by target. Accepts comma-separated values for multiple targets (e.g., `-t claude,copilot`). Auto-detects from `apm.yml` if not specified. `vscode` is an alias for `copilot` +- `-t, --target [copilot|vscode|claude|cursor|codex|opencode|gemini|all]` - Filter files by target. Accepts comma-separated values for multiple targets (e.g., `-t claude,copilot`). Auto-detects from `apm.yml` if not specified. `vscode` is an alias for `copilot` - `--archive` - Produce a `.tar.gz` archive instead of a directory - `--dry-run` - List files that would be packed without writing anything - `--format [apm|plugin]` - Bundle format (default: `apm`). `plugin` produces a standalone plugin directory with `plugin.json` @@ -956,7 +956,7 @@ apm deps update [PACKAGES...] [OPTIONS] - `--verbose, -v` - Show detailed update information - `--force` - Overwrite locally-authored files on collision - `-g, --global` - Update user-scope dependencies (`~/.apm/`) -- `--target, -t` - Force deployment to specific target(s). Accepts comma-separated values (e.g., `-t claude,copilot`). Valid values: copilot, claude, cursor, opencode, vscode, agents, all +- `--target, -t` - Force deployment to specific target(s). Accepts comma-separated values (e.g., `-t claude,copilot`). Valid values: copilot, claude, cursor, opencode, gemini, vscode, agents, all - `--parallel-downloads` - Max concurrent downloads (default: 4) **Policy enforcement:** `apm deps update` runs the install pipeline and is therefore gated by org `apm-policy.yml`. There is no `--no-policy` flag on this command -- the only escape hatch is `APM_POLICY_DISABLE=1` for the shell session. See [Policy reference](../../enterprise/policy-reference/#install-time-enforcement). @@ -1348,7 +1348,7 @@ apm compile [OPTIONS] **Options:** - `-o, --output TEXT` - Output file path (for single-file mode) -- `-t, --target [vscode|agents|claude|codex|opencode|all]` - Target agent format. Accepts comma-separated values for multiple targets (e.g., `-t claude,copilot`). `agents` is an alias for `vscode`. Auto-detects if not specified. +- `-t, --target [vscode|agents|claude|codex|opencode|gemini|all]` - Target agent format. Accepts comma-separated values for multiple targets (e.g., `-t claude,copilot`). `agents` is an alias for `vscode`. Auto-detects if not specified. - `--chatmode TEXT` - Chatmode to prepend to the AGENTS.md file - `--dry-run` - Preview compilation without writing files (shows placement decisions) - `--no-links` - Skip markdown link resolution @@ -1369,6 +1369,7 @@ When `--target` is not specified, APM auto-detects based on existing project str | `.github/` exists only | `vscode` | AGENTS.md + .github/ | | `.claude/` exists only | `claude` | CLAUDE.md + .claude/ | | `.codex/` exists | `codex` | AGENTS.md + .codex/ + .agents/ | +| `.gemini/` exists | `gemini` | .gemini/settings.json | | Both folders exist | `all` | All outputs | | Neither folder exists | `minimal` | AGENTS.md only | @@ -1632,17 +1633,18 @@ apm runtime COMMAND [OPTIONS] - **`copilot`** - GitHub Copilot coding agent - **`codex`** - OpenAI Codex CLI with GitHub Models support - **`llm`** - Simon Willison's LLM library with multiple providers +- **`gemini`** - Google Gemini CLI #### `apm runtime setup` - Install AI runtime Download and configure an AI runtime from official sources. ```bash -apm runtime setup [OPTIONS] {copilot|codex|llm} +apm runtime setup [OPTIONS] {copilot|codex|llm|gemini} ``` **Arguments:** -- `{copilot|codex|llm}` - Runtime to install +- `{copilot|codex|llm|gemini}` - Runtime to install **Options:** - `--version TEXT` - Specific version to install @@ -1693,11 +1695,11 @@ apm runtime list Remove an installed runtime and its configuration. ```bash -apm runtime remove [OPTIONS] {copilot|codex|llm} +apm runtime remove [OPTIONS] {copilot|codex|llm|gemini} ``` **Arguments:** -- `{copilot|codex|llm}` - Runtime to remove +- `{copilot|codex|llm|gemini}` - Runtime to remove **Options:** - `--yes` - Confirm the action without prompting diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index a70f485eb..c5a3e6a99 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -77,9 +77,9 @@ Set `MCP_REGISTRY_URL` (default `https://api.mcp.github.com`) to point all `apm | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm runtime setup {copilot\|codex\|llm}` | Install a runtime | `--version`, `--vanilla` | +| `apm runtime setup {copilot\|codex\|llm\|gemini}` | Install a runtime | `--version`, `--vanilla` | | `apm runtime list` | Show installed runtimes | -- | -| `apm runtime remove {copilot\|codex\|llm}` | Remove a runtime | `--yes` | +| `apm runtime remove {copilot\|codex\|llm\|gemini}` | Remove a runtime | `--yes` | | `apm runtime status` | Show active runtime | -- | ## Experimental features diff --git a/src/apm_cli/adapters/client/gemini.py b/src/apm_cli/adapters/client/gemini.py index ed723b33c..2454990d2 100644 --- a/src/apm_cli/adapters/client/gemini.py +++ b/src/apm_cli/adapters/client/gemini.py @@ -22,10 +22,14 @@ """ import json +import logging import os from pathlib import Path from .copilot import CopilotClientAdapter +from ...utils.console import _rich_error, _rich_info, _rich_success + +logger = logging.getLogger(__name__) class GeminiClientAdapter(CopilotClientAdapter): @@ -93,7 +97,7 @@ def configure_mcp_server( the Gemini CLI settings file. """ if not server_url: - print("Error: server_url cannot be empty") + _rich_error("server_url cannot be empty", symbol="error") return False gemini_dir = Path(os.getcwd()) / ".gemini" @@ -107,7 +111,7 @@ def configure_mcp_server( server_info = self.registry_client.find_server_by_reference(server_url) if not server_info: - print(f"Error: MCP server '{server_url}' not found in registry") + _rich_error(f"MCP server '{server_url}' not found in registry", symbol="error") return False if server_name: @@ -122,11 +126,12 @@ def configure_mcp_server( ) self.update_config({config_key: server_config}) - print( - f"Successfully configured MCP server '{config_key}' for Gemini CLI" + _rich_success( + f"Configured MCP server '{config_key}' for Gemini CLI", symbol="success" ) return True except Exception as e: - print(f"Error configuring MCP server: {e}") + logger.debug("Gemini MCP configuration failed: %s", e) + _rich_error("Failed to configure MCP server for Gemini CLI", symbol="error") return False From f46cce0fbee967e11d5d4f06f75d71a9626063b0 Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Sat, 25 Apr 2026 06:42:54 -0400 Subject: [PATCH 03/11] fix(gemini): add settings.json to target description and use _rich_success for stale cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "+ .gemini/settings.json (MCP/hooks)" to get_target_description() so --help and diagnostic output accurately reflect Gemini's config path. Swap _rich_info() → _rich_success(symbol="check") for all 6 "Removed stale MCP server" messages in mcp_integrator.py cleanup paths — removal is a confirmed action, not informational. Co-Authored-By: Claude Opus 4.6 --- src/apm_cli/core/target_detection.py | 2 +- src/apm_cli/integration/mcp_integrator.py | 30 ++++++++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 95cdd9796..425f04285 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -241,7 +241,7 @@ def get_target_description(target: UserTargetType) -> str: "cursor": ".cursor/agents/ + .cursor/skills/ + .cursor/rules/", "opencode": "AGENTS.md + .opencode/agents/ + .opencode/commands/ + .opencode/skills/", "codex": "AGENTS.md + .agents/skills/ + .codex/agents/ + .codex/hooks.json", - "gemini": ".gemini/commands/ + .gemini/rules/ + .gemini/skills/", + "gemini": ".gemini/commands/ + .gemini/rules/ + .gemini/skills/ + .gemini/settings.json (MCP/hooks)", "all": "AGENTS.md + CLAUDE.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .agents/", "minimal": "AGENTS.md only (create a target folder for full integration)", } diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 5da240790..1130e3a23 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -515,8 +515,9 @@ def remove_stale( f"Removed stale MCP server '{name}' from .vscode/mcp.json" ) else: - _rich_info( - f"+ Removed stale MCP server '{name}' from .vscode/mcp.json" + _rich_success( + f"Removed stale MCP server '{name}' from .vscode/mcp.json", + symbol="check", ) except Exception: _log.debug( @@ -541,8 +542,9 @@ def remove_stale( _json.dumps(config, indent=2), encoding="utf-8" ) for name in removed: - _rich_info( - f"+ Removed stale MCP server '{name}' from Copilot CLI config" + _rich_success( + f"Removed stale MCP server '{name}' from Copilot CLI config", + symbol="check", ) except Exception: _log.debug( @@ -565,8 +567,9 @@ def remove_stale( if removed: codex_cfg.write_text(_toml.dumps(config), encoding="utf-8") for name in removed: - _rich_info( - f"+ Removed stale MCP server '{name}' from Codex CLI config" + _rich_success( + f"Removed stale MCP server '{name}' from Codex CLI config", + symbol="check", ) except Exception: _log.debug( @@ -591,8 +594,9 @@ def remove_stale( _json.dumps(config, indent=2), encoding="utf-8" ) for name in removed: - _rich_info( - f"+ Removed stale MCP server '{name}' from .cursor/mcp.json" + _rich_success( + f"Removed stale MCP server '{name}' from .cursor/mcp.json", + symbol="check", ) except Exception: _log.debug( @@ -622,8 +626,9 @@ def remove_stale( f"Removed stale MCP server '{name}' from opencode.json" ) else: - _rich_info( - f"+ Removed stale MCP server '{name}' from opencode.json" + _rich_success( + f"Removed stale MCP server '{name}' from opencode.json", + symbol="check", ) except Exception: _log.debug( @@ -653,8 +658,9 @@ def remove_stale( f"Removed stale MCP server '{name}' from .gemini/settings.json" ) else: - _rich_info( - f"+ Removed stale MCP server '{name}' from .gemini/settings.json" + _rich_success( + f"Removed stale MCP server '{name}' from .gemini/settings.json", + symbol="check", ) except Exception: _log.debug( From b56869a67fddb78cc29fb304135de7525d84012b Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Sat, 25 Apr 2026 06:45:03 -0400 Subject: [PATCH 04/11] refactor(target): remove unused should_integrate_*() function family Six should_integrate_{vscode,claude,opencode,cursor,codex,gemini}() helpers were defined and tested but never called in production code. Each target's integration decision is handled inline by the integrators and compile pipeline. Remove the functions and their test classes to prevent per-target maintenance drift. Co-Authored-By: Claude Opus 4.6 --- src/apm_cli/core/target_detection.py | 72 ------------------- tests/unit/core/test_target_detection.py | 92 ------------------------ 2 files changed, 164 deletions(-) diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 425f04285..8916d71b6 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -123,78 +123,6 @@ def detect_target( return "minimal", "no target folder found" -def should_integrate_vscode(target: TargetType) -> bool: - """Check if VSCode integration should be performed. - - Args: - target: The detected or configured target - - Returns: - bool: True if VSCode integration (prompts, agents) should run - """ - return target in ("vscode", "all") - - -def should_integrate_claude(target: TargetType) -> bool: - """Check if Claude integration should be performed. - - Args: - target: The detected or configured target - - Returns: - bool: True if Claude integration (commands, skills) should run - """ - return target in ("claude", "all") - - -def should_integrate_opencode(target: TargetType) -> bool: - """Check if OpenCode integration should be performed. - - Args: - target: The detected or configured target - - Returns: - bool: True if OpenCode integration (agents, commands, skills) should run - """ - return target in ("opencode", "all") - - -def should_integrate_cursor(target: TargetType) -> bool: - """Check if Cursor integration should be performed. - - Args: - target: The detected or configured target - - Returns: - bool: True if Cursor integration (agents, skills, rules) should run - """ - return target in ("cursor", "all") - - -def should_integrate_codex(target: TargetType) -> bool: - """Check if Codex CLI integration should be performed. - - Args: - target: The detected or configured target - - Returns: - bool: True if Codex integration (agents, skills, hooks) should run - """ - return target in ("codex", "all") - - -def should_integrate_gemini(target: TargetType) -> bool: - """Check if Gemini CLI integration should be performed. - - Args: - target: The detected or configured target - - Returns: - bool: True if Gemini integration (commands, rules, skills) should run - """ - return target in ("gemini", "all") - - def should_compile_agents_md(target: TargetType) -> bool: """Check if AGENTS.md should be compiled. diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index 4137ac836..4b35f3650 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -2,10 +2,6 @@ from apm_cli.core.target_detection import ( detect_target, - should_integrate_vscode, - should_integrate_claude, - should_integrate_cursor, - should_integrate_opencode, should_compile_agents_md, should_compile_claude_md, get_target_description, @@ -173,46 +169,6 @@ def test_auto_detect_neither_folder(self, tmp_path): assert "no target folder found" in reason -class TestShouldIntegrateVscode: - """Tests for should_integrate_vscode function.""" - - def test_vscode_target(self): - """VSCode integration enabled for vscode target.""" - assert should_integrate_vscode("vscode") is True - - def test_all_target(self): - """VSCode integration enabled for all target.""" - assert should_integrate_vscode("all") is True - - def test_claude_target(self): - """VSCode integration disabled for claude target.""" - assert should_integrate_vscode("claude") is False - - def test_minimal_target(self): - """VSCode integration disabled for minimal target.""" - assert should_integrate_vscode("minimal") is False - - -class TestShouldIntegrateClaude: - """Tests for should_integrate_claude function.""" - - def test_claude_target(self): - """Claude integration enabled for claude target.""" - assert should_integrate_claude("claude") is True - - def test_all_target(self): - """Claude integration enabled for all target.""" - assert should_integrate_claude("all") is True - - def test_vscode_target(self): - """Claude integration disabled for vscode target.""" - assert should_integrate_claude("vscode") is False - - def test_minimal_target(self): - """Claude integration disabled for minimal target.""" - assert should_integrate_claude("minimal") is False - - class TestShouldCompileAgentsMd: """Tests for should_compile_agents_md function.""" @@ -292,54 +248,6 @@ def test_opencode_description(self): assert ".opencode/" in desc -class TestShouldIntegrateCursor: - """Tests for should_integrate_cursor function.""" - - def test_cursor_target(self): - """Cursor integration enabled for cursor target.""" - assert should_integrate_cursor("cursor") is True - - def test_all_target(self): - """Cursor integration enabled for all target.""" - assert should_integrate_cursor("all") is True - - def test_vscode_target(self): - """Cursor integration disabled for vscode target.""" - assert should_integrate_cursor("vscode") is False - - def test_claude_target(self): - """Cursor integration disabled for claude target.""" - assert should_integrate_cursor("claude") is False - - def test_minimal_target(self): - """Cursor integration disabled for minimal target.""" - assert should_integrate_cursor("minimal") is False - - -class TestShouldIntegrateOpencode: - """Tests for should_integrate_opencode function.""" - - def test_opencode_target(self): - """OpenCode integration enabled for opencode target.""" - assert should_integrate_opencode("opencode") is True - - def test_all_target(self): - """OpenCode integration enabled for all target.""" - assert should_integrate_opencode("all") is True - - def test_vscode_target(self): - """OpenCode integration disabled for vscode target.""" - assert should_integrate_opencode("vscode") is False - - def test_claude_target(self): - """OpenCode integration disabled for claude target.""" - assert should_integrate_opencode("claude") is False - - def test_minimal_target(self): - """OpenCode integration disabled for minimal target.""" - assert should_integrate_opencode("minimal") is False - - class TestDetectTargetCursor: """Tests for auto-detection and explicit cursor target.""" From 55d5a5d534ba23fd2e2bdddf245728169264dede Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Sat, 25 Apr 2026 07:20:30 -0400 Subject: [PATCH 05/11] Update src/apm_cli/adapters/client/gemini.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/apm_cli/adapters/client/gemini.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apm_cli/adapters/client/gemini.py b/src/apm_cli/adapters/client/gemini.py index 2454990d2..42feb6e08 100644 --- a/src/apm_cli/adapters/client/gemini.py +++ b/src/apm_cli/adapters/client/gemini.py @@ -16,7 +16,7 @@ } APM only writes to ``.gemini/settings.json`` when the ``.gemini/`` -directory already exists — Gemini CLI support is opt-in. +directory already exists -- Gemini CLI support is opt-in. Ref: https://geminicli.com/docs/reference/configuration/ """ From 13e271787ebb80a89701f0027c62621506152121 Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Sat, 25 Apr 2026 07:22:50 -0400 Subject: [PATCH 06/11] Update tests/integration/test_golden_scenario_e2e.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/integration/test_golden_scenario_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_golden_scenario_e2e.py b/tests/integration/test_golden_scenario_e2e.py index b5a6990d2..f5e6a0ce3 100644 --- a/tests/integration/test_golden_scenario_e2e.py +++ b/tests/integration/test_golden_scenario_e2e.py @@ -108,7 +108,7 @@ def temp_e2e_home(): test_home = os.path.join(temp_dir, 'e2e_home') os.makedirs(test_home) - # Set up test environment — stash original HOME so tests can + # Set up test environment -- stash original HOME so tests can # recover credentials (e.g. ADC at ~/.config/gcloud/). os.environ['_APM_ORIGINAL_HOME'] = original_home or '' os.environ['HOME'] = test_home From 28b550f019a913b0915a70d58d90eb35d8d5e07a Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Sat, 25 Apr 2026 07:22:18 -0400 Subject: [PATCH 07/11] fix(changelog): add PR reference (#917) to Gemini CLI entry Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0217efee..982989477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - PGS project board sync: `scripts/project/sync_item.py` and `.github/workflows/project-sync.yml` keep https://github.com/orgs/microsoft/projects/2304 in lockstep with `theme/*`, `area/*`, `type/*`, `priority/*` labels and milestones. Backfill helper at `scripts/project/backfill.sh`. (#919) -- **Gemini CLI** as a supported APM target (`--target gemini`). APM auto-detects `.gemini/` directories and writes MCP server configuration to `.gemini/settings.json`. Includes `apm runtime setup gemini` / `apm runtime remove gemini` support. +- **Gemini CLI** as a supported APM target (`--target gemini`). APM auto-detects `.gemini/` directories and writes MCP server configuration to `.gemini/settings.json`. Includes `apm runtime setup gemini` / `apm runtime remove gemini` support. (#917) - New `pr-description-skill` skill bundle: enforces a 10-section PR body shape (TL;DR / Problem / Approach / Implementation / Diagrams / Trade-offs / Benefits / Validation / How to test, plus the `Co-authored-by` trailer) with a cite-or-omit rule for every WHY-claim, GFM-rendered output, ASCII-only template source, and validated mermaid diagrams. Captures the meta-pattern from PR #882 as a reusable scaffold so future PR bodies meet the same bar without per-PR specialist subagent intervention. (#884) - `apm experimental` command group -- a feature-flag registry with `list` / `enable` / `disable` / `reset` subcommands. Opt in to new behaviour before it graduates to default. Ships with one built-in flag (`verbose-version`) and a contributor recipe for proposing new flags. (#849) - `includes:` manifest field (auto | list) for explicit governance of local `.apm/` content. Closes audit-blindness gap (#887). From c5f464d1a4caf1ad1b55d610f8bcd8e423661676 Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Sat, 25 Apr 2026 07:38:33 -0400 Subject: [PATCH 08/11] Update README.md reference for Gemini --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 32117c842..f21045c99 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Think `package.json`, `requirements.txt`, or `Cargo.toml` — but for AI agent configuration. -GitHub Copilot · Claude Code · Cursor · OpenCode · Codex · Gemini CLI +GitHub Copilot · Claude Code · Cursor · OpenCode · Codex · Gemini **[Documentation](https://microsoft.github.io/apm/)** · **[Quick Start](https://microsoft.github.io/apm/getting-started/quick-start/)** · **[CLI Reference](https://microsoft.github.io/apm/reference/cli-commands/)** · **[Roadmap](https://github.com/orgs/microsoft/projects/2304)** From 04d4bfc9e4a35e6fc5e81722ac105ea3631a4366 Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Sat, 25 Apr 2026 09:05:13 -0400 Subject: [PATCH 09/11] fix(gemini): simplify GEMINI.md to import stub and update docs GEMINI.md now generates a thin stub containing `@./AGENTS.md` which leverages Gemini CLI's native import preprocessor instead of duplicating the full instruction roll-up. This ensures AGENTS.md is always compiled alongside GEMINI.md via should_compile_agents_md(). Also fixes doc consistency: adds .gemini/ to CI drift checks, commit guides, and auto-detection lists; removes inaccurate "no compile step" claims for Gemini; clarifies compile vs install behavior; removes broken test referencing deleted .gemini/rules/ support; and cleans up an unused import. Reviewed-by: APM Review Panel (Python Architect, CLI Logging Expert, DevX UX Expert, Supply Chain Security Expert, OSS Growth Hacker, CEO Arbiter) Reviewed-by: CodeRabbit (coderabbit review --agent) Reviewed-by: Claude Opus 4.7 (standalone code reviewer) Reviewed-by: Gemini 2.5 Pro (gemini-cli --sandbox) Reviewed-by: Technical Writer (documentation consistency reviewer) Co-Authored-By: Claude Opus 4.6 --- .../docs/getting-started/first-package.md | 11 +- .../docs/getting-started/quick-start.md | 14 +- docs/src/content/docs/guides/compilation.md | 23 ++-- docs/src/content/docs/integrations/ci-cd.md | 14 +- .../docs/integrations/github-rulesets.md | 2 +- .../docs/integrations/ide-tool-integration.md | 30 ++++- .../content/docs/introduction/how-it-works.md | 4 +- .../content/docs/introduction/what-is-apm.md | 5 +- docs/src/content/docs/introduction/why-apm.md | 4 +- .../content/docs/reference/cli-commands.md | 15 ++- src/apm_cli/adapters/client/gemini.py | 2 +- src/apm_cli/commands/compile/cli.py | 13 +- src/apm_cli/compilation/agents_compiler.py | 67 +++++++++- src/apm_cli/compilation/gemini_formatter.py | 123 ++++++++++++++++++ src/apm_cli/core/target_detection.py | 30 +++-- .../integration/instruction_integrator.py | 45 +------ src/apm_cli/integration/targets.py | 7 +- tests/integration/test_gemini_integration.py | 87 +------------ tests/integration/test_golden_scenario_e2e.py | 21 ++- .../compilation/test_compile_target_flag.py | 29 +++++ tests/unit/core/test_target_detection.py | 33 +++++ .../integration/test_data_driven_dispatch.py | 1 - .../test_instruction_integrator.py | 109 ---------------- 23 files changed, 382 insertions(+), 307 deletions(-) create mode 100644 src/apm_cli/compilation/gemini_formatter.py diff --git a/docs/src/content/docs/getting-started/first-package.md b/docs/src/content/docs/getting-started/first-package.md index c237f0645..aa58b25c4 100644 --- a/docs/src/content/docs/getting-started/first-package.md +++ b/docs/src/content/docs/getting-started/first-package.md @@ -194,14 +194,15 @@ team-skills/ `apm install` auto-detects which runtimes you have. The example above shows `.github/` because Copilot is the default fallback. If `.claude/`, `.cursor/`, -or `.opencode/` exists in the project, they get populated too. To target +`.opencode/`, or `.gemini/` exists in the project, they get populated too. To target explicitly, see the [Compilation guide](/apm/guides/compilation/). > **What about `apm compile`?** Compile is a different concern: it -> generates merged `AGENTS.md` / `CLAUDE.md` files for tools that read a -> single top-level context document (Codex, Gemini, plain `agents`-protocol -> hosts). Copilot, Claude Code, and Cursor read the per-skill directories -> directly -- no compile step needed. +> generates merged `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` files for tools +> that read a top-level context document for instructions (Codex, Gemini, +> plain `agents`-protocol hosts). Gemini also receives commands, skills, +> hooks, and MCP via `apm install`. Copilot, Claude Code, and Cursor read +> the per-skill directories directly -- no compile step needed. Now open Copilot or Claude in this project. Ask "draft a PR description for my last commit". The `pr-description` skill activates on its own. To get the diff --git a/docs/src/content/docs/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md index f7b2fe21c..59ff36acf 100644 --- a/docs/src/content/docs/getting-started/quick-start.md +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -92,12 +92,15 @@ my-project/ design-reviewer.md commands/ design-review.md + .gemini/ + commands/ + design-review.toml ``` Three things happened: 1. The package was downloaded into `apm_modules/` (like `node_modules/`). -2. Instructions, agents, and skills were deployed to `.github/`, `.claude/`, `.cursor/`, `.opencode/`, and `.gemini/` (when present) -- the native directories that GitHub Copilot, Claude, Cursor, OpenCode, and Gemini read from. If the project has its own `.apm/` content, that is deployed too (local content takes priority over dependencies on collision). +2. Agents, commands, skills, and hooks were deployed to `.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.codex/`, and `.gemini/` (when present). If the project has its own `.apm/` content, that is deployed too (local content takes priority over dependencies on collision). 3. A lockfile (`apm.lock.yaml`) was created, pinning the exact commit so every team member gets identical configuration. Your `apm.yml` now tracks the dependency: @@ -138,15 +141,16 @@ apm install github/awesome-copilot/skills/review-and-refactor - `apm.yml` and `apm.lock.yaml` — version-controlled, shared with the team. - `.github/` deployed files (`prompts/`, `agents/`, `instructions/`, `skills/`, `hooks/`) — commit them so every contributor (and [Copilot on github.com](https://docs.github.com/en/copilot)) gets agent context immediately after cloning, before they run `apm install` to sync and regenerate files. - `.claude/` deployed files (`agents/`, `commands/`, `skills/`, `hooks/`) — same rationale for Claude Code users: committed files give instant context on clone, while `apm install` remains the way to refresh them from `apm.yml`. -- `.cursor/` deployed files (`rules/`, `agents/`, `skills/`, `hooks/`) — same rationale for Cursor users. -- `apm_modules/` — add to `.gitignore`. Rebuilt from the lockfile on install. +- `.cursor/` deployed files (`rules/`, `agents/`, `skills/`, `hooks/`) -- same rationale for Cursor users. +- `.gemini/` deployed files (`commands/`, `skills/`, `settings.json`) -- same rationale for Gemini CLI users. +- `apm_modules/` -- add to `.gitignore`. Rebuilt from the lockfile on install. :::tip[Keeping deployed files in sync] -When you update `apm.yml`, re-run `apm install` and commit the changed `.github/`, `.claude/`, and `.cursor/` files. A [CI drift check](../../integrations/ci-cd/#verify-deployed-primitives) catches stale files automatically. +When you update `apm.yml`, re-run `apm install` and commit the changed `.github/`, `.claude/`, `.cursor/`, and `.gemini/` files. A [CI drift check](../../integrations/ci-cd/#verify-deployed-primitives) catches stale files automatically. ::: :::note[Using Codex or Gemini?] -These tools use different configuration formats. Run `apm compile` after installing to generate their native files. See the [Compilation guide](../../guides/compilation/) for details. +Gemini and Codex need `apm compile` for instructions (`GEMINI.md` / `AGENTS.md`). Gemini receives commands, skills, hooks, and MCP via `apm install`. See the [Compilation guide](../../guides/compilation/) for details. ::: ## Add MCP servers diff --git a/docs/src/content/docs/guides/compilation.md b/docs/src/content/docs/guides/compilation.md index 289f76f08..6e3d0883e 100644 --- a/docs/src/content/docs/guides/compilation.md +++ b/docs/src/content/docs/guides/compilation.md @@ -4,7 +4,7 @@ sidebar: order: 1 --- -Compilation is **optional for most users**. If your team uses GitHub Copilot, Claude, Cursor, or Gemini, `apm install` deploys all primitives in their native format -- you can skip this guide entirely. For OpenCode, `apm install` deploys agents, commands, skills, and MCP, but instructions require `apm compile` to generate the `AGENTS.md` that OpenCode reads. For Codex, `apm install` deploys skills, agents, and hooks natively, but instructions require `apm compile`. +Compilation is **optional for some users**. If your team uses GitHub Copilot, Claude, or Cursor, `apm install` deploys all primitives in their native format -- you can skip this guide entirely. For Gemini, `apm install` deploys commands, skills, and hooks, but instructions require `apm compile` to generate `GEMINI.md`. For OpenCode and Codex, `apm install` deploys agents, commands, skills, and hooks, but instructions require `apm compile` to generate `AGENTS.md`. **Solving the AI agent scalability problem through constraint satisfaction optimization** @@ -23,14 +23,16 @@ When you run `apm compile` without specifying a target, APM automatically detect | `.github/` folder only | `copilot` | AGENTS.md (instructions only) | | `.claude/` folder only | `claude` | CLAUDE.md (instructions only) | | `.codex/` folder exists | `codex` | AGENTS.md (instructions only) | -| Both folders exist | `all` | Both AGENTS.md and CLAUDE.md | +| `.gemini/` folder exists | `gemini` | GEMINI.md (instructions only) | +| Multiple folders exist | `all` | AGENTS.md + CLAUDE.md + GEMINI.md | | Neither folder exists | `minimal` | AGENTS.md only (universal format) | ```bash apm compile # Auto-detects target from project structure -apm compile --target copilot # Force GitHub Copilot, Cursor, Gemini -apm compile --target codex # Force Codex CLI +apm compile --target copilot # Force GitHub Copilot, Cursor apm compile --target claude # Force Claude Code, Claude Desktop +apm compile --target gemini # Force Gemini CLI +apm compile --target codex # Force Codex CLI apm compile -t claude,copilot # Multiple targets (comma-separated) ``` @@ -51,15 +53,16 @@ target: [claude, copilot] # multiple targets -- only these are compiled | Target | Files Generated | Consumers | |--------|-----------------|-----------| -| `copilot` | `AGENTS.md` | GitHub Copilot, Cursor, OpenCode, Gemini | -| `codex` | `AGENTS.md` | Codex CLI | +| `copilot` | `AGENTS.md` | GitHub Copilot, Cursor, OpenCode | | `claude` | `CLAUDE.md` | Claude Code, Claude Desktop | -| `all` | Both `AGENTS.md` and `CLAUDE.md` | Universal compatibility | +| `gemini` | `GEMINI.md` | Gemini CLI | +| `codex` | `AGENTS.md` | Codex CLI | +| `all` | `AGENTS.md` + `CLAUDE.md` + `GEMINI.md` | Universal compatibility | | `minimal` | `AGENTS.md` only | Works everywhere, no folder integration | > **Aliases**: `vscode` and `agents` are accepted as aliases for `copilot`. -> **Note**: `AGENTS.md` and `CLAUDE.md` contain **only instructions** (grouped by `applyTo` patterns). Prompts, agents, commands, hooks, and skills are integrated by `apm install`, not `apm compile`. See the [Integrations Guide](../../integrations/ide-tool-integration/) for details on how `apm install` populates `.github/prompts/`, `.github/agents/`, `.github/skills/`, `.claude/commands/`, `.cursor/rules/`, `.cursor/agents/`, `.opencode/agents/`, `.opencode/commands/`, `.codex/agents/`, and `.agents/skills/`. +> **Note**: `AGENTS.md`, `CLAUDE.md`, and `GEMINI.md` contain **only instructions** (grouped by `applyTo` patterns). Prompts, agents, commands, hooks, and skills are integrated by `apm install`, not `apm compile`. See the [Integrations Guide](../../integrations/ide-tool-integration/) for details on how `apm install` populates `.github/prompts/`, `.github/agents/`, `.github/skills/`, `.claude/commands/`, `.cursor/rules/`, `.cursor/agents/`, `.opencode/agents/`, `.opencode/commands/`, `.codex/agents/`, `.gemini/commands/`, and `.agents/skills/`. ### How It Works @@ -447,9 +450,9 @@ Different AI tools get different levels of support from `apm install` vs `apm co | Cursor | `.cursor/rules/`, `.cursor/agents/`, `.cursor/skills/`, `.cursor/hooks.json`, `.cursor/mcp.json` | `AGENTS.md` (optional) | **Full** | | OpenCode | `.opencode/agents/`, `.opencode/commands/`, `.opencode/skills/`, `opencode.json` (MCP) | Via `AGENTS.md` | **Full** | | Codex CLI | `.agents/skills/`, `.codex/agents/`, `.codex/hooks.json` | `AGENTS.md` (instructions) | **Full** | -| Gemini | `.gemini/rules/`, `.gemini/commands/`, `.gemini/skills/`, `.gemini/settings.json` (MCP, hooks) | Via `GEMINI.md` | **Full** | +| Gemini | `.gemini/commands/`, `.gemini/skills/`, `.gemini/settings.json` (MCP, hooks) | `GEMINI.md` (instructions) | **Full** | -For Copilot, Claude, Cursor, and Gemini users, `apm install` handles everything natively. OpenCode and Codex users should also run `apm compile` to generate `AGENTS.md` for instructions. Compilation is the bridge that brings instruction support to tools that do not yet have first-class APM integration. +For Copilot, Claude, and Cursor users, `apm install` handles everything natively. Gemini, OpenCode, and Codex users should also run `apm compile` to generate their instruction roll-up (`GEMINI.md` or `AGENTS.md`). ## Theoretical Foundations diff --git a/docs/src/content/docs/integrations/ci-cd.md b/docs/src/content/docs/integrations/ci-cd.md index 5c2221e44..166af21a4 100644 --- a/docs/src/content/docs/integrations/ci-cd.md +++ b/docs/src/content/docs/integrations/ci-cd.md @@ -28,7 +28,7 @@ jobs: - name: Install APM packages uses: microsoft/apm-action@v1 # Optional: add compile: true if targeting Codex, Gemini, - # or other tools without native APM integration + # or other tools whose instructions require compilation ``` ### Private Dependencies @@ -44,13 +44,13 @@ For private repositories, pass a token via the workflow `env:` block. See the [A ### Verify Compiled Output (Optional) -If your project uses `apm compile` to target tools like Cursor, Codex, or Gemini, add a check to ensure compiled output stays in sync: +If your project uses `apm compile` to target tools like Codex or Gemini, add a check to ensure compiled output stays in sync: ```yaml - name: Check for drift run: | apm compile - if [ -n "$(git status --porcelain -- AGENTS.md CLAUDE.md)" ]; then + if [ -n "$(git status --porcelain -- AGENTS.md CLAUDE.md GEMINI.md)" ]; then echo "Compiled output is out of date. Run 'apm compile' locally and commit." exit 1 fi @@ -60,13 +60,13 @@ This step is not needed if your team only uses GitHub Copilot and Claude, which ### Verify Deployed Primitives -To ensure `.github/`, `.claude/`, `.cursor/`, and `.opencode/` integration files stay in sync with `apm.yml`, add a drift check: +To ensure `.github/`, `.claude/`, `.cursor/`, `.opencode/`, and `.gemini/` integration files stay in sync with `apm.yml`, add a drift check: ```yaml - name: Check APM integration drift run: | apm install - if [ -n "$(git status --porcelain -- .github/ .claude/ .cursor/ .opencode/)" ]; then + if [ -n "$(git status --porcelain -- .github/ .claude/ .cursor/ .opencode/ .gemini/)" ]; then echo "APM integration files are out of date. Run 'apm install' and commit." exit 1 fi @@ -201,6 +201,6 @@ See the [Pack & Distribute guide](../../guides/pack-distribute/) for the full wo - **Pin APM version** in CI to avoid unexpected changes: `pip install apm-cli==0.7.7` - **Commit `apm.lock.yaml`** so CI resolves the same dependency versions as local development -- **Commit `.github/`, `.claude/`, `.cursor/`, and `.opencode/` deployed files** so contributors and cloud-based Copilot get agent context without running `apm install` -- **If using `apm compile`** (for Codex, Gemini), run it in CI and fail the build if the output differs from what's committed +- **Commit `.github/`, `.claude/`, `.cursor/`, `.opencode/`, and `.gemini/` deployed files** so contributors and cloud-based Copilot get agent context without running `apm install` +- **If using `apm compile`** (for Codex, Gemini instructions), run it in CI and fail the build if the output differs from what's committed - **Use `GITHUB_APM_PAT`** for private dependencies; never use the default `GITHUB_TOKEN` for cross-repo access diff --git a/docs/src/content/docs/integrations/github-rulesets.md b/docs/src/content/docs/integrations/github-rulesets.md index 26af8903d..448981d84 100644 --- a/docs/src/content/docs/integrations/github-rulesets.md +++ b/docs/src/content/docs/integrations/github-rulesets.md @@ -122,7 +122,7 @@ jobs: ### Separate Jobs for Granular Status -If your project uses `apm compile` (for Codex, Gemini, or other tools without native APM integration), you can add audit and compile as separate required checks: +If your project uses `apm compile` (for Codex, Gemini, or other tools whose instructions require compilation), you can add audit and compile as separate required checks: ```yaml jobs: diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index 5ff4c42e6..8de94b0dd 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -150,7 +150,7 @@ apm install microsoft/apm-sample-package ### Optional: Compiled Context with AGENTS.md -For tools that do not support granular primitive discovery, `apm compile` produces an `AGENTS.md` file that merges instructions into a single document. This is not needed for GitHub Copilot, Claude, Cursor, or Gemini, which read per-file instructions natively. OpenCode and Codex also read `AGENTS.md`, so run `apm compile` to deploy instructions there. +For tools that do not support granular primitive discovery, `apm compile` produces an `AGENTS.md` file that merges instructions into a single document. This is not needed for GitHub Copilot, Claude, or Cursor, which read per-file instructions natively. OpenCode and Codex also read `AGENTS.md`, so run `apm compile` to deploy instructions there. ```bash # Compile all local and dependency instructions into AGENTS.md @@ -227,6 +227,18 @@ APM natively integrates with OpenCode when a `.opencode/` directory exists in yo > **Note**: Skills deploy to `.agents/skills/` (the cross-tool agent skills standard directory), not `.codex/skills/`. Agents are transformed from `.agent.md` Markdown to `.toml` format. +#### Gemini CLI (`.gemini/`) + +| APM Primitive | Gemini Destination | Format | +|---|---|---| +| Commands (`.prompt.md`) | `.gemini/commands/*.toml` | Converted from Markdown to TOML | +| Skills (`SKILL.md`) | `.gemini/skills/{name}/` | Verbatim copy | +| Hooks (`.json`) | `.gemini/settings.json` | Merged into `hooks` key | +| MCP servers | `.gemini/settings.json` | Merged into `mcpServers` key | +| Instructions | Via `GEMINI.md` | Compile-only (`apm compile --target gemini`) | + +**Setup**: Create a `.gemini/` directory in your project root, then run `apm install`. APM detects the directory and deploys commands, skills, hooks, and MCP configuration automatically. For instructions, run `apm compile --target gemini` to generate `GEMINI.md` (a stub that imports `AGENTS.md`). + ### Automatic Agent Integration APM automatically deploys agent files from installed packages into `.claude/agents/`: @@ -313,13 +325,14 @@ apm install anthropics/claude-plugins-official/plugins/hookify 3. For Claude: merges hook definitions into `.claude/settings.json` under the `hooks` key 4. For Cursor: merges hook definitions into `.cursor/hooks.json` under the `hooks` key (only when `.cursor/` exists) 5. For Codex: merges hook definitions into `.codex/hooks.json` under the `hooks` key (only when `.codex/` exists) -6. Copies referenced scripts to the target location -7. Rewrites `${CLAUDE_PLUGIN_ROOT}` and relative script paths for the target platform -8. `apm uninstall` removes hook files and cleans up merged settings +6. For Gemini: merges hook definitions into `.gemini/settings.json` under the `hooks` key (only when `.gemini/` exists) +7. Copies referenced scripts to the target location +8. Rewrites `${CLAUDE_PLUGIN_ROOT}` and relative script paths for the target platform +9. `apm uninstall` removes hook files and cleans up merged settings ### Optional: Target-Specific Compilation -Compilation is optional for Copilot, Claude, Cursor, and Gemini, which read per-file instructions natively. For OpenCode and Codex, run `apm compile` to generate `AGENTS.md` for instructions: +Compilation is optional for Copilot, Claude, and Cursor, which read per-file instructions natively. For OpenCode, Codex, and Gemini, run `apm compile` to generate instruction files: ```bash # Generate all formats (default) @@ -332,6 +345,10 @@ apm compile --target claude # Generate only VS Code/Copilot formats apm compile --target copilot # Creates: AGENTS.md (instructions only) + +# Generate only Gemini formats +apm compile --target gemini +# Creates: GEMINI.md (imports AGENTS.md) ``` > **Remember**: `apm compile` generates instruction files only. Use `apm install` to integrate prompts, agents, instructions, commands, and skills from packages. @@ -473,6 +490,9 @@ APM configures MCP servers in the native config format for each supported client | VS Code | `.vscode/mcp.json` | JSON `servers` object | | GitHub Copilot CLI | `~/.copilot/mcp-config.json` | JSON `mcpServers` object | | Codex CLI | `~/.codex/config.toml` | TOML `mcp_servers` section | +| Claude | `.claude/settings.json` | JSON `mcpServers` object | +| Cursor | `.cursor/mcp.json` | JSON `mcpServers` object | +| Gemini CLI | `.gemini/settings.json` | JSON `mcpServers` object | **Runtime targeting**: APM detects which runtimes are installed and configures MCP servers for all of them. Use `--runtime ` or `--exclude ` to control which clients receive configuration. diff --git a/docs/src/content/docs/introduction/how-it-works.md b/docs/src/content/docs/introduction/how-it-works.md index a6f94e47a..bb511da98 100644 --- a/docs/src/content/docs/introduction/how-it-works.md +++ b/docs/src/content/docs/introduction/how-it-works.md @@ -123,7 +123,7 @@ graph TD 4. **AI Coding Agents** - Execute your compiled workflows (Copilot, Cursor, etc.) 5. **Supporting Infrastructure** - MCP servers for tools, LLM models for execution -GitHub Copilot and Claude read the deployed primitives natively. Cursor and OpenCode also receive native integration when `.cursor/` or `.opencode/` exists. For other tools (Codex, Gemini), `apm compile` generates an `agents.md` instruction file they can consume. +GitHub Copilot and Claude read the deployed primitives natively. Cursor, OpenCode, and Gemini also receive native integration when their config directories exist. For instructions, Codex and Gemini use `apm compile` to generate `AGENTS.md` / `GEMINI.md`. ## The Three Layers Explained @@ -259,8 +259,8 @@ For tools that read a single instructions file, `apm compile` merges your primit - **Cursor** - native integration to `.cursor/rules/`, `.cursor/agents/`, `.cursor/skills/`, `.cursor/hooks.json`, `.cursor/mcp.json` - **OpenCode** - native integration to `.opencode/agents/`, `.opencode/commands/`, `.opencode/skills/`, `opencode.json` (MCP) +- **Gemini** - native integration to `.gemini/commands/`, `.gemini/skills/`, `.gemini/settings.json` (MCP, hooks); instructions compiled to `GEMINI.md` - **Codex CLI** - compiled to `AGENTS.md` -- **Gemini** - compiled to `GEMINI.md` See the [Compilation guide](../../guides/compilation/) for details on output formats and options. diff --git a/docs/src/content/docs/introduction/what-is-apm.md b/docs/src/content/docs/introduction/what-is-apm.md index 75eb6f2b8..3029037cf 100644 --- a/docs/src/content/docs/introduction/what-is-apm.md +++ b/docs/src/content/docs/introduction/what-is-apm.md @@ -149,8 +149,8 @@ supported tool: | Claude | `.claude/` commands, skills, MCP | `CLAUDE.md` | **Full** | | Cursor | `.cursor/rules/`, `.cursor/agents/`, skills, hooks, MCP | `.cursor/rules/` (also via compile) | **Full** | | OpenCode | `.opencode/agents/`, `.opencode/commands/`, skills, MCP | Via `AGENTS.md` | **Full** | -| Codex CLI | — | `AGENTS.md` | Instructions via compile | -| Gemini | — | `GEMINI.md` | Instructions via compile | +| Codex CLI | -- | `AGENTS.md` | Instructions via compile | +| Gemini | `.gemini/commands/`, `.gemini/skills/`, `.gemini/settings.json` (MCP, hooks) | `GEMINI.md` (instructions) | **Full** | For tools with **Full** support, `apm install` deploys all primitives in their native format — no additional steps needed. For other tools, `apm compile` @@ -222,6 +222,7 @@ APM: - Your `AGENTS.md` still works with Copilot and Codex - Your `CLAUDE.md` still works with Claude +- Your `GEMINI.md` still works with Gemini - Your `.cursor/rules/` still work with Cursor - Your `.opencode/` files still work with OpenCode - Your `.github/prompts/` still work with Copilot diff --git a/docs/src/content/docs/introduction/why-apm.md b/docs/src/content/docs/introduction/why-apm.md index 3854839be..fbad6c97f 100644 --- a/docs/src/content/docs/introduction/why-apm.md +++ b/docs/src/content/docs/introduction/why-apm.md @@ -35,8 +35,8 @@ dependencies: Run `apm install` and APM: - **Resolves transitive dependencies** — if package A depends on package B, both are installed automatically. -- **Integrates primitives** -- instructions, prompts, agents, and skills are deployed to `.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.codex/`, and `.gemini/` based on which directories exist. GitHub Copilot, Claude, Cursor, OpenCode, Codex, and Gemini read these natively. -- **Bridges other tools** — for tools without native integration, `apm compile` generates compatible instruction files (`AGENTS.md`, `CLAUDE.md`). +- **Integrates primitives** -- prompts, agents, commands, skills, and hooks are deployed to `.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.codex/`, and `.gemini/` based on which directories exist. +- **Compiles instructions** -- `apm compile` generates instruction roll-ups (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`) that each tool reads natively. ## APM vs. Manual Setup diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 63c32627a..d80e8550c 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -264,6 +264,8 @@ APM automatically detects which integrations to enable based on your project str - **Claude integration**: Enabled when `.claude/` directory exists - **Cursor integration**: Enabled when `.cursor/` directory exists - **OpenCode integration**: Enabled when `.opencode/` directory exists +- **Codex integration**: Enabled when `.codex/` directory exists +- **Gemini integration**: Enabled when `.gemini/` directory exists - All integrations can coexist in the same project **VSCode Integration (`.github/` present):** @@ -370,6 +372,9 @@ apm uninstall -g microsoft/apm-sample-package | OpenCode agents | `.opencode/agents/*.md` | | OpenCode commands | `.opencode/commands/*.md` | | OpenCode skills | `.opencode/skills/{folder-name}/` | +| Gemini commands | `.gemini/commands/*.toml` | +| Gemini skills | `.gemini/skills/{folder-name}/` | +| Gemini settings | `.gemini/settings.json` (hooks + MCP cleaned) | | Lockfile entries | `apm.lock.yaml` (removed packages + orphaned transitives) | **Behavior:** @@ -597,6 +602,7 @@ apm pack -o dist/ | `claude` | `.claude/` | | `cursor` | `.cursor/` | | `opencode` | `.opencode/` | +| `gemini` | `.gemini/` | | `all` | all of the above | **Enriched lockfile example:** @@ -1369,7 +1375,7 @@ When `--target` is not specified, APM auto-detects based on existing project str | `.github/` exists only | `vscode` | AGENTS.md + .github/ | | `.claude/` exists only | `claude` | CLAUDE.md + .claude/ | | `.codex/` exists | `codex` | AGENTS.md + .codex/ + .agents/ | -| `.gemini/` exists | `gemini` | .gemini/settings.json | +| `.gemini/` exists | `gemini` | GEMINI.md + .gemini/ | | Both folders exist | `all` | All outputs | | Neither folder exists | `minimal` | AGENTS.md only | @@ -1390,10 +1396,11 @@ target: [claude, copilot] # multiple targets -- only these are compiled/install | Target | Output Files | Best For | |--------|--------------|----------| -| `vscode` | AGENTS.md, .github/prompts/, .github/agents/, .github/skills/ | GitHub Copilot, Cursor, Gemini | +| `vscode` | AGENTS.md, .github/prompts/, .github/agents/, .github/skills/ | GitHub Copilot, Cursor | | `claude` | CLAUDE.md, .claude/commands/, SKILL.md | Claude Code, Claude Desktop | | `codex` | AGENTS.md, .agents/skills/, .codex/agents/, .codex/hooks.json | Codex CLI | | `opencode` | AGENTS.md, .opencode/agents/, .opencode/commands/, .opencode/skills/ | OpenCode | +| `gemini` | GEMINI.md, .gemini/commands/, .gemini/skills/ | Gemini CLI | | `all` | All of the above | Universal compatibility | **Examples:** @@ -1621,7 +1628,7 @@ export APM_TEMP_DIR=/tmp/apm-work ### `apm runtime` (Experimental) - Manage AI runtimes -APM manages AI runtime installation and configuration automatically. Currently supports three runtimes: `copilot`, `codex`, and `llm`. +APM manages AI runtime installation and configuration automatically. Currently supports four runtimes: `copilot`, `codex`, `llm`, and `gemini`. > See the [Agent Workflows guide](../../guides/agent-workflows/) for usage details. @@ -1713,7 +1720,7 @@ apm runtime status ``` **Output includes:** -- Runtime preference order (copilot → codex → llm) +- Runtime preference order (copilot → codex → gemini → llm) - Currently active runtime - Next steps if no runtime is available diff --git a/src/apm_cli/adapters/client/gemini.py b/src/apm_cli/adapters/client/gemini.py index 42feb6e08..2b9b22494 100644 --- a/src/apm_cli/adapters/client/gemini.py +++ b/src/apm_cli/adapters/client/gemini.py @@ -27,7 +27,7 @@ from pathlib import Path from .copilot import CopilotClientAdapter -from ...utils.console import _rich_error, _rich_info, _rich_success +from ...utils.console import _rich_error, _rich_success logger = logging.getLogger(__name__) diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index f18074bec..1b559eef2 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -166,8 +166,10 @@ def _get_validation_suggestion(error_msg): def _resolve_compile_target(target): """Map CLI target input to compiler-understood target string. - The compiler only understands ``"vscode"``, ``"claude"``, and ``"all"``. - Multi-target lists are mapped to the narrowest equivalent. + The compiler understands ``"vscode"``, ``"claude"``, ``"gemini"``, + and ``"all"``. Multi-target lists are mapped to the narrowest + equivalent; any combination of two or more distinct compiler + families collapses to ``"all"``. Args: target: A single target string, a list of target strings, or ``None``. @@ -179,15 +181,18 @@ def _resolve_compile_target(target): return None # will trigger detect_target() auto-detection if isinstance(target, list): target_set = set(target) - # Any target that produces AGENTS.md (copilot/vscode/agents/cursor/opencode/codex) has_agents_family = bool( target_set & {"copilot", "vscode", "agents", "cursor", "opencode", "codex"} ) has_claude = "claude" in target_set - if has_agents_family and has_claude: + has_gemini = "gemini" in target_set + distinct = sum([has_agents_family, has_claude, has_gemini]) + if distinct >= 2: return "all" elif has_claude: return "claude" + elif has_gemini: + return "gemini" else: return "vscode" # agents-family only return target # single string pass-through diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index bc1d26dce..f6986b3be 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -20,7 +20,7 @@ ) from .link_resolver import resolve_markdown_links, validate_link_targets from ..utils.paths import portable_relpath -from ..core.target_detection import should_compile_agents_md, should_compile_claude_md +from ..core.target_detection import should_compile_agents_md, should_compile_claude_md, should_compile_gemini_md # User-facing target aliases that map to the canonical "vscode" target. @@ -238,8 +238,11 @@ def compile(self, config: CompilationConfig, primitives: Optional[PrimitiveColle if should_compile_claude_md(routing_target): results.append(self._compile_claude_md(config, primitives)) - # Some targets (e.g. gemini, cursor) use the data-driven - # integration layer and don't need AGENTS.md/CLAUDE.md compilation. + if should_compile_gemini_md(routing_target): + results.append(self._compile_gemini_md(config, primitives)) + + # Some targets (e.g. cursor) use the data-driven + # integration layer and don't need compilation. if not results: return CompilationResult( success=True, @@ -596,7 +599,63 @@ def _compile_claude_md(self, config: CompilationConfig, primitives: PrimitiveCol stats=stats, has_critical_security=critical_security_found, ) - + + def _compile_gemini_md(self, config: CompilationConfig, primitives: PrimitiveCollection) -> CompilationResult: + """Compile GEMINI.md stub that imports AGENTS.md. + + Gemini CLI supports ``@./path`` import syntax, so GEMINI.md is a + thin wrapper that pulls in AGENTS.md at load time. The actual + instruction roll-up is handled by the AGENTS.md pipeline (which + is always compiled alongside via ``should_compile_agents_md``). + + Args: + config: Compilation configuration. + primitives: Primitives to compile. + + Returns: + CompilationResult for the GEMINI.md compilation. + """ + from .gemini_formatter import GeminiFormatter + + gemini_formatter = GeminiFormatter(str(self.base_dir)) + gemini_result = gemini_formatter.format_distributed(primitives) + + all_warnings = self.warnings + gemini_result.warnings + all_errors = self.errors + gemini_result.errors + + if config.dry_run: + return CompilationResult( + success=len(all_errors) == 0, + output_path="Preview mode - GEMINI.md", + content="GEMINI.md Preview: Would generate stub importing AGENTS.md", + warnings=all_warnings, + errors=all_errors, + stats=gemini_result.stats, + ) + + files_written = 0 + for gemini_path, content in gemini_result.content_map.items(): + try: + gemini_path.parent.mkdir(parents=True, exist_ok=True) + gemini_path.write_text(content, encoding="utf-8") + files_written += 1 + except OSError as e: + all_errors.append(f"Failed to write {gemini_path}: {str(e)}") + + stats = gemini_result.stats.copy() + stats["gemini_files_written"] = files_written + + self._log("progress", f"[+] Generated GEMINI.md (imports AGENTS.md)") + + return CompilationResult( + success=len(all_errors) == 0, + output_path=f"GEMINI.md: {files_written} files", + content=f"Generated {files_written} GEMINI.md stub importing AGENTS.md", + warnings=all_warnings, + errors=all_errors, + stats=stats, + ) + def _merge_results(self, results: List[CompilationResult]) -> CompilationResult: """Merge multiple compilation results into a single result. diff --git a/src/apm_cli/compilation/gemini_formatter.py b/src/apm_cli/compilation/gemini_formatter.py new file mode 100644 index 000000000..ef78b2920 --- /dev/null +++ b/src/apm_cli/compilation/gemini_formatter.py @@ -0,0 +1,123 @@ +"""GEMINI.md formatter for Gemini CLI integration. + +Generates a lightweight GEMINI.md stub that imports AGENTS.md via Gemini +CLI's ``@path`` preprocessor. Gemini CLI resolves ``@./AGENTS.md`` at +load time, so there is no need to duplicate the instruction roll-up that +the AGENTS.md pipeline already produces. +""" + +import builtins +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Set + +from ..primitives.models import Instruction, PrimitiveCollection +from ..version import get_version +from .constants import BUILD_ID_PLACEHOLDER + +# CRITICAL: Shadow Click commands to prevent namespace collision +set = builtins.set +list = builtins.list +dict = builtins.dict + + +@dataclass +class GeminiPlacement: + """Result of GEMINI.md placement analysis.""" + gemini_path: Path + instructions: List[Instruction] + coverage_patterns: Set[str] = field(default_factory=set) + source_attribution: Dict[str, str] = field(default_factory=dict) + + +@dataclass +class GeminiCompilationResult: + """Result of GEMINI.md compilation.""" + success: bool + placements: List[GeminiPlacement] + content_map: Dict[Path, str] + warnings: List[str] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + stats: Dict[str, float] = field(default_factory=dict) + + +class GeminiFormatter: + """Formatter for generating GEMINI.md stub files. + + Generates a single GEMINI.md at the project root that imports + AGENTS.md via Gemini CLI's ``@path`` directive. The actual + instruction roll-up is produced by the AGENTS.md pipeline; + this formatter only creates the thin import wrapper. + """ + + def __init__(self, base_dir: str = ".") -> None: + try: + self.base_dir = Path(base_dir).resolve() + except (OSError, FileNotFoundError): + self.base_dir = Path(base_dir).absolute() + + self.warnings: List[str] = [] + self.errors: List[str] = [] + + def format_distributed( + self, + primitives: PrimitiveCollection, + placement_map: Optional[Dict[Path, List[Instruction]]] = None, + config: Optional[dict] = None, + ) -> GeminiCompilationResult: + """Generate a GEMINI.md stub that imports AGENTS.md. + + The *placement_map* argument is accepted for interface + compatibility with other formatters but is not used -- the + stub always lives at the project root. + """ + self.warnings.clear() + self.errors.clear() + + try: + gemini_path = self.base_dir / "GEMINI.md" + content = self._generate_stub() + + placement = GeminiPlacement( + gemini_path=gemini_path, + instructions=[], + coverage_patterns=set(), + source_attribution={}, + ) + + stats: Dict[str, float] = { + "gemini_files_generated": 1, + "primitives_found": primitives.count(), + } + + return GeminiCompilationResult( + success=True, + placements=[placement], + content_map={gemini_path: content}, + warnings=self.warnings.copy(), + errors=self.errors.copy(), + stats=stats, + ) + except Exception as e: + self.errors.append(f"GEMINI.md formatting failed: {str(e)}") + return GeminiCompilationResult( + success=False, + placements=[], + content_map={}, + warnings=self.warnings.copy(), + errors=self.errors.copy(), + stats={}, + ) + + def _generate_stub(self) -> str: + """Generate the GEMINI.md stub content.""" + lines = [ + "# GEMINI.md", + "", + BUILD_ID_PLACEHOLDER, + f"", + "", + "@./AGENTS.md", + "", + ] + return "\n".join(lines) diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 8916d71b6..7f1b12cd4 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -125,9 +125,9 @@ def detect_target( def should_compile_agents_md(target: TargetType) -> bool: """Check if AGENTS.md should be compiled. - - AGENTS.md is generated for vscode, codex, all, and minimal targets. - It's the universal format that works everywhere. + + AGENTS.md is generated for vscode, codex, gemini, all, and minimal + targets. Gemini needs it because GEMINI.md imports AGENTS.md. Args: target: The detected or configured target @@ -135,21 +135,33 @@ def should_compile_agents_md(target: TargetType) -> bool: Returns: bool: True if AGENTS.md should be generated """ - return target in ("vscode", "opencode", "codex", "all", "minimal") + return target in ("vscode", "opencode", "codex", "gemini", "all", "minimal") def should_compile_claude_md(target: TargetType) -> bool: """Check if CLAUDE.md should be compiled. - + Args: target: The detected or configured target - + Returns: bool: True if CLAUDE.md should be generated """ return target in ("claude", "all") +def should_compile_gemini_md(target: TargetType) -> bool: + """Check if GEMINI.md should be compiled. + + Args: + target: The detected or configured target + + Returns: + bool: True if GEMINI.md should be generated + """ + return target in ("gemini", "all") + + def get_target_description(target: UserTargetType) -> str: """Get a human-readable description of what will be generated for a target. @@ -169,8 +181,8 @@ def get_target_description(target: UserTargetType) -> str: "cursor": ".cursor/agents/ + .cursor/skills/ + .cursor/rules/", "opencode": "AGENTS.md + .opencode/agents/ + .opencode/commands/ + .opencode/skills/", "codex": "AGENTS.md + .agents/skills/ + .codex/agents/ + .codex/hooks.json", - "gemini": ".gemini/commands/ + .gemini/rules/ + .gemini/skills/ + .gemini/settings.json (MCP/hooks)", - "all": "AGENTS.md + CLAUDE.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .agents/", + "gemini": "GEMINI.md + .gemini/commands/ + .gemini/skills/ + .gemini/settings.json (MCP/hooks)", + "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .agents/", "minimal": "AGENTS.md only (create a target folder for full integration)", } return descriptions.get(normalized, "unknown target") @@ -201,7 +213,7 @@ def normalize_target_list( - ``None`` -> ``None`` (auto-detect) - ``"claude"`` -> ``["claude"]`` - ``"copilot"`` -> ``["vscode"]`` (alias resolution) - - ``"all"`` -> ``["claude", "codex", "copilot", "cursor", "opencode"]`` + - ``"all"`` -> ``["claude", "codex", "cursor", "gemini", "opencode", "vscode"]`` - ``["claude", "copilot"]`` -> ``["claude", "vscode"]`` - Deduplicates while preserving first-seen order. diff --git a/src/apm_cli/integration/instruction_integrator.py b/src/apm_cli/integration/instruction_integrator.py index c05bd12ec..2cf8e6437 100644 --- a/src/apm_cli/integration/instruction_integrator.py +++ b/src/apm_cli/integration/instruction_integrator.py @@ -28,7 +28,7 @@ class InstructionIntegrator(BaseIntegrator): * Copilot: ``.github/instructions/`` (verbatim, preserving applyTo:) * Cursor: ``.cursor/rules/`` (``.mdc`` format, applyTo: -> globs:) * Claude Code: ``.claude/rules/`` (``.md`` format, applyTo: -> paths:) - * Gemini CLI: ``.gemini/rules/`` (``.md`` format, frontmatter stripped) + * Gemini CLI: compile-only (GEMINI.md) -- no per-file rule deployment """ def find_instruction_files(self, package_path: Path) -> List[Path]: @@ -72,7 +72,6 @@ def integrate_instructions_for_target( * ``cursor_rules`` -- convert ``applyTo:`` to ``globs:`` frontmatter * ``claude_rules`` -- convert ``applyTo:`` to ``paths:`` frontmatter - * ``gemini_rules`` -- strip frontmatter (Gemini CLI has no path-scoping) * anything else -- copy verbatim (identity transform) """ mapping = target.primitives.get("instructions") @@ -93,7 +92,7 @@ def integrate_instructions_for_target( deploy_dir.mkdir(parents=True, exist_ok=True) fmt = mapping.format_id - needs_rename = fmt in ("cursor_rules", "claude_rules", "gemini_rules") + needs_rename = fmt in ("cursor_rules", "claude_rules") files_integrated = 0 files_skipped = 0 @@ -123,8 +122,6 @@ def integrate_instructions_for_target( links_resolved = self.copy_instruction_cursor(source_file, target_path) elif fmt == "claude_rules": links_resolved = self.copy_instruction_claude(source_file, target_path) - elif fmt == "gemini_rules": - links_resolved = self.copy_instruction_gemini(source_file, target_path) else: links_resolved = self.copy_instruction(source_file, target_path) @@ -156,9 +153,9 @@ def sync_for_target( legacy_dir = project_root / effective_root / mapping.subdir if mapping.format_id == "cursor_rules": legacy_pattern = "*.mdc" - elif mapping.format_id in ("claude_rules", "gemini_rules"): - # Do not use a broad legacy glob for Claude/Gemini rules to avoid - # deleting user-authored .md files under .claude/rules/ or .gemini/rules/. + elif mapping.format_id == "claude_rules": + # Do not use a broad legacy glob for Claude rules to avoid + # deleting user-authored .md files under .claude/rules/. legacy_pattern = None else: legacy_pattern = "*.instructions.md" @@ -387,35 +384,3 @@ def sync_integration_claude( managed_files=managed_files, ) - # ------------------------------------------------------------------ - # Gemini CLI Rules (.md, frontmatter stripped) - # ------------------------------------------------------------------ - - @staticmethod - def _convert_to_gemini_rules(content: str) -> str: - """Convert APM instruction content to Gemini CLI rules ``.md`` format. - - Strips APM-specific frontmatter (``applyTo``, ``description``, - etc.) since Gemini CLI has no path-scoping mechanism. Returns - the body as clean Markdown. - - Ref: https://geminicli.com/docs/cli/gemini-md/ - """ - body = content - - fm_match = re.match(r'^---\s*\n(.*?\n)?---\s*\n?', content, re.DOTALL) - if fm_match: - body = content[fm_match.end():] - - return body.lstrip("\n") - - def copy_instruction_gemini(self, source: Path, target: Path) -> int: - """Copy instruction file converted to Gemini CLI rules format. - - Strips frontmatter and resolves links. - """ - content = source.read_text(encoding='utf-8') - content = self._convert_to_gemini_rules(content) - content, links_resolved = self.resolve_links(content, source, target) - target.write_text(content, encoding='utf-8') - return links_resolved diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index a2b9602f2..554b6c59a 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -261,8 +261,8 @@ def for_scope(self, user_scope: bool = False) -> "TargetProfile | None": unsupported_user_primitives=("hooks",), ), # Gemini CLI -- ~/.gemini/ is the documented user-level config directory. - # Instructions deploy as individual .md files to .gemini/rules/; gemini-cli's - # JIT directory discovery or @import syntax picks them up from GEMINI.md. + # Instructions are compile-only (GEMINI.md) -- Gemini CLI does not read + # per-file rules from .gemini/rules/. # Commands are TOML files under .gemini/commands/. # Hooks merge into .gemini/settings.json (same pattern as Claude Code). # Ref: https://geminicli.com/docs/cli/gemini-md/ @@ -271,9 +271,6 @@ def for_scope(self, user_scope: bool = False) -> "TargetProfile | None": name="gemini", root_dir=".gemini", primitives={ - "instructions": PrimitiveMapping( - "rules", ".md", "gemini_rules" - ), "commands": PrimitiveMapping( "commands", ".toml", "gemini_command" ), diff --git a/tests/integration/test_gemini_integration.py b/tests/integration/test_gemini_integration.py index 7beeba264..ed2217f89 100644 --- a/tests/integration/test_gemini_integration.py +++ b/tests/integration/test_gemini_integration.py @@ -22,6 +22,8 @@ SkillIntegrator, ) from apm_cli.integration.command_integrator import CommandIntegrator +from typing import Optional + from apm_cli.models.apm_package import ( APMPackage, GitReferenceType, @@ -34,7 +36,7 @@ def _make_package_info( package_dir: Path, name: str = "test-pkg", - package_type: PackageType = None, + package_type: Optional[PackageType] = None, ) -> PackageInfo: """Build a minimal ``PackageInfo`` for offline tests.""" package = APMPackage(name=name, version="1.0.0", package_path=package_dir) @@ -123,65 +125,6 @@ def test_no_description_omits_key(self): assert "description" not in doc -@pytest.mark.integration -class TestGeminiInstructionIntegration: - """Instructions: .instructions.md -> .gemini/rules/*.md (frontmatter stripped)""" - - def setup_method(self): - self.tmp = tempfile.mkdtemp() - self.root = Path(self.tmp) - (self.root / ".gemini").mkdir() - - def teardown_method(self): - shutil.rmtree(self.tmp, ignore_errors=True) - - def _create_instruction(self, name: str, apply_to: str, body: str) -> Path: - pkg = self.root / "apm_modules" / "test-pkg" - pkg.mkdir(parents=True, exist_ok=True) - (pkg / "apm.yml").write_text("name: test-pkg\nversion: 1.0.0\n") - inst_dir = pkg / ".apm" / "instructions" - inst_dir.mkdir(parents=True, exist_ok=True) - inst = inst_dir / f"{name}.instructions.md" - inst.write_text( - f"---\napplyTo: '{apply_to}'\ndescription: test rule\n---\n{body}\n" - ) - return pkg - - def test_deploys_md_with_frontmatter_stripped(self): - body = "Always use snake_case for variables." - pkg = self._create_instruction("naming", "**/*.py", body) - info = _make_package_info(pkg) - target = KNOWN_TARGETS["gemini"] - - result = InstructionIntegrator().integrate_instructions_for_target( - target, info, self.root - ) - - assert result.files_integrated == 1 - rule_path = self.root / ".gemini" / "rules" / "naming.md" - assert rule_path.exists() - - content = rule_path.read_text() - assert "---" not in content - assert "applyTo" not in content - assert body in content - - def test_body_content_preserved(self): - body = "## Heading\n\n- bullet one\n- bullet two\n\nParagraph." - pkg = self._create_instruction("style", "**/*.ts", body) - info = _make_package_info(pkg) - target = KNOWN_TARGETS["gemini"] - - InstructionIntegrator().integrate_instructions_for_target( - target, info, self.root - ) - - content = (self.root / ".gemini" / "rules" / "style.md").read_text() - assert "## Heading" in content - assert "- bullet one" in content - assert "Paragraph." in content - - @pytest.mark.integration class TestGeminiSkillIntegration: """Skills: package dir -> .gemini/skills/{name}/SKILL.md (verbatim copy)""" @@ -371,27 +314,3 @@ def test_prompts_deployed_to_both_targets(self): assert (self.root / ".github" / "prompts" / "review.prompt.md").exists() assert (self.root / ".gemini" / "commands" / "review.toml").exists() - def test_instructions_transformed_differently_per_target(self): - pkg = self._create_full_package() - info = _make_package_info(pkg) - copilot = KNOWN_TARGETS["copilot"] - gemini = KNOWN_TARGETS["gemini"] - - inst = InstructionIntegrator() - inst.integrate_instructions_for_target(copilot, info, self.root) - inst.integrate_instructions_for_target(gemini, info, self.root) - - copilot_path = ( - self.root / ".github" / "instructions" / "style.instructions.md" - ) - gemini_path = self.root / ".gemini" / "rules" / "style.md" - assert copilot_path.exists() - assert gemini_path.exists() - - copilot_content = copilot_path.read_text() - gemini_content = gemini_path.read_text() - - assert "applyTo" in copilot_content - assert "applyTo" not in gemini_content - assert "Use black." in copilot_content - assert "Use black." in gemini_content diff --git a/tests/integration/test_golden_scenario_e2e.py b/tests/integration/test_golden_scenario_e2e.py index f5e6a0ce3..5b3d04975 100644 --- a/tests/integration/test_golden_scenario_e2e.py +++ b/tests/integration/test_golden_scenario_e2e.py @@ -113,13 +113,20 @@ def temp_e2e_home(): os.environ['_APM_ORIGINAL_HOME'] = original_home or '' os.environ['HOME'] = test_home - # Copy gcloud ADC credentials into the fake home so runtimes - # that rely on ADC (e.g. gemini-cli with Vertex AI) can auth. - real_gcloud = Path(original_home or '') / ".config" / "gcloud" - if real_gcloud.is_dir(): - fake_gcloud = Path(test_home) / ".config" / "gcloud" - fake_gcloud.parent.mkdir(parents=True, exist_ok=True) - shutil.copytree(str(real_gcloud), str(fake_gcloud)) + # Copy only the ADC credentials file (not the entire gcloud + # directory) so runtimes that rely on ADC (e.g. gemini-cli with + # Vertex AI) can auth without exposing service account keys. + real_adc = ( + Path(original_home or '') + / ".config" / "gcloud" / "application_default_credentials.json" + ) + if real_adc.is_file(): + fake_adc = ( + Path(test_home) + / ".config" / "gcloud" / "application_default_credentials.json" + ) + fake_adc.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(real_adc), str(fake_adc)) # Note: Do NOT override token environment variables here diff --git a/tests/unit/compilation/test_compile_target_flag.py b/tests/unit/compilation/test_compile_target_flag.py index 21b9177c0..dfa89cbea 100644 --- a/tests/unit/compilation/test_compile_target_flag.py +++ b/tests/unit/compilation/test_compile_target_flag.py @@ -203,6 +203,20 @@ def test_target_opencode_generates_agents_md(self, temp_project, sample_primitiv assert result.success assert "AGENTS.md" in result.output_path + def test_target_gemini_generates_gemini_md(self, temp_project, sample_primitives): + """target='gemini' must produce GEMINI.md, not a silent no-op.""" + config = CompilationConfig( + target="gemini", + dry_run=True, + ) + + compiler = AgentsCompiler(str(temp_project)) + result = compiler.compile(config, sample_primitives) + + assert result.success + assert result.output_path, "gemini target must route to a compiler, not return empty" + assert "GEMINI" in result.output_path + def test_target_minimal_generates_agents_md(self, temp_project, sample_primitives): """target='minimal' must route to AGENTS.md-only.""" config = CompilationConfig( @@ -1014,6 +1028,21 @@ def test_list_cursor_and_claude_returns_all(self): assert _resolve_compile_target(["cursor", "claude"]) == "all" assert _resolve_compile_target(["codex", "claude"]) == "all" + def test_list_gemini_only_returns_gemini(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + + assert _resolve_compile_target(["gemini"]) == "gemini" + + def test_list_gemini_and_claude_returns_all(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + + assert _resolve_compile_target(["gemini", "claude"]) == "all" + + def test_list_gemini_and_copilot_returns_all(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + + assert _resolve_compile_target(["gemini", "vscode"]) == "all" + def test_list_all_targets_returns_all(self): from apm_cli.commands.compile.cli import _resolve_compile_target diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index 4b35f3650..f2527c07d 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -4,6 +4,7 @@ detect_target, should_compile_agents_md, should_compile_claude_md, + should_compile_gemini_md, get_target_description, TargetParamType, VALID_TARGET_VALUES, @@ -188,6 +189,10 @@ def test_claude_target(self): """AGENTS.md not compiled for claude target.""" assert should_compile_agents_md("claude") is False + def test_gemini_target(self): + """AGENTS.md compiled for gemini target (GEMINI.md imports it).""" + assert should_compile_agents_md("gemini") is True + class TestShouldCompileClaudeMd: """Tests for should_compile_claude_md function.""" @@ -209,6 +214,34 @@ def test_minimal_target(self): assert should_compile_claude_md("minimal") is False +class TestShouldCompileGeminiMd: + """Tests for should_compile_gemini_md function.""" + + def test_gemini_target_returns_true(self): + """GEMINI.md compiled for gemini target.""" + assert should_compile_gemini_md("gemini") is True + + def test_all_target_returns_true(self): + """GEMINI.md compiled for all target.""" + assert should_compile_gemini_md("all") is True + + def test_claude_target_returns_false(self): + """GEMINI.md not compiled for claude target.""" + assert should_compile_gemini_md("claude") is False + + def test_vscode_target_returns_false(self): + """GEMINI.md not compiled for vscode target.""" + assert should_compile_gemini_md("vscode") is False + + def test_codex_target_returns_false(self): + """GEMINI.md not compiled for codex target.""" + assert should_compile_gemini_md("codex") is False + + def test_minimal_target_returns_false(self): + """GEMINI.md not compiled for minimal target.""" + assert should_compile_gemini_md("minimal") is False + + class TestGetTargetDescription: """Tests for get_target_description function.""" diff --git a/tests/unit/integration/test_data_driven_dispatch.py b/tests/unit/integration/test_data_driven_dispatch.py index a2ff2c345..20a61c971 100644 --- a/tests/unit/integration/test_data_driven_dispatch.py +++ b/tests/unit/integration/test_data_driven_dispatch.py @@ -281,7 +281,6 @@ def test_partition_parity_with_old_buckets(self): "commands_gemini", "commands_opencode", "instructions", # was instructions_copilot, aliased - "instructions_gemini", "rules_cursor", # was instructions_cursor, aliased "rules_claude", # was instructions_claude, aliased "skills", # cross-target bucket diff --git a/tests/unit/integration/test_instruction_integrator.py b/tests/unit/integration/test_instruction_integrator.py index 046e0206a..83634096c 100644 --- a/tests/unit/integration/test_instruction_integrator.py +++ b/tests/unit/integration/test_instruction_integrator.py @@ -1097,112 +1097,3 @@ def test_sync_handles_missing_rules_dir(self): assert result["errors"] == 0 -# =================================================================== -# Gemini CLI Rules (.md, frontmatter stripped) -# =================================================================== - - -class TestConvertToGeminiRules: - """Test the _convert_to_gemini_rules() frontmatter conversion helper.""" - - def test_strips_apply_to_frontmatter(self): - content = "---\napplyTo: 'src/**/*.py'\n---\n\n# Python rules" - result = InstructionIntegrator._convert_to_gemini_rules(content) - assert "applyTo" not in result - assert "---" not in result - assert "# Python rules" in result - - def test_preserves_body(self): - content = "---\napplyTo: '**/*.ts'\n---\n\n# TypeScript\n\nUse strict mode." - result = InstructionIntegrator._convert_to_gemini_rules(content) - assert "# TypeScript" in result - assert "Use strict mode." in result - - def test_no_frontmatter_returns_body(self): - content = "# Simple rules\n\nJust some guidelines." - result = InstructionIntegrator._convert_to_gemini_rules(content) - assert "# Simple rules" in result - assert "Just some guidelines." in result - - def test_strips_description_frontmatter(self): - content = "---\ndescription: General rules\napplyTo: '**/*.py'\n---\n\n# Rules" - result = InstructionIntegrator._convert_to_gemini_rules(content) - assert "description:" not in result - assert "applyTo" not in result - assert "# Rules" in result - - def test_empty_frontmatter(self): - content = "---\n---\n\n# Body only" - result = InstructionIntegrator._convert_to_gemini_rules(content) - assert "---" not in result - assert "# Body only" in result - - -class TestGeminiRulesIntegration: - """Test integrate_instructions_for_target() with gemini target.""" - - def setup_method(self): - self.temp_dir = tempfile.mkdtemp() - self.project_root = Path(self.temp_dir) - self.integrator = InstructionIntegrator() - - def teardown_method(self): - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_skips_when_no_gemini_dir(self): - """Returns empty result when .gemini/ doesn't exist.""" - pkg = self.project_root / "package" - inst_dir = pkg / ".apm" / "instructions" - inst_dir.mkdir(parents=True) - (inst_dir / "python.instructions.md").write_text("# Python") - - pkg_info = _make_package_info(pkg) - from apm_cli.integration.targets import KNOWN_TARGETS - result = self.integrator.integrate_instructions_for_target( - KNOWN_TARGETS["gemini"], pkg_info, self.project_root - ) - assert result.files_integrated == 0 - - def test_deploys_when_gemini_dir_exists(self): - """Deploys .md files when .gemini/ exists.""" - (self.project_root / ".gemini").mkdir() - - pkg = self.project_root / "package" - inst_dir = pkg / ".apm" / "instructions" - inst_dir.mkdir(parents=True) - (inst_dir / "python.instructions.md").write_text( - "---\napplyTo: 'src/**/*.py'\n---\n\n# Python rules" - ) - - pkg_info = _make_package_info(pkg) - from apm_cli.integration.targets import KNOWN_TARGETS - result = self.integrator.integrate_instructions_for_target( - KNOWN_TARGETS["gemini"], pkg_info, self.project_root - ) - - assert result.files_integrated == 1 - deployed = self.project_root / ".gemini" / "rules" / "python.md" - assert deployed.exists() - content = deployed.read_text() - assert "applyTo" not in content - assert "---" not in content - assert "# Python rules" in content - - def test_renames_instructions_md_to_md(self): - """File extension is changed from .instructions.md to .md.""" - (self.project_root / ".gemini").mkdir() - - pkg = self.project_root / "package" - inst_dir = pkg / ".apm" / "instructions" - inst_dir.mkdir(parents=True) - (inst_dir / "coding-style.instructions.md").write_text("# Coding style") - - pkg_info = _make_package_info(pkg) - from apm_cli.integration.targets import KNOWN_TARGETS - result = self.integrator.integrate_instructions_for_target( - KNOWN_TARGETS["gemini"], pkg_info, self.project_root - ) - - assert result.files_integrated == 1 - assert (self.project_root / ".gemini" / "rules" / "coding-style.md").exists() - assert not (self.project_root / ".gemini" / "rules" / "coding-style.instructions.md").exists() From a2599dd9c68b2a4f094e5a4967c8b61f86897625 Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Sat, 25 Apr 2026 11:15:50 -0400 Subject: [PATCH 10/11] fix(gemini): MCP config, hook mapping and format, tests, and doc fix - Reimplement _format_server_config for Gemini (no type/tools/id fields, httpUrl for HTTP remotes, url for SSE) - Map hook event names per target (preToolUse->BeforeTool, Stop->SessionEnd) - Transform flat Copilot hook entries to nested Gemini format (bash->command, timeoutSec->timeout in ms, hooks[] nesting) - Add unit tests for MCP config format and hook transformations - Fix hallucinated documentation links in test report Co-Authored-By: Claude Opus 4.6 --- .../docs/getting-started/quick-start.md | 2 +- src/apm_cli/adapters/client/gemini.py | 156 ++++++++++- src/apm_cli/integration/hook_integrator.py | 66 ++++- tests/integration/test_gemini_integration.py | 169 ++++++++++++ tests/integration/test_global_scope_e2e.py | 62 +++++ .../unit/compilation/test_gemini_formatter.py | 96 +++++++ .../unit/integration/test_hook_integrator.py | 256 ++++++++++++++++++ tests/unit/test_gemini_mcp.py | 94 +++++++ 8 files changed, 895 insertions(+), 6 deletions(-) create mode 100644 tests/unit/compilation/test_gemini_formatter.py diff --git a/docs/src/content/docs/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md index 59ff36acf..7b772d040 100644 --- a/docs/src/content/docs/getting-started/quick-start.md +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -115,7 +115,7 @@ dependencies: ## That's it -Open your editor. GitHub Copilot, Claude, Cursor, OpenCode, and Gemini pick up the new context immediately -- no extra configuration, no compile step, no restart. The agent now knows your project's design standards, can run your prompt templates, and follows the conventions defined in the package. +Open your editor. GitHub Copilot, Claude, Cursor, and OpenCode pick up the new context immediately -- no extra configuration, no compile step, no restart. The agent now knows your project's design standards, can run your prompt templates, and follows the conventions defined in the package. This is the core idea: **packages define what your AI agent knows, and `apm install` puts that knowledge exactly where your tools expect it.** diff --git a/src/apm_cli/adapters/client/gemini.py b/src/apm_cli/adapters/client/gemini.py index 2b9b22494..b601a83b9 100644 --- a/src/apm_cli/adapters/client/gemini.py +++ b/src/apm_cli/adapters/client/gemini.py @@ -1,7 +1,9 @@ """Gemini CLI implementation of MCP client adapter. Gemini CLI uses ``.gemini/settings.json`` at the project root with an -``mcpServers`` key. The schema is nearly identical to Copilot's: +``mcpServers`` key. Unlike Copilot, Gemini infers transport from which +key is present (``command`` for stdio, ``url`` for SSE, ``httpUrl`` for +streamable HTTP) and does not use ``type``, ``tools``, or ``id`` fields. .. code-block:: json @@ -27,6 +29,8 @@ from pathlib import Path from .copilot import CopilotClientAdapter +from ...core.docker_args import DockerArgsProcessor +from ...core.token_manager import GitHubTokenManager from ...utils.console import _rich_error, _rich_success logger = logging.getLogger(__name__) @@ -35,9 +39,9 @@ class GeminiClientAdapter(CopilotClientAdapter): """Gemini CLI MCP client adapter. - Reuses Copilot's config formatting (``mcpServers`` schema is - compatible) and writes to ``.gemini/settings.json`` in the - project root. + Inherits Copilot's helper methods for package selection, env-var + resolution, and argument processing but fully reimplements + ``_format_server_config`` to emit Gemini-valid JSON. """ supports_user_scope: bool = True @@ -82,6 +86,150 @@ def get_current_config(self): except (json.JSONDecodeError, IOError): return {} + def _format_server_config(self, server_info, env_overrides=None, runtime_vars=None): + """Format server info into Gemini CLI MCP configuration. + + Gemini's schema differs from Copilot's: + - No ``type``, ``tools``, or ``id`` fields. + - Transport inferred from key: ``command`` (stdio), ``url`` (SSE), + ``httpUrl`` (streamable HTTP). + - Tool filtering via ``includeTools``/``excludeTools``. + + Args: + server_info: Server information from registry. + env_overrides: Pre-collected environment variable overrides. + runtime_vars: Pre-collected runtime variable values. + + Returns: + dict suitable for writing to ``.gemini/settings.json``. + """ + if runtime_vars is None: + runtime_vars = {} + + config: dict = {} + + # --- raw stdio (self-defined deps) --- + raw = server_info.get("_raw_stdio") + if raw: + config["command"] = raw["command"] + config["args"] = raw["args"] + if raw.get("env"): + config["env"] = raw["env"] + self._warn_input_variables( + raw["env"], server_info.get("name", ""), "Gemini CLI" + ) + return config + + # --- remote endpoints --- + remotes = server_info.get("remotes", []) + if remotes: + remote = self._select_remote_with_url(remotes) or remotes[0] + + transport = (remote.get("transport_type") or "").strip() + if not transport: + transport = "http" + elif transport not in ("sse", "http", "streamable-http"): + raise ValueError( + f"Unsupported remote transport '{transport}' for Gemini. " + f"Server: {server_info.get('name', 'unknown')}. " + f"Supported transports: http, sse, streamable-http." + ) + + url = (remote.get("url") or "").strip() + if transport == "sse": + config["url"] = url + else: + config["httpUrl"] = url + + # GitHub server auth + server_name = server_info.get("name", "") + is_github = self._is_github_server( + server_name, remote.get("url", "") + ) + if is_github: + _tm = GitHubTokenManager() + token = _tm.get_token_for_purpose("gemini") or os.getenv( + "GITHUB_PERSONAL_ACCESS_TOKEN" + ) + if token: + config["headers"] = {"Authorization": f"Bearer {token}"} + + # Registry-supplied headers + for header in remote.get("headers", []): + name = header.get("name", "") + value = header.get("value", "") + if name and value: + config.setdefault("headers", {})[name] = ( + self._resolve_env_variable(name, value, env_overrides) + ) + + if config.get("headers"): + self._warn_input_variables( + config["headers"], server_info.get("name", ""), "Gemini CLI" + ) + + return config + + # --- local packages --- + packages = server_info.get("packages", []) + + if not packages: + raise ValueError( + f"MCP server has no package information or remote endpoints. " + f"Server: {server_info.get('name', 'unknown')}" + ) + + package = self._select_best_package(packages) + if not package: + return config + + registry_name = self._infer_registry_name(package) + package_name = package.get("name", "") + runtime_hint = package.get("runtime_hint", "") + runtime_arguments = package.get("runtime_arguments", []) + package_arguments = package.get("package_arguments", []) + env_vars = package.get("environment_variables", []) + + resolved_env = self._resolve_environment_variables( + env_vars, env_overrides + ) + processed_rt = self._process_arguments( + runtime_arguments, resolved_env, runtime_vars + ) + processed_pkg = self._process_arguments( + package_arguments, resolved_env, runtime_vars + ) + + if registry_name == "npm": + config["command"] = runtime_hint or "npx" + config["args"] = ["-y", package_name] + processed_rt + processed_pkg + elif registry_name == "docker": + config["command"] = "docker" + if processed_rt: + config["args"] = self._inject_env_vars_into_docker_args( + processed_rt, resolved_env + ) + else: + config["args"] = DockerArgsProcessor.process_docker_args( + ["run", "-i", "--rm", package_name], resolved_env + ) + elif registry_name == "pypi": + config["command"] = runtime_hint or "uvx" + config["args"] = [package_name] + processed_rt + processed_pkg + elif registry_name == "homebrew": + config["command"] = ( + package_name.split("/")[-1] if "/" in package_name else package_name + ) + config["args"] = processed_rt + processed_pkg + else: + config["command"] = runtime_hint or package_name + config["args"] = processed_rt + processed_pkg + + if resolved_env: + config["env"] = resolved_env + + return config + def configure_mcp_server( self, server_url, diff --git a/src/apm_cli/integration/hook_integrator.py b/src/apm_cli/integration/hook_integrator.py index 561b857c0..f8e12412c 100644 --- a/src/apm_cli/integration/hook_integrator.py +++ b/src/apm_cli/integration/hook_integrator.py @@ -85,6 +85,64 @@ class _MergeHookConfig: require_dir: bool # True = skip if target dir doesn't exist +# Per-target hook event name mapping. Packages are authored with +# Copilot (camelCase) or Claude (PascalCase) names; targets that use +# different conventions get their events renamed during merge. +_HOOK_EVENT_MAP: dict[str, dict[str, str]] = { + "gemini": { + # Copilot / Claude -> Gemini + "PreToolUse": "BeforeTool", + "preToolUse": "BeforeTool", + "PostToolUse": "AfterTool", + "postToolUse": "AfterTool", + "Stop": "SessionEnd", + }, +} + +def _to_gemini_hook_entries(entries: list) -> list: + """Transform hook entries into Gemini CLI format. + + Gemini requires ``{"hooks": [...]}`` nesting, uses ``command`` (not + ``bash``), and ``timeout`` in milliseconds (not ``timeoutSec`` in + seconds). Entries already in Claude/Gemini nested format are left + unchanged. + """ + result = [] + for entry in entries: + if not isinstance(entry, dict): + result.append(entry) + continue + # Already nested (Claude / Gemini format) -- just fix inner keys + if "hooks" in entry and isinstance(entry["hooks"], list): + for hook in entry["hooks"]: + _copilot_keys_to_gemini(hook) + result.append(entry) + continue + # Flat Copilot entry -- wrap in nested format + inner = dict(entry) + _copilot_keys_to_gemini(inner) + # Pull _apm_source to outer level (set later, but keep if present) + apm_source = inner.pop("_apm_source", None) + outer: dict = {"hooks": [inner]} + if apm_source: + outer["_apm_source"] = apm_source + result.append(outer) + return result + + +def _copilot_keys_to_gemini(hook: dict) -> None: + """Rename Copilot hook keys to Gemini equivalents in-place.""" + # bash / powershell -> command + if "command" not in hook: + for key in ("bash", "powershell", "windows"): + if key in hook: + hook["command"] = hook.pop(key) + break + # timeoutSec (seconds) -> timeout (milliseconds) + if "timeoutSec" in hook: + hook["timeout"] = hook.pop("timeoutSec") * 1000 + + _MERGE_HOOK_TARGETS: dict[str, _MergeHookConfig] = { "claude": _MergeHookConfig( config_filename="settings.json", @@ -530,12 +588,18 @@ def _integrate_merged_hooks( # Merge hooks into config (additive) hooks = rewritten.get("hooks", {}) - for event_name, entries in hooks.items(): + event_map = _HOOK_EVENT_MAP.get(config.target_key, {}) + for raw_event_name, entries in hooks.items(): if not isinstance(entries, list): continue + event_name = event_map.get(raw_event_name, raw_event_name) if event_name not in json_config["hooks"]: json_config["hooks"][event_name] = [] + # Transform flat Copilot entries to Gemini nested format + if config.target_key == "gemini": + entries = _to_gemini_hook_entries(entries) + # Mark each entry with APM source for sync/cleanup for entry in entries: if isinstance(entry, dict): diff --git a/tests/integration/test_gemini_integration.py b/tests/integration/test_gemini_integration.py index ed2217f89..d33bf8f9c 100644 --- a/tests/integration/test_gemini_integration.py +++ b/tests/integration/test_gemini_integration.py @@ -314,3 +314,172 @@ def test_prompts_deployed_to_both_targets(self): assert (self.root / ".github" / "prompts" / "review.prompt.md").exists() assert (self.root / ".gemini" / "commands" / "review.toml").exists() + +@pytest.mark.integration +class TestGeminiHookIntegration: + """Hooks: merged into .gemini/settings.json with _apm_source markers.""" + + def setup_method(self): + self.tmp = tempfile.mkdtemp() + self.root = Path(self.tmp) + (self.root / ".gemini").mkdir() + + def teardown_method(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _setup_hook_package(self, name: str = "test-hooks") -> PackageInfo: + pkg = self.root / "apm_modules" / name + hooks_dir = pkg / ".apm" / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + (hooks_dir / "hooks.json").write_text(json.dumps({ + "hooks": { + "preCommit": [ + {"type": "command", "command": "echo lint"} + ] + } + })) + return _make_package_info(pkg, name) + + def test_hooks_merge_into_settings_json(self): + """Hooks are merged into .gemini/settings.json with _apm_source.""" + from apm_cli.integration.hook_integrator import HookIntegrator + info = self._setup_hook_package() + target = KNOWN_TARGETS["gemini"] + + integrator = HookIntegrator() + result = integrator.integrate_hooks_for_target(target, info, self.root) + + assert result.files_integrated == 1 + settings = json.loads( + (self.root / ".gemini" / "settings.json").read_text() + ) + assert "hooks" in settings + assert "preCommit" in settings["hooks"] + assert settings["hooks"]["preCommit"][0]["_apm_source"] == "test-hooks" + + def test_hooks_preserve_existing_mcp_servers(self): + """Hook merge must not clobber existing mcpServers in settings.json.""" + settings_path = self.root / ".gemini" / "settings.json" + settings_path.write_text(json.dumps({ + "mcpServers": {"my-server": {"command": "npx", "args": ["-y", "foo"]}}, + "theme": "dark", + })) + + from apm_cli.integration.hook_integrator import HookIntegrator + info = self._setup_hook_package() + target = KNOWN_TARGETS["gemini"] + + integrator = HookIntegrator() + integrator.integrate_hooks_for_target(target, info, self.root) + + settings = json.loads(settings_path.read_text()) + assert settings["mcpServers"]["my-server"]["command"] == "npx" + assert settings["theme"] == "dark" + assert "hooks" in settings + assert "preCommit" in settings["hooks"] + + def test_sync_removes_hook_entries_preserves_mcp(self): + """Sync removes APM-managed hook entries but preserves mcpServers.""" + from apm_cli.integration.hook_integrator import HookIntegrator + settings_path = self.root / ".gemini" / "settings.json" + settings_path.write_text(json.dumps({ + "mcpServers": {"srv": {"command": "echo"}}, + "hooks": { + "preCommit": [ + {"_apm_source": "test-hooks", "hooks": [ + {"type": "command", "command": "echo lint"} + ]}, + ] + } + })) + + integrator = HookIntegrator() + target = KNOWN_TARGETS["gemini"] + integrator.sync_integration(None, self.root, targets=[target]) + + settings = json.loads(settings_path.read_text()) + assert settings["mcpServers"]["srv"]["command"] == "echo" + assert "hooks" not in settings + + def test_hooks_not_deployed_without_gemini_dir(self): + """Hooks are not deployed when .gemini/ does not exist.""" + shutil.rmtree(self.root / ".gemini") + + from apm_cli.integration.hook_integrator import HookIntegrator + info = self._setup_hook_package() + target = KNOWN_TARGETS["gemini"] + + integrator = HookIntegrator() + result = integrator.integrate_hooks_for_target(target, info, self.root) + + assert result.files_integrated == 0 + assert not (self.root / ".gemini").exists() + + +@pytest.mark.integration +class TestGeminiUninstallCleanup: + """Uninstall: verify .gemini/ files are cleaned up correctly.""" + + def setup_method(self): + self.tmp = tempfile.mkdtemp() + self.root = Path(self.tmp) + (self.root / ".gemini").mkdir() + + def teardown_method(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_uninstall_cleans_commands(self): + """Sync removes deployed commands from .gemini/commands/.""" + commands_dir = self.root / ".gemini" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "review.toml").write_text('prompt = "Review code"') + + managed_files = { + ".gemini/commands/review.toml", + } + + target = KNOWN_TARGETS["gemini"] + integrator = CommandIntegrator() + stats = integrator.sync_for_target( + target, None, self.root, managed_files=managed_files + ) + + assert stats["files_removed"] == 1 + assert not (commands_dir / "review.toml").exists() + + def test_uninstall_cleans_skills(self): + """Sync removes deployed skills from .gemini/skills/.""" + skills_dir = self.root / ".gemini" / "skills" / "style-checker" + skills_dir.mkdir(parents=True) + (skills_dir / "SKILL.md").write_text("# Skill\nCheck style.") + + managed_files = { + ".gemini/skills/style-checker", + } + + integrator = SkillIntegrator() + stats = integrator.sync_integration( + None, self.root, managed_files=managed_files + ) + + assert stats["files_removed"] == 1 + assert not skills_dir.exists() + + def test_uninstall_transitive_dep_cleans_skill(self): + """Transitive dep skill is cleaned from .gemini/skills/ on uninstall.""" + skill_dir = self.root / ".gemini" / "skills" / "review-and-refactor" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# Transitive skill") + + managed_files = { + ".gemini/skills/review-and-refactor", + } + + integrator = SkillIntegrator() + stats = integrator.sync_integration( + None, self.root, managed_files=managed_files + ) + + assert stats["files_removed"] == 1 + assert not skill_dir.exists() + diff --git a/tests/integration/test_global_scope_e2e.py b/tests/integration/test_global_scope_e2e.py index 84a4fda60..c424cc2b9 100644 --- a/tests/integration/test_global_scope_e2e.py +++ b/tests/integration/test_global_scope_e2e.py @@ -362,6 +362,68 @@ def test_user_root_strings_are_relative(self): # --------------------------------------------------------------------------- +class TestGlobalGeminiScope: + """Verify user-scope install/uninstall deploys to ~/.gemini/.""" + + def test_global_install_creates_gemini_dirs( + self, apm_command, fake_home, local_package + ): + """--global should deploy primitives to ~/.gemini/ when .gemini/ exists.""" + gemini_dir = fake_home / ".gemini" + gemini_dir.mkdir() + + result = _run_apm( + apm_command, + ["install", "--global", str(local_package)], + fake_home, + fake_home, + ) + combined = result.stdout + result.stderr + assert "gemini" in combined.lower(), ( + f"Gemini not mentioned in output: {combined}" + ) + + def test_global_install_mentions_gemini_full_support( + self, apm_command, fake_home + ): + """--global output should list gemini as fully supported.""" + gemini_dir = fake_home / ".gemini" + gemini_dir.mkdir() + + result = _run_apm( + apm_command, ["install", "--global"], fake_home, fake_home, + ) + combined = result.stdout + result.stderr + assert "gemini" in combined.lower(), ( + f"Gemini not in scope support message: {combined}" + ) + + def test_global_uninstall_runs_in_user_scope( + self, apm_command, fake_home, local_package + ): + """Uninstall --global with .gemini/ present operates in user scope.""" + gemini_dir = fake_home / ".gemini" + gemini_dir.mkdir() + + _run_apm( + apm_command, + ["install", "--global", str(local_package)], + fake_home, + fake_home, + ) + + result = _run_apm( + apm_command, + ["uninstall", "--global", "local-pkg"], + fake_home, + fake_home, + ) + combined = result.stdout + result.stderr + assert "user scope" in combined.lower(), ( + f"Uninstall did not run in user scope: {combined}" + ) + + class TestGlobalUninstallLifecycle: """Test uninstall --global removes packages from user-scope metadata.""" diff --git a/tests/unit/compilation/test_gemini_formatter.py b/tests/unit/compilation/test_gemini_formatter.py new file mode 100644 index 000000000..b21434177 --- /dev/null +++ b/tests/unit/compilation/test_gemini_formatter.py @@ -0,0 +1,96 @@ +"""Unit tests for GeminiFormatter -- GEMINI.md stub generation.""" + +from pathlib import Path + +import pytest + +from apm_cli.compilation.gemini_formatter import ( + GeminiFormatter, + GeminiPlacement, + GeminiCompilationResult, +) +from apm_cli.compilation.constants import BUILD_ID_PLACEHOLDER +from apm_cli.primitives.models import PrimitiveCollection +from apm_cli.version import get_version + + +class TestGeminiFormatterInit: + """Tests for GeminiFormatter initialization.""" + + def test_init_with_valid_directory(self, tmp_path: Path) -> None: + """Test initialization with a valid directory path.""" + formatter = GeminiFormatter(str(tmp_path)) + assert formatter.base_dir == tmp_path + assert formatter.warnings == [] + assert formatter.errors == [] + + def test_init_with_default_directory(self) -> None: + """Test initialization with default current directory.""" + formatter = GeminiFormatter() + assert formatter.base_dir.exists() + + +class TestGeminiFormatterFormatDistributed: + """Tests for format_distributed() stub generation.""" + + def test_returns_successful_result(self, tmp_path: Path) -> None: + """format_distributed returns a successful result with one placement.""" + formatter = GeminiFormatter(str(tmp_path)) + primitives = PrimitiveCollection() + + result = formatter.format_distributed(primitives) + + assert isinstance(result, GeminiCompilationResult) + assert result.success is True + assert len(result.placements) == 1 + assert len(result.content_map) == 1 + assert result.errors == [] + + def test_placement_points_to_gemini_md(self, tmp_path: Path) -> None: + """The single placement should target GEMINI.md at the project root.""" + formatter = GeminiFormatter(str(tmp_path)) + result = formatter.format_distributed(PrimitiveCollection()) + + placement = result.placements[0] + assert isinstance(placement, GeminiPlacement) + assert placement.gemini_path == tmp_path / "GEMINI.md" + + def test_stub_contains_agents_import(self, tmp_path: Path) -> None: + """The generated stub must contain the @./AGENTS.md import.""" + formatter = GeminiFormatter(str(tmp_path)) + result = formatter.format_distributed(PrimitiveCollection()) + + content = list(result.content_map.values())[0] + assert "@./AGENTS.md" in content + + def test_stub_contains_header_and_version(self, tmp_path: Path) -> None: + """The stub should contain the APM header, build ID, and version.""" + formatter = GeminiFormatter(str(tmp_path)) + result = formatter.format_distributed(PrimitiveCollection()) + + content = list(result.content_map.values())[0] + assert "# GEMINI.md" in content + assert "Generated by APM CLI" in content + assert BUILD_ID_PLACEHOLDER in content + assert get_version() in content + + def test_stats_reflect_primitives_count(self, tmp_path: Path) -> None: + """Stats should include the primitive count from the collection.""" + formatter = GeminiFormatter(str(tmp_path)) + result = formatter.format_distributed(PrimitiveCollection()) + + assert result.stats["gemini_files_generated"] == 1 + assert "primitives_found" in result.stats + + def test_placement_map_is_ignored(self, tmp_path: Path) -> None: + """placement_map is accepted for interface compat but not used.""" + formatter = GeminiFormatter(str(tmp_path)) + fake_map = {Path("some/dir"): []} + + result = formatter.format_distributed( + PrimitiveCollection(), placement_map=fake_map + ) + + assert result.success is True + assert len(result.placements) == 1 + assert result.placements[0].gemini_path == tmp_path / "GEMINI.md" diff --git a/tests/unit/integration/test_hook_integrator.py b/tests/unit/integration/test_hook_integrator.py index 8a7a63acf..4e2f6ffa3 100644 --- a/tests/unit/integration/test_hook_integrator.py +++ b/tests/unit/integration/test_hook_integrator.py @@ -2082,6 +2082,262 @@ def test_codex_hooks_not_deployed_without_codex_dir(self): assert result.files_integrated == 0 +# --- Gemini hook integration tests ----------------------------------------------- + + +class TestGeminiHookIntegration: + """Tests for Gemini hook integration (.gemini/settings.json merge).""" + + @pytest.fixture + def temp_project(self): + temp_dir = tempfile.mkdtemp() + project = Path(temp_dir) + (project / ".gemini").mkdir() + yield project + shutil.rmtree(temp_dir, ignore_errors=True) + + def _setup_hook_package(self, project: Path, name: str = "test-hooks") -> PackageInfo: + pkg_dir = project / "apm_modules" / name + hooks_dir = pkg_dir / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + (hooks_dir / "hooks.json").write_text(json.dumps(RALPH_LOOP_HOOKS_JSON)) + (hooks_dir / "stop-hook.sh").write_text("#!/bin/bash\nexit 0") + return _make_package_info(pkg_dir, name) + + def test_integrate_hooks_gemini(self, temp_project): + """Test Gemini integration merges hooks into settings.json.""" + from apm_cli.integration.targets import KNOWN_TARGETS + pkg_info = self._setup_hook_package(temp_project, "ralph-loop") + target = KNOWN_TARGETS["gemini"] + integrator = HookIntegrator() + + result = integrator.integrate_hooks_for_target(target, pkg_info, temp_project) + + assert result.files_integrated == 1 + settings = json.loads( + (temp_project / ".gemini" / "settings.json").read_text() + ) + assert "hooks" in settings + # "Stop" is mapped to "SessionEnd" for Gemini + assert "SessionEnd" in settings["hooks"] + assert "Stop" not in settings["hooks"] + assert settings["hooks"]["SessionEnd"][0]["_apm_source"] == "ralph-loop" + + def test_skips_when_no_gemini_dir(self, temp_project): + """Gemini hooks are not deployed when .gemini/ does not exist.""" + shutil.rmtree(temp_project / ".gemini") + + from apm_cli.integration.targets import KNOWN_TARGETS + pkg_info = self._setup_hook_package(temp_project, "ralph-loop") + target = KNOWN_TARGETS["gemini"] + integrator = HookIntegrator() + + result = integrator.integrate_hooks_for_target(target, pkg_info, temp_project) + + assert result.files_integrated == 0 + assert not (temp_project / ".gemini").exists() + + def test_merge_preserves_existing_keys(self, temp_project): + """Hook merge preserves mcpServers and other top-level keys.""" + settings_path = temp_project / ".gemini" / "settings.json" + settings_path.write_text(json.dumps({ + "mcpServers": {"srv": {"command": "echo"}}, + "theme": "dark", + })) + + from apm_cli.integration.targets import KNOWN_TARGETS + pkg_info = self._setup_hook_package(temp_project, "ralph-loop") + target = KNOWN_TARGETS["gemini"] + integrator = HookIntegrator() + + integrator.integrate_hooks_for_target(target, pkg_info, temp_project) + + settings = json.loads(settings_path.read_text()) + assert settings["mcpServers"]["srv"]["command"] == "echo" + assert settings["theme"] == "dark" + assert "SessionEnd" in settings["hooks"] + + def test_additive_merge_same_event(self, temp_project): + """Multiple packages can add hooks to the same event.""" + from apm_cli.integration.targets import KNOWN_TARGETS + target = KNOWN_TARGETS["gemini"] + integrator = HookIntegrator() + + pkg1_info = self._setup_hook_package(temp_project, "ralph-loop") + integrator.integrate_hooks_for_target(target, pkg1_info, temp_project) + + pkg2_dir = temp_project / "apm_modules" / "other-pkg" + hooks2_dir = pkg2_dir / "hooks" + hooks2_dir.mkdir(parents=True, exist_ok=True) + (hooks2_dir / "hooks.json").write_text(json.dumps({ + "hooks": {"Stop": [{"hooks": [ + {"type": "command", "command": "echo other-stop"} + ]}]} + })) + pkg2_info = _make_package_info(pkg2_dir, "other-pkg") + integrator.integrate_hooks_for_target(target, pkg2_info, temp_project) + + settings = json.loads( + (temp_project / ".gemini" / "settings.json").read_text() + ) + # Both "Stop" entries land under "SessionEnd" after mapping + assert len(settings["hooks"]["SessionEnd"]) == 2 + + def test_reinstall_is_idempotent(self, temp_project): + """Re-running integration does not duplicate hook entries.""" + from apm_cli.integration.targets import KNOWN_TARGETS + target = KNOWN_TARGETS["gemini"] + pkg_info = self._setup_hook_package(temp_project, "ralph-loop") + integrator = HookIntegrator() + + integrator.integrate_hooks_for_target(target, pkg_info, temp_project) + first = (temp_project / ".gemini" / "settings.json").read_text() + + for _ in range(2): + integrator.integrate_hooks_for_target(target, pkg_info, temp_project) + + settings = json.loads( + (temp_project / ".gemini" / "settings.json").read_text() + ) + assert len(settings["hooks"]["SessionEnd"]) == 1 + assert (temp_project / ".gemini" / "settings.json").read_text() == first + + def test_sync_removes_gemini_hook_entries(self, temp_project): + """Sync removes APM-managed entries from .gemini/settings.json.""" + from apm_cli.integration.targets import KNOWN_TARGETS + settings_path = temp_project / ".gemini" / "settings.json" + settings_path.write_text(json.dumps({ + "mcpServers": {"srv": {"command": "echo"}}, + "hooks": { + "SessionEnd": [ + {"_apm_source": "ralph-loop", "hooks": [ + {"type": "command", "command": "..."} + ]}, + {"hooks": [{"type": "command", "command": "echo user-hook"}]}, + ], + } + })) + + target = KNOWN_TARGETS["gemini"] + integrator = HookIntegrator() + integrator.sync_integration(None, temp_project, targets=[target]) + + settings = json.loads(settings_path.read_text()) + assert settings["mcpServers"]["srv"]["command"] == "echo" + assert "SessionEnd" in settings["hooks"] + assert len(settings["hooks"]["SessionEnd"]) == 1 + assert "_apm_source" not in settings["hooks"]["SessionEnd"][0] + + def test_sync_removes_empty_hooks_key(self, temp_project): + """Empty hooks key is removed after sync cleanup.""" + from apm_cli.integration.targets import KNOWN_TARGETS + settings_path = temp_project / ".gemini" / "settings.json" + settings_path.write_text(json.dumps({ + "mcpServers": {"srv": {"command": "echo"}}, + "hooks": { + "SessionEnd": [{"_apm_source": "test", "hooks": []}] + } + })) + + target = KNOWN_TARGETS["gemini"] + integrator = HookIntegrator() + integrator.sync_integration(None, temp_project, targets=[target]) + + settings = json.loads(settings_path.read_text()) + assert "hooks" not in settings + assert "mcpServers" in settings + + def test_event_name_mapping_pretooluse_to_beforetool(self, temp_project): + """preToolUse (Copilot convention) maps to BeforeTool for Gemini.""" + from apm_cli.integration.targets import KNOWN_TARGETS + + pkg_dir = temp_project / "apm_modules" / "lint-pkg" + hooks_dir = pkg_dir / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + (hooks_dir / "hooks.json").write_text(json.dumps({ + "hooks": { + "preToolUse": [{"hooks": [ + {"type": "command", "command": "echo lint"} + ]}], + "postToolUse": [{"hooks": [ + {"type": "command", "command": "echo done"} + ]}], + } + })) + pkg_info = _make_package_info(pkg_dir, "lint-pkg") + + target = KNOWN_TARGETS["gemini"] + integrator = HookIntegrator() + integrator.integrate_hooks_for_target(target, pkg_info, temp_project) + + settings = json.loads( + (temp_project / ".gemini" / "settings.json").read_text() + ) + assert "BeforeTool" in settings["hooks"] + assert "AfterTool" in settings["hooks"] + assert "preToolUse" not in settings["hooks"] + assert "postToolUse" not in settings["hooks"] + + def test_unmapped_events_pass_through(self, temp_project): + """Gemini-native events (BeforeAgent etc.) pass through unchanged.""" + from apm_cli.integration.targets import KNOWN_TARGETS + + pkg_dir = temp_project / "apm_modules" / "agent-pkg" + hooks_dir = pkg_dir / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + (hooks_dir / "hooks.json").write_text(json.dumps({ + "hooks": { + "BeforeAgent": [{"hooks": [ + {"type": "command", "command": "echo agent"} + ]}], + } + })) + pkg_info = _make_package_info(pkg_dir, "agent-pkg") + + target = KNOWN_TARGETS["gemini"] + integrator = HookIntegrator() + integrator.integrate_hooks_for_target(target, pkg_info, temp_project) + + settings = json.loads( + (temp_project / ".gemini" / "settings.json").read_text() + ) + assert "BeforeAgent" in settings["hooks"] + + def test_flat_copilot_entries_become_nested_gemini(self, temp_project): + """Flat Copilot hook entries (bash, timeoutSec) are transformed to Gemini format.""" + from apm_cli.integration.targets import KNOWN_TARGETS + + pkg_dir = temp_project / "apm_modules" / "flat-pkg" + hooks_dir = pkg_dir / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + (hooks_dir / "hooks.json").write_text(json.dumps({ + "hooks": { + "preToolUse": [ + {"type": "command", "bash": "echo lint", "timeoutSec": 10} + ], + } + })) + pkg_info = _make_package_info(pkg_dir, "flat-pkg") + + target = KNOWN_TARGETS["gemini"] + integrator = HookIntegrator() + integrator.integrate_hooks_for_target(target, pkg_info, temp_project) + + settings = json.loads( + (temp_project / ".gemini" / "settings.json").read_text() + ) + assert "BeforeTool" in settings["hooks"] + entry = settings["hooks"]["BeforeTool"][0] + # Must be nested: outer has "hooks" list, inner has "command" not "bash" + assert "hooks" in entry + inner = entry["hooks"][0] + assert inner["command"] == "echo lint" + assert "bash" not in inner + # timeoutSec converted to timeout in milliseconds + assert inner["timeout"] == 10000 + assert "timeoutSec" not in inner + + # ─── Scope-resolved target tests (PR #566 rework) ──────────────────────────── diff --git a/tests/unit/test_gemini_mcp.py b/tests/unit/test_gemini_mcp.py index b247e52e9..8e78cbf4b 100644 --- a/tests/unit/test_gemini_mcp.py +++ b/tests/unit/test_gemini_mcp.py @@ -164,3 +164,97 @@ def test_uses_explicit_server_name(self): def test_supports_user_scope_is_true(self): self.assertTrue(self.adapter.supports_user_scope) + + +class TestGeminiFormatServerConfig(unittest.TestCase): + """Verify _format_server_config produces Gemini-valid schema.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.gemini_dir = Path(self.tmp.name) / ".gemini" + self.gemini_dir.mkdir() + self._cwd_patcher = patch("os.getcwd", return_value=self.tmp.name) + self._cwd_patcher.start() + + self.mock_registry_patcher = patch( + "apm_cli.adapters.client.copilot.SimpleRegistryClient" + ) + self.mock_registry_class = self.mock_registry_patcher.start() + + self.mock_integration_patcher = patch( + "apm_cli.adapters.client.copilot.RegistryIntegration" + ) + self.mock_integration_class = self.mock_integration_patcher.start() + + self.adapter = GeminiClientAdapter() + + def tearDown(self): + self._cwd_patcher.stop() + self.mock_registry_patcher.stop() + self.mock_integration_patcher.stop() + self.tmp.cleanup() + + def test_stdio_config_has_no_copilot_fields(self): + """stdio config must not contain type, tools, or id.""" + server_info = { + "_raw_stdio": { + "command": "node", + "args": ["server.js"], + "env": {"KEY": "val"}, + }, + "name": "test-server", + } + config = self.adapter._format_server_config(server_info) + self.assertEqual(config["command"], "node") + self.assertEqual(config["args"], ["server.js"]) + self.assertEqual(config["env"], {"KEY": "val"}) + self.assertNotIn("type", config) + self.assertNotIn("tools", config) + self.assertNotIn("id", config) + + def test_npm_package_config_has_no_copilot_fields(self): + """npm package config must not contain type, tools, or id.""" + server_info = { + "packages": [{ + "name": "@scope/mcp-server", + "registry_name": "npm", + "runtime_hint": "npx", + }], + "name": "test-server", + } + config = self.adapter._format_server_config(server_info) + self.assertEqual(config["command"], "npx") + self.assertIn("@scope/mcp-server", config["args"]) + self.assertNotIn("type", config) + self.assertNotIn("tools", config) + self.assertNotIn("id", config) + + def test_remote_http_uses_httpUrl(self): + """HTTP remotes must use httpUrl key, not url.""" + server_info = { + "remotes": [{ + "url": "https://api.example.com/mcp", + "transport_type": "http", + }], + "name": "remote-server", + } + config = self.adapter._format_server_config(server_info) + self.assertEqual(config["httpUrl"], "https://api.example.com/mcp") + self.assertNotIn("url", config) + self.assertNotIn("type", config) + self.assertNotIn("tools", config) + self.assertNotIn("id", config) + + def test_remote_sse_uses_url(self): + """SSE remotes must use url key, not httpUrl.""" + server_info = { + "remotes": [{ + "url": "https://api.example.com/sse", + "transport_type": "sse", + }], + "name": "sse-server", + } + config = self.adapter._format_server_config(server_info) + self.assertEqual(config["url"], "https://api.example.com/sse") + self.assertNotIn("httpUrl", config) + self.assertNotIn("type", config) From fc0c63c212500ec13b5f75db1961f1afdd36a1cd Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Sat, 25 Apr 2026 12:00:38 -0400 Subject: [PATCH 11/11] fix(gemini): remove auth bug, clean up vestigial fields, update docs - Remove get_token_for_purpose("gemini") call that raises ValueError (no "gemini" key in TOKEN_PRECEDENCE); Gemini adapter doesn't need GitHub token injection - Remove vestigial coverage_patterns/source_attribution from GeminiPlacement - Fix _log("progress") call to match other call sites (no f-string, no [+]) - Add gemini to --runtime TEXT list in cli-commands.md Co-Authored-By: Claude Opus 4.6 --- docs/src/content/docs/reference/cli-commands.md | 2 +- src/apm_cli/adapters/client/gemini.py | 14 -------------- src/apm_cli/compilation/agents_compiler.py | 2 +- src/apm_cli/compilation/gemini_formatter.py | 6 +----- 4 files changed, 3 insertions(+), 21 deletions(-) diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index d80e8550c..35ad3ee9c 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -84,7 +84,7 @@ apm install [PACKAGES...] [OPTIONS] - `PACKAGES` - Optional APM packages to add and install. Accepts shorthand (`owner/repo`), HTTPS URLs, SSH URLs, FQDN shorthand (`host/owner/repo`), local filesystem paths (`./path`, `../path`, `/absolute/path`, `~/path`), or marketplace references (`NAME@MARKETPLACE[#ref]`). All forms are normalized to canonical format in `apm.yml`. **Options:** -- `--runtime TEXT` - Target specific runtime only (copilot, codex, vscode) +- `--runtime TEXT` - Target specific runtime only (copilot, codex, gemini, vscode) - `--exclude TEXT` - Exclude specific runtime from installation - `--only [apm|mcp]` - Install only specific dependency type - `--target [copilot|claude|cursor|codex|opencode|gemini|all]` - Force deployment to specific target(s). Accepts comma-separated values for multiple targets (e.g., `-t claude,copilot`). Overrides auto-detection diff --git a/src/apm_cli/adapters/client/gemini.py b/src/apm_cli/adapters/client/gemini.py index b601a83b9..30c619acc 100644 --- a/src/apm_cli/adapters/client/gemini.py +++ b/src/apm_cli/adapters/client/gemini.py @@ -30,7 +30,6 @@ from .copilot import CopilotClientAdapter from ...core.docker_args import DockerArgsProcessor -from ...core.token_manager import GitHubTokenManager from ...utils.console import _rich_error, _rich_success logger = logging.getLogger(__name__) @@ -141,19 +140,6 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No else: config["httpUrl"] = url - # GitHub server auth - server_name = server_info.get("name", "") - is_github = self._is_github_server( - server_name, remote.get("url", "") - ) - if is_github: - _tm = GitHubTokenManager() - token = _tm.get_token_for_purpose("gemini") or os.getenv( - "GITHUB_PERSONAL_ACCESS_TOKEN" - ) - if token: - config["headers"] = {"Authorization": f"Bearer {token}"} - # Registry-supplied headers for header in remote.get("headers", []): name = header.get("name", "") diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index f6986b3be..7dd7d4338 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -645,7 +645,7 @@ def _compile_gemini_md(self, config: CompilationConfig, primitives: PrimitiveCol stats = gemini_result.stats.copy() stats["gemini_files_written"] = files_written - self._log("progress", f"[+] Generated GEMINI.md (imports AGENTS.md)") + self._log("progress", "Generated GEMINI.md (imports AGENTS.md)") return CompilationResult( success=len(all_errors) == 0, diff --git a/src/apm_cli/compilation/gemini_formatter.py b/src/apm_cli/compilation/gemini_formatter.py index ef78b2920..65a72e5f9 100644 --- a/src/apm_cli/compilation/gemini_formatter.py +++ b/src/apm_cli/compilation/gemini_formatter.py @@ -9,7 +9,7 @@ import builtins from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional, Set +from typing import Dict, List, Optional from ..primitives.models import Instruction, PrimitiveCollection from ..version import get_version @@ -26,8 +26,6 @@ class GeminiPlacement: """Result of GEMINI.md placement analysis.""" gemini_path: Path instructions: List[Instruction] - coverage_patterns: Set[str] = field(default_factory=set) - source_attribution: Dict[str, str] = field(default_factory=dict) @dataclass @@ -81,8 +79,6 @@ def format_distributed( placement = GeminiPlacement( gemini_path=gemini_path, instructions=[], - coverage_patterns=set(), - source_attribution={}, ) stats: Dict[str, float] = {