diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa4784b9..982989477 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. (#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). diff --git a/README.md b/README.md index e0fd8a2b7..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 +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)** @@ -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/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 09adb1dd0..7b772d040 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/`, 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. 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 @@ -157,7 +161,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..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, 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 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.md` | Instructions via compile | +| Gemini | `.gemini/commands/`, `.gemini/skills/`, `.gemini/settings.json` (MCP, hooks) | `GEMINI.md` (instructions) | **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, 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/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/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 114e29994..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 (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, 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/`: @@ -286,7 +298,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). @@ -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, 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, 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 3f8280cc9..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/`, 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** -- 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 cd1981167..35ad3ee9c 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -84,10 +84,10 @@ 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|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 @@ -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:** @@ -555,7 +560,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` @@ -597,6 +602,7 @@ apm pack -o dist/ | `claude` | `.claude/` | | `cursor` | `.cursor/` | | `opencode` | `.opencode/` | +| `gemini` | `.gemini/` | | `all` | all of the above | **Enriched lockfile example:** @@ -956,7 +962,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 +1354,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 +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.md + .gemini/ | | Both folders exist | `all` | All outputs | | Neither folder exists | `minimal` | AGENTS.md only | @@ -1389,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:** @@ -1620,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. @@ -1632,17 +1640,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 +1702,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 @@ -1711,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/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/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..30c619acc --- /dev/null +++ b/src/apm_cli/adapters/client/gemini.py @@ -0,0 +1,271 @@ +"""Gemini CLI implementation of MCP client adapter. + +Gemini CLI uses ``.gemini/settings.json`` at the project root with an +``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 + + { + "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 logging +import os +from pathlib import Path + +from .copilot import CopilotClientAdapter +from ...core.docker_args import DockerArgsProcessor +from ...utils.console import _rich_error, _rich_success + +logger = logging.getLogger(__name__) + + +class GeminiClientAdapter(CopilotClientAdapter): + """Gemini CLI MCP client adapter. + + 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 + + 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 _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 + + # 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, + 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: + _rich_error("server_url cannot be empty", symbol="error") + 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: + _rich_error(f"MCP server '{server_url}' not found in registry", symbol="error") + 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}) + + _rich_success( + f"Configured MCP server '{config_key}' for Gemini CLI", symbol="success" + ) + return True + + except Exception as e: + logger.debug("Gemini MCP configuration failed: %s", e) + _rich_error("Failed to configure MCP server for Gemini CLI", symbol="error") + return False 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/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..7dd7d4338 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -20,14 +20,14 @@ ) 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. # 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,14 @@ 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. + 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: - 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(), @@ -600,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", "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..65a72e5f9 --- /dev/null +++ b/src/apm_cli/compilation/gemini_formatter.py @@ -0,0 +1,119 @@ +"""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 + +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] + + +@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=[], + ) + + 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/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..7f1b12cd4 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,96 +117,49 @@ 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: - """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_compile_agents_md(target: TargetType) -> bool: + """Check if AGENTS.md should be compiled. -def should_integrate_claude(target: TargetType) -> bool: - """Check if Claude integration should be performed. + 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 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 + bool: True if AGENTS.md should be generated """ - return target in ("opencode", "all") + return target in ("vscode", "opencode", "codex", "gemini", "all", "minimal") -def should_integrate_cursor(target: TargetType) -> bool: - """Check if Cursor integration should be performed. +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 Cursor integration (agents, skills, rules) should run + bool: True if CLAUDE.md should be generated """ - 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 + return target in ("claude", "all") - Returns: - bool: True if Codex integration (agents, skills, hooks) should run - """ - return target in ("codex", "all") +def should_compile_gemini_md(target: TargetType) -> bool: + """Check if GEMINI.md should be compiled. -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. - Args: target: The detected or configured target - - Returns: - bool: True if AGENTS.md should be generated - """ - return target in ("vscode", "opencode", "codex", "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 + bool: True if GEMINI.md should be generated """ - return target in ("claude", "all") + return target in ("gemini", "all") def get_target_description(target: UserTargetType) -> str: @@ -220,8 +181,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.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") @@ -232,7 +194,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] = { @@ -251,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/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'(? 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", @@ -101,6 +159,11 @@ class _MergeHookConfig: target_key="codex", require_dir=True, ), + "gemini": _MergeHookConfig( + config_filename="settings.json", + target_key="gemini", + require_dir=True, + ), } @@ -525,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/src/apm_cli/integration/instruction_integrator.py b/src/apm_cli/integration/instruction_integrator.py index 659bc0b57..2cf8e6437 100644 --- a/src/apm_cli/integration/instruction_integrator.py +++ b/src/apm_cli/integration/instruction_integrator.py @@ -28,6 +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: compile-only (GEMINI.md) -- no per-file rule deployment """ def find_instruction_files(self, package_path: Path) -> List[Path]: @@ -382,3 +383,4 @@ def sync_integration_claude( KNOWN_TARGETS["claude"], apm_package, project_root, managed_files=managed_files, ) + diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 85e54c1fa..1130e3a23 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: @@ -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( @@ -631,6 +636,38 @@ 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_success( + f"Removed stale MCP server '{name}' from .gemini/settings.json", + symbol="check", + ) + except Exception: + _log.debug( + "Failed to clean stale MCP servers from .gemini/settings.json", + exc_info=True, + ) + # ------------------------------------------------------------------ # Lockfile persistence # ------------------------------------------------------------------ @@ -687,6 +724,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 +761,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 +834,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 +969,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 +985,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 +1011,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..554b6c59a 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -260,6 +260,32 @@ 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 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/ + # Ref: https://geminicli.com/docs/reference/configuration/ + "gemini": TargetProfile( + name="gemini", + root_dir=".gemini", + primitives={ + "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..d33bf8f9c --- /dev/null +++ b/tests/integration/test_gemini_integration.py @@ -0,0 +1,485 @@ +"""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 typing import Optional + +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: Optional[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 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() + + +@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/integration/test_golden_scenario_e2e.py b/tests/integration/test_golden_scenario_e2e.py index da45960f6..5b3d04975 100644 --- a/tests/integration/test_golden_scenario_e2e.py +++ b/tests/integration/test_golden_scenario_e2e.py @@ -108,9 +108,27 @@ 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 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 # Let test-integration.sh handle token management properly # It has the correct prioritization: GITHUB_APM_PAT > GITHUB_TOKEN @@ -118,6 +136,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 +664,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/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/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/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..f2527c07d 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -2,12 +2,9 @@ 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, + should_compile_gemini_md, get_target_description, TargetParamType, VALID_TARGET_VALUES, @@ -170,47 +167,7 @@ def test_auto_detect_neither_folder(self, tmp_path): ) assert target == "minimal" - assert "no .github/" 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 + assert "no target folder found" in reason class TestShouldCompileAgentsMd: @@ -232,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.""" @@ -253,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.""" @@ -292,54 +281,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.""" 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..20a61c971 100644 --- a/tests/unit/integration/test_data_driven_dispatch.py +++ b/tests/unit/integration/test_data_driven_dispatch.py @@ -278,6 +278,7 @@ 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 "rules_cursor", # was instructions_cursor, aliased 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/integration/test_instruction_integrator.py b/tests/unit/integration/test_instruction_integrator.py index 0b14b5bcd..83634096c 100644 --- a/tests/unit/integration/test_instruction_integrator.py +++ b/tests/unit/integration/test_instruction_integrator.py @@ -1095,3 +1095,5 @@ def test_sync_handles_missing_rules_dir(self): assert result["files_removed"] == 0 assert result["errors"] == 0 + + 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..8e78cbf4b --- /dev/null +++ b/tests/unit/test_gemini_mcp.py @@ -0,0 +1,260 @@ +"""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) + + +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) 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