diff --git a/CLAUDE.md b/CLAUDE.md index 7c463329..72e95ad9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/workspace-configs-templates/gemini-cli/config.yaml b/workspace-configs-templates/gemini-cli/config.yaml new file mode 100644 index 00000000..858c57dd --- /dev/null +++ b/workspace-configs-templates/gemini-cli/config.yaml @@ -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 diff --git a/workspace-configs-templates/gemini-cli/system-prompt.md b/workspace-configs-templates/gemini-cli/system-prompt.md new file mode 100644 index 00000000..32facf7c --- /dev/null +++ b/workspace-configs-templates/gemini-cli/system-prompt.md @@ -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 diff --git a/workspace-template/adapters/gemini_cli/Dockerfile b/workspace-template/adapters/gemini_cli/Dockerfile new file mode 100644 index 00000000..4f5cc222 --- /dev/null +++ b/workspace-template/adapters/gemini_cli/Dockerfile @@ -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 diff --git a/workspace-template/adapters/gemini_cli/__init__.py b/workspace-template/adapters/gemini_cli/__init__.py new file mode 100644 index 00000000..3e6ad4c5 --- /dev/null +++ b/workspace-template/adapters/gemini_cli/__init__.py @@ -0,0 +1,3 @@ +from .adapter import GeminiCLIAdapter as Adapter + +__all__ = ["Adapter"] diff --git a/workspace-template/adapters/gemini_cli/adapter.py b/workspace-template/adapters/gemini_cli/adapter.py new file mode 100644 index 00000000..7013c275 --- /dev/null +++ b/workspace-template/adapters/gemini_cli/adapter.py @@ -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, + ) diff --git a/workspace-template/adapters/gemini_cli/requirements.txt b/workspace-template/adapters/gemini_cli/requirements.txt new file mode 100644 index 00000000..4897bc73 --- /dev/null +++ b/workspace-template/adapters/gemini_cli/requirements.txt @@ -0,0 +1,2 @@ +# gemini-cli adapter has no extra Python dependencies. +# The CLIAgentExecutor (base) handles all subprocess management. diff --git a/workspace-template/cli_executor.py b/workspace-template/cli_executor.py index 579f7c14..2f2802ec 100644 --- a/workspace-template/cli_executor.py +++ b/workspace-template/cli_executor.py @@ -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) diff --git a/workspace-template/tests/test_adapters.py b/workspace-template/tests/test_adapters.py index 89572910..1c1f9114 100644 --- a/workspace-template/tests/test_adapters.py +++ b/workspace-template/tests/test_adapters.py @@ -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):