feat(adapters): add gemini-cli runtime adapter (closes #332) (#379)

Adds a `gemini-cli` workspace runtime backed by Google's Gemini CLI
(@google/gemini-cli, ~101k ★, Apache 2.0). Mirrors the claude-code
adapter pattern: Docker image installs the CLI, CLIAgentExecutor
drives the subprocess, A2A MCP tools wire via ~/.gemini/settings.json.

Changes:
- workspace-template/adapters/gemini_cli/ — new adapter (Dockerfile,
  adapter.py, __init__.py, requirements.txt); setup() seeds GEMINI.md
  from system-prompt.md and injects A2A MCP server into settings.json
- workspace-template/cli_executor.py — adds gemini-cli to
  RUNTIME_PRESETS (--yolo flag, -p prompt, --model, GEMINI_API_KEY env
  auth); adds mcp_via_settings preset flag to skip --mcp-config
  injection for runtimes that own their own settings file
- workspace-configs-templates/gemini-cli/ — default config.yaml +
  system-prompt.md template
- tests/test_adapters.py — adds gemini-cli to expected adapter set
- CLAUDE.md — documents new runtime row in the image table

Requires: GEMINI_API_KEY global secret. Build:
  bash workspace-template/build-all.sh gemini-cli

Co-authored-by: DevOps Engineer <devops@molecule.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-15 23:30:00 -07:00 committed by GitHub
parent b2e1631640
commit 0aec76400a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 208 additions and 4 deletions

View File

@ -186,8 +186,9 @@ Each runtime has its own Docker image extending `workspace-template:base`, with
| autogen | `workspace-template:autogen` | autogen |
| deepagents | `workspace-template:deepagents` | deepagents |
| hermes | `workspace-template:hermes` | openai (OpenAI-compatible client; Nous Portal via `HERMES_API_KEY` or OpenRouter via `OPENROUTER_API_KEY` fallback) |
| gemini-cli | `workspace-template:gemini-cli` | @google/gemini-cli (npm); requires `GEMINI_API_KEY`; MCP wired via `~/.gemini/settings.json`; memory file: `GEMINI.md` |
Templates are framework presets in `workspace-configs-templates/`: `claude-code-default`, `langgraph`, `openclaw`, `deepagents`. Agent roles are configured after deployment via Config tab or API.
Templates are framework presets in `workspace-configs-templates/`: `claude-code-default`, `langgraph`, `openclaw`, `deepagents`, `gemini-cli`. Agent roles are configured after deployment via Config tab or API.
For Claude Code runtime, write your OAuth token to `workspace-configs-templates/claude-code-default/.auth-token`.

View File

@ -0,0 +1,11 @@
name: Gemini CLI Agent
description: General-purpose Gemini CLI workspace
version: 1.0.0
tier: 2
runtime: gemini-cli
runtime_config:
model: gemini-2.5-pro
required_env:
- GEMINI_API_KEY
timeout: 0

View File

@ -0,0 +1,24 @@
# Gemini CLI Agent
You are a general-purpose AI agent running inside a Molecule AI workspace, powered by Google Gemini CLI.
## Your Capabilities
- **Code**: Read, write, and modify files in /workspace
- **Shell**: Run commands to build, test, and debug
- **Memory**: Persist context between sessions via `commit_memory` / `recall_memory`
- **Delegation**: Coordinate with peer agents via `delegate_task`
- **MCP tools**: Full A2A protocol toolset available (list_peers, delegate_task, etc.)
## Working Style
- Be concise and direct
- Use tools actively — don't ask for permission before reading a file or running a safe command
- Check /workspace for any cloned repositories before starting work
- Commit important decisions and findings to memory
## Environment
- Working directory: /workspace (if populated) or /configs
- GEMINI.md: your persistent memory file for this workspace
- Auth: GEMINI_API_KEY is injected as an env var

View File

@ -0,0 +1,5 @@
FROM workspace-template:base
USER root
RUN npm install -g @google/gemini-cli 2>/dev/null || true
# gemini-cli has no extra Python adapter deps — uses CLIAgentExecutor from base
# Do NOT set USER agent — entrypoint starts as root, chowns volumes, drops to agent via gosu

View File

@ -0,0 +1,3 @@
from .adapter import GeminiCLIAdapter as Adapter
__all__ = ["Adapter"]

View File

@ -0,0 +1,141 @@
"""Gemini CLI adapter — wraps Google's Gemini CLI as an agent runtime.
Gemini CLI (github.com/google-gemini/gemini-cli, ~101k stars, Apache 2.0)
is structurally identical to the Claude Code adapter: a single-agent agentic
CLI with file/shell tools, MCP support, and a ReAct loop backed by Gemini
instead of Claude.
Key differences from claude-code:
- Auth: GEMINI_API_KEY env var (no OAuth token needed)
- Memory file: GEMINI.md (equivalent of Claude Code's CLAUDE.md)
- MCP config: ~/.gemini/settings.json (not via --mcp-config flag)
- Executor: CLIAgentExecutor (no Python SDK; uses gemini CLI subprocess)
"""
import json
import logging
import os
import sys
from pathlib import Path
from a2a.server.agent_execution import AgentExecutor
from adapters.base import BaseAdapter, AdapterConfig
logger = logging.getLogger(__name__)
class GeminiCLIAdapter(BaseAdapter):
@staticmethod
def name() -> str:
return "gemini-cli"
@staticmethod
def display_name() -> str:
return "Gemini CLI"
@staticmethod
def description() -> str:
return (
"Google Gemini CLI — agentic coding with file/shell tools, "
"MCP support, and a ReAct loop backed by Gemini models"
)
@staticmethod
def get_config_schema() -> dict:
return {
"model": {
"type": "string",
"description": "Gemini model (e.g. gemini-2.5-pro, gemini-2.5-flash)",
"default": "gemini-2.5-pro",
},
"required_env": {
"type": "array",
"description": "Required env vars",
"default": ["GEMINI_API_KEY"],
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (0 = no timeout)",
"default": 0,
},
}
def memory_filename(self) -> str:
"""Gemini CLI reads GEMINI.md as its persistent context file."""
return "GEMINI.md"
async def setup(self, config: AdapterConfig) -> None:
"""Wire MCP server into ~/.gemini/settings.json and seed GEMINI.md.
Gemini CLI does not accept an --mcp-config flag; instead, MCP servers
are declared in ~/.gemini/settings.json under the "mcpServers" key.
This method merges the A2A MCP server into that file, preserving any
existing keys (e.g. user's own MCP tools).
Also seeds GEMINI.md from system-prompt.md if GEMINI.md is absent,
so the agent has role context on first boot.
"""
from executor_helpers import get_mcp_server_path
# -- MCP wiring --------------------------------------------------
gemini_dir = Path.home() / ".gemini"
gemini_dir.mkdir(parents=True, exist_ok=True)
settings_path = gemini_dir / "settings.json"
settings: dict = {}
if settings_path.exists():
try:
settings = json.loads(settings_path.read_text())
except Exception as exc:
logger.warning("gemini-cli: could not parse %s: %s", settings_path, exc)
settings = {}
settings.setdefault("mcpServers", {})
settings["mcpServers"]["a2a"] = {
"command": sys.executable,
"args": [get_mcp_server_path()],
}
try:
settings_path.write_text(json.dumps(settings, indent=2))
logger.info("gemini-cli: wrote MCP config to %s", settings_path)
except OSError as exc:
logger.warning("gemini-cli: could not write %s: %s", settings_path, exc)
# -- GEMINI.md seed ----------------------------------------------
gemini_md = Path(config.config_path) / "GEMINI.md"
system_prompt_file = Path(config.config_path) / "system-prompt.md"
if not gemini_md.exists() and system_prompt_file.exists():
try:
gemini_md.write_text(system_prompt_file.read_text())
logger.info("gemini-cli: seeded GEMINI.md from system-prompt.md")
except OSError as exc:
logger.warning("gemini-cli: could not seed GEMINI.md: %s", exc)
async def create_executor(self, config: AdapterConfig) -> AgentExecutor:
from cli_executor import CLIAgentExecutor
from config import RuntimeConfig
rc = config.runtime_config
if isinstance(rc, dict):
model = rc.get("model") or "gemini-2.5-pro"
timeout = int(rc.get("timeout") or 0)
else:
model = getattr(rc, "model", None) or "gemini-2.5-pro"
timeout = int(getattr(rc, "timeout", None) or 0)
runtime_config = RuntimeConfig(
model=model,
timeout=timeout,
required_env=["GEMINI_API_KEY"],
)
return CLIAgentExecutor(
runtime="gemini-cli",
runtime_config=runtime_config,
system_prompt=config.system_prompt,
config_path=config.config_path,
heartbeat=config.heartbeat,
)

View File

@ -0,0 +1,2 @@
# gemini-cli adapter has no extra Python dependencies.
# The CLIAgentExecutor (base) handles all subprocess management.

View File

@ -81,6 +81,21 @@ RUNTIME_PRESETS: dict[str, dict] = {
"default_auth_env": "",
"default_auth_file": "",
},
# Gemini CLI (github.com/google-gemini/gemini-cli, Apache 2.0).
# Auth via GEMINI_API_KEY env var; MCP is wired via ~/.gemini/settings.json
# (not --mcp-config) — the adapter's setup() handles that step.
# System prompt is seeded into GEMINI.md (equivalent of CLAUDE.md).
"gemini-cli": {
"command": "gemini",
"base_args": ["--yolo"], # auto-approve all tool calls (non-interactive)
"prompt_flag": "-p",
"model_flag": "--model",
"system_prompt_flag": None, # GEMINI.md used instead; seeded by adapter.setup()
"auth_pattern": "env", # GEMINI_API_KEY; also enables A2A MCP instructions
"default_auth_env": "GEMINI_API_KEY",
"default_auth_file": "",
"mcp_via_settings": True, # MCP injected into ~/.gemini/settings.json, not --mcp-config
},
}
@ -232,8 +247,10 @@ class CLIAgentExecutor(AgentExecutor):
settings = json.dumps({"apiKeyHelper": self._auth_helper_path})
args.extend(["--settings", settings])
# A2A MCP server — inject for MCP-compatible runtimes (created once in __init__)
if self._mcp_config_path:
# A2A MCP server — inject for MCP-compatible runtimes (created once in __init__).
# Runtimes that declare `mcp_via_settings: True` (e.g. gemini-cli) wire MCP
# through their own settings file (adapter.setup()) instead of --mcp-config.
if self._mcp_config_path and not self.preset.get("mcp_via_settings"):
args.extend(["--mcp-config", self._mcp_config_path])
# Extra args from config (before prompt so flags are parsed correctly)

View File

@ -639,7 +639,7 @@ class TestAdapterRegistry:
from adapters import discover_adapters
adapters = discover_adapters()
names = set(adapters.keys())
expected = {"langgraph", "crewai", "claude-code", "autogen", "deepagents", "openclaw", "hermes"}
expected = {"langgraph", "crewai", "claude-code", "autogen", "deepagents", "openclaw", "hermes", "gemini-cli"}
assert expected == names, f"Missing: {expected - names}, Extra: {names - expected}"
def test_no_duplicate_names(self):