diff --git a/README.md b/README.md
index 32899b9..25220f2 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ Turn tasks into shipped code with agents.
## From Ticket to Shipped Code, Autonomously
-Modula is an agentic orchestration platform that automates your development workflow end to end. Bring your own coding agents (Claude, Codex, OpenCode, and others) and let them run the whole thing in one place, instead of jumping between your terminal, IDE, AI agents, and issue tracker.
+Modula is an agentic orchestration platform that automates your development workflow end to end. Bring your own coding agents (Claude, Codex, OpenCode, Gemini, and others) and let them run the whole thing in one place, instead of jumping between your terminal, IDE, AI agents, and issue tracker.
- **Your whole workflow in one place**: tickets, agents, code, and review, with no tool-switching
- **Runs on its own**: agents pick up work automatically as it progresses, not when you prompt them
@@ -42,7 +42,7 @@ Modula is an agentic orchestration platform that automates your development work
| **Parallel variants** | Explore several solutions to the same task at once, then pick the one you like best |
| **Isolated by default** | Each variant works in its own isolated branch, so parallel attempts never collide |
| **Configurable workflow** | Tailor the stages a task moves through to fit your process |
-| **Bring your own model** | Claude, Codex, and OpenCode work out of the box, and you choose the model for each |
+| **Bring your own model** | Claude, Codex, OpenCode, and Gemini work out of the box, and you choose the model for each |
| **Local and self-hosted models** | Point a provider at a local or self-hosted model to keep everything on your machine |
| **Live progress** | Watch every agent work in real time |
| **Works with any tracker** | Pull tasks from Jira, Linear, GitHub Issues, or your own source, kept in sync automatically |
@@ -57,6 +57,7 @@ Bring your own coding agents. Supported providers:
| [Claude Code](https://github.com/anthropics/claude-code) | Fully supported |
| [OpenCode](https://github.com/opencode-ai/opencode) | Fully supported |
| [Codex CLI](https://github.com/openai/codex) | Fully supported |
+| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | Fully supported |
| More coming soon | |
## Install
diff --git a/apps/desktop/src/components/icons/GeminiIcon.tsx b/apps/desktop/src/components/icons/GeminiIcon.tsx
new file mode 100644
index 0000000..398d85d
--- /dev/null
+++ b/apps/desktop/src/components/icons/GeminiIcon.tsx
@@ -0,0 +1,8 @@
+export function GeminiIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/components/provider-form/ProviderFields.tsx b/apps/desktop/src/components/provider-form/ProviderFields.tsx
index a3e4928..9328aac 100644
--- a/apps/desktop/src/components/provider-form/ProviderFields.tsx
+++ b/apps/desktop/src/components/provider-form/ProviderFields.tsx
@@ -52,7 +52,7 @@ export function ProviderFields({
= Object.fromEntries(
diff --git a/crates/modula_services/provider_catalog.toml b/crates/modula_services/provider_catalog.toml
index 03e0b8c..ad8bc13 100644
--- a/crates/modula_services/provider_catalog.toml
+++ b/crates/modula_services/provider_catalog.toml
@@ -40,3 +40,18 @@ models = [
{ id = "openai/gpt-4o", label = "GPT-4o (OpenAI)" },
{ id = "google/gemini-2.5-pro", label = "Gemini 2.5 Pro (Google)" },
]
+
+[[providers]]
+id = "gemini"
+models = [
+ { id = "auto", label = "Auto (alias)" },
+ { id = "pro", label = "Pro (alias)" },
+ { id = "flash", label = "Flash (alias)" },
+ { id = "flash-lite", label = "Flash Lite (alias)" },
+ { id = "gemini-3.1-pro-preview", label = "Gemini 3.1 Pro (preview)" },
+ { id = "gemini-3-pro-preview", label = "Gemini 3 Pro (preview)" },
+ { id = "gemini-3.8-flash", label = "Gemini 3.8 Flash" },
+ { id = "gemini-3.5-flash", label = "Gemini 3.5 Flash" },
+ { id = "gemini-3.5-flash-lite", label = "Gemini 3.5 Flash Lite" },
+ { id = "gemini-2.5-pro", label = "Gemini 2.5 Pro" },
+]
diff --git a/crates/modula_services/src/generate.rs b/crates/modula_services/src/generate.rs
index 679ba30..6a0853c 100644
--- a/crates/modula_services/src/generate.rs
+++ b/crates/modula_services/src/generate.rs
@@ -67,7 +67,8 @@ impl GenerationService {
let prompt = compose_prompt(¶ms.instruction, params.field_label.as_deref());
// Only the chat-first command passes Claude's `--include-partial-messages`,
- // without which it emits no deltas. codex/opencode return None here.
+ // without which it emits no deltas. gemini also returns Some (preset
+ // `--session-id`); codex/opencode return None.
let mut cmd = match runtime.build_command_chat_first(&prompt, &Uuid::new_v4().to_string()) {
Some(c) => c,
None => runtime.build_command(&prompt, None),
diff --git a/crates/modula_services/src/mcp_config/claude.rs b/crates/modula_services/src/mcp_config/claude.rs
index a6911ee..5ae6841 100644
--- a/crates/modula_services/src/mcp_config/claude.rs
+++ b/crates/modula_services/src/mcp_config/claude.rs
@@ -65,7 +65,7 @@ impl McpConfigStrategy for ClaudeStrategy {
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.ok_or_else(|| ApiError::BadRequest(format!("{FILE}: mcpServers is not an object")))?;
- reconcile_json(servers, desired, entry);
+ reconcile_json(servers, desired, "url", entry);
write_atomic(&path, &serde_json::to_string_pretty(&root)?)
}
}
diff --git a/crates/modula_services/src/mcp_config/gemini.rs b/crates/modula_services/src/mcp_config/gemini.rs
new file mode 100644
index 0000000..ae51883
--- /dev/null
+++ b/crates/modula_services/src/mcp_config/gemini.rs
@@ -0,0 +1,207 @@
+use std::path::Path;
+
+use serde_json::{json, Map, Value};
+
+use super::{auth_token, reconcile_json, write_atomic, McpConfigStrategy, McpServer};
+use modula_core::error::{ApiError, ApiResult};
+
+pub struct GeminiStrategy;
+
+const FILE: &str = "settings.json";
+
+/// Gemini picks the transport by key: `httpUrl` is streamable HTTP, `url` is
+/// SSE. We only manage `httpUrl` entries so a hand-written SSE server survives.
+const URL_KEY: &str = "httpUrl";
+
+/// Parse JSONC (Gemini tolerates comments in settings.json).
+fn parse(text: &str) -> ApiResult {
+ let parsed: Option =
+ jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
+ .map_err(|e| ApiError::BadRequest(format!("{FILE}: {e}")))?;
+ Ok(parsed.unwrap_or_else(|| Value::Object(Map::new())))
+}
+
+fn entry(server: &McpServer) -> Value {
+ let mut e = Map::new();
+ e.insert(URL_KEY.into(), json!(server.url));
+ if let Some(token) = auth_token(server) {
+ e.insert("headers".into(), json!({ "Authorization": token }));
+ }
+ Value::Object(e)
+}
+
+impl McpConfigStrategy for GeminiStrategy {
+ fn read(&self, config_dir: &Path) -> ApiResult> {
+ let path = config_dir.join(FILE);
+ if !path.is_file() {
+ return Ok(vec![]);
+ }
+ let root = parse(&std::fs::read_to_string(&path)?)?;
+ let Some(servers) = root.get("mcpServers").and_then(Value::as_object) else {
+ return Ok(vec![]);
+ };
+ Ok(servers
+ .iter()
+ .filter_map(|(key, cfg)| {
+ let url = cfg.get(URL_KEY).and_then(Value::as_str)?;
+ let auth_token = cfg
+ .get("headers")
+ .and_then(|h| h.get("Authorization"))
+ .and_then(Value::as_str)
+ .map(str::to_string);
+ Some(McpServer {
+ key: key.clone(),
+ url: url.to_string(),
+ auth_token,
+ })
+ })
+ .collect())
+ }
+
+ fn apply(&self, config_dir: &Path, desired: &[McpServer]) -> ApiResult<()> {
+ let path = config_dir.join(FILE);
+ let mut root = match path.is_file() {
+ true => parse(&std::fs::read_to_string(&path)?)?,
+ false => Value::Object(Map::new()),
+ };
+ let obj = root
+ .as_object_mut()
+ .ok_or_else(|| ApiError::BadRequest(format!("{FILE}: root is not an object")))?;
+ let servers = obj
+ .entry("mcpServers")
+ .or_insert_with(|| Value::Object(Map::new()))
+ .as_object_mut()
+ .ok_or_else(|| ApiError::BadRequest(format!("{FILE}: mcpServers is not an object")))?;
+ reconcile_json(servers, desired, URL_KEY, entry);
+ write_atomic(&path, &serde_json::to_string_pretty(&root)?)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn srv(key: &str, url: &str, token: Option<&str>) -> McpServer {
+ McpServer {
+ key: key.into(),
+ url: url.into(),
+ auth_token: token.map(str::to_string),
+ }
+ }
+
+ fn read_file(dir: &Path) -> Value {
+ serde_json::from_str(&std::fs::read_to_string(dir.join(FILE)).unwrap()).unwrap()
+ }
+
+ #[test]
+ fn create_fresh_then_read_round_trips() {
+ let tmp = tempfile::tempdir().unwrap();
+ let want = vec![srv(
+ "atlassian",
+ "https://mcp.atlassian.com/v1/mcp",
+ Some("tok"),
+ )];
+ GeminiStrategy.apply(tmp.path(), &want).unwrap();
+
+ let v = read_file(tmp.path());
+ assert_eq!(
+ v["mcpServers"]["atlassian"]["httpUrl"],
+ "https://mcp.atlassian.com/v1/mcp"
+ );
+ assert!(v["mcpServers"]["atlassian"].get("url").is_none());
+ assert_eq!(
+ v["mcpServers"]["atlassian"]["headers"]["Authorization"],
+ "Bearer tok"
+ );
+ assert_eq!(
+ GeminiStrategy.read(tmp.path()).unwrap(),
+ vec![srv(
+ "atlassian",
+ "https://mcp.atlassian.com/v1/mcp",
+ Some("Bearer tok")
+ )]
+ );
+ }
+
+ #[test]
+ fn no_token_omits_headers() {
+ let tmp = tempfile::tempdir().unwrap();
+ GeminiStrategy
+ .apply(
+ tmp.path(),
+ &[srv("linear", "https://mcp.linear.app/mcp", None)],
+ )
+ .unwrap();
+ let v = read_file(tmp.path());
+ assert!(v["mcpServers"]["linear"].get("headers").is_none());
+ }
+
+ #[test]
+ fn preserves_unrelated_keys_stdio_and_sse_servers() {
+ let tmp = tempfile::tempdir().unwrap();
+ std::fs::write(
+ tmp.path().join(FILE),
+ r#"{
+ // user settings
+ "security": { "auth": { "selectedType": "oauth-personal" } },
+ "mcpServers": {
+ "local": { "command": "npx", "args": ["x"] },
+ "sse": { "url": "https://sse.example/sse" },
+ "old": { "httpUrl": "https://old.example/mcp" }
+ }
+}"#,
+ )
+ .unwrap();
+
+ GeminiStrategy
+ .apply(
+ tmp.path(),
+ &[srv("github", "https://api.githubcopilot.com/mcp/", None)],
+ )
+ .unwrap();
+
+ let v = read_file(tmp.path());
+ assert_eq!(v["security"]["auth"]["selectedType"], "oauth-personal");
+ assert_eq!(v["mcpServers"]["local"]["command"], "npx");
+ assert_eq!(v["mcpServers"]["sse"]["url"], "https://sse.example/sse");
+ assert!(v["mcpServers"].get("old").is_none());
+ assert_eq!(
+ GeminiStrategy.read(tmp.path()).unwrap(),
+ vec![srv("github", "https://api.githubcopilot.com/mcp/", None)]
+ );
+ }
+
+ #[test]
+ fn delete_clears_all_managed() {
+ let tmp = tempfile::tempdir().unwrap();
+ GeminiStrategy
+ .apply(
+ tmp.path(),
+ &[srv("a", "https://a", None), srv("b", "https://b", None)],
+ )
+ .unwrap();
+ GeminiStrategy.apply(tmp.path(), &[]).unwrap();
+ assert!(GeminiStrategy.read(tmp.path()).unwrap().is_empty());
+ }
+
+ #[test]
+ fn malformed_file_errors_without_clobbering() {
+ let tmp = tempfile::tempdir().unwrap();
+ std::fs::write(tmp.path().join(FILE), "{ not json").unwrap();
+ let err = GeminiStrategy.apply(tmp.path(), &[]).unwrap_err();
+ assert!(matches!(err, ApiError::BadRequest(_)));
+ assert_eq!(
+ std::fs::read_to_string(tmp.path().join(FILE)).unwrap(),
+ "{ not json"
+ );
+ }
+
+ #[test]
+ fn missing_dir_reads_empty() {
+ let tmp = tempfile::tempdir().unwrap();
+ assert!(GeminiStrategy
+ .read(&tmp.path().join("nope"))
+ .unwrap()
+ .is_empty());
+ }
+}
diff --git a/crates/modula_services/src/mcp_config/mod.rs b/crates/modula_services/src/mcp_config/mod.rs
index eccc27f..512f684 100644
--- a/crates/modula_services/src/mcp_config/mod.rs
+++ b/crates/modula_services/src/mcp_config/mod.rs
@@ -12,6 +12,7 @@ use modula_core::error::ApiResult;
mod claude;
mod codex;
+mod gemini;
mod opencode;
/// A managed HTTP MCP server entry. `auth_token`, when present and non-blank,
@@ -40,6 +41,7 @@ pub fn for_type(provider_type: &str) -> Option> {
match provider_type {
"claude" => Some(Box::new(claude::ClaudeStrategy)),
"codex" => Some(Box::new(codex::CodexStrategy)),
+ "gemini" => Some(Box::new(gemini::GeminiStrategy)),
"opencode" => Some(Box::new(opencode::OpenCodeStrategy)),
_ => None,
}
@@ -64,16 +66,17 @@ fn auth_token(server: &McpServer) -> Option {
}
}
-/// Reconcile a serde_json `mcp`/`mcpServers` map (claude + opencode share this
-/// shape; codex uses toml_edit). Managed entries are those carrying a `url`;
-/// the user's command/stdio entries have none and are left alone.
+/// Reconcile a serde_json `mcp`/`mcpServers` map (claude, opencode and gemini
+/// share this shape; codex uses toml_edit). Managed entries are those carrying
+/// `url_key`; the user's other entries lack it and are left alone.
fn reconcile_json(
servers: &mut Map,
desired: &[McpServer],
+ url_key: &str,
make_entry: impl Fn(&McpServer) -> Value,
) {
let keep: HashSet<&str> = desired.iter().map(|s| s.key.as_str()).collect();
- servers.retain(|key, cfg| cfg.get("url").is_none() || keep.contains(key.as_str()));
+ servers.retain(|key, cfg| cfg.get(url_key).is_none() || keep.contains(key.as_str()));
for server in desired {
servers.insert(server.key.clone(), make_entry(server));
}
@@ -109,6 +112,7 @@ mod tests {
fn for_type_dispatch() {
assert!(for_type("claude").is_some());
assert!(for_type("codex").is_some());
+ assert!(for_type("gemini").is_some());
assert!(for_type("opencode").is_some());
assert!(for_type("unknown").is_none());
}
@@ -136,5 +140,5 @@ mod tests {
);
}
- // Per-strategy round-trip tests live in claude.rs / codex.rs / opencode.rs.
+ // Per-strategy round-trip tests live in each strategy module.
}
diff --git a/crates/modula_services/src/mcp_config/opencode.rs b/crates/modula_services/src/mcp_config/opencode.rs
index b839ed4..9f2f246 100644
--- a/crates/modula_services/src/mcp_config/opencode.rs
+++ b/crates/modula_services/src/mcp_config/opencode.rs
@@ -80,7 +80,7 @@ impl McpConfigStrategy for OpenCodeStrategy {
.ok_or_else(|| {
ApiError::BadRequest(format!("{}: mcp is not an object", path.display()))
})?;
- reconcile_json(servers, desired, entry);
+ reconcile_json(servers, desired, "url", entry);
write_atomic(&path, &serde_json::to_string_pretty(&root)?)
}
}
diff --git a/crates/modula_services/src/providers/gemini.rs b/crates/modula_services/src/providers/gemini.rs
new file mode 100644
index 0000000..2f1e330
--- /dev/null
+++ b/crates/modula_services/src/providers/gemini.rs
@@ -0,0 +1,307 @@
+//! Gemini provider — `gemini` CLI.
+
+use std::ffi::OsString;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use serde_json::{json, Value as JsonValue};
+
+use super::{program, read_jsonc, ChatEvent, ProviderRuntime};
+
+/// `config_dir` is the `.gemini` dir itself; the CLI finds it as `$GEMINI_CLI_HOME/.gemini`.
+pub struct GeminiRuntime {
+ pub config_dir: PathBuf,
+ pub model: Option,
+}
+
+impl GeminiRuntime {
+ fn command(&self, prompt: &str, session_flag: Option<(&str, &str)>) -> Command {
+ let mut cmd = Command::new(program("gemini"));
+ // `--skip-trust`: an untrusted cwd exits 55 and silently drops `--yolo`.
+ cmd.arg("-o")
+ .arg("stream-json")
+ .arg("--skip-trust")
+ .arg("--yolo");
+ if let Some(m) = &self.model {
+ cmd.arg("-m").arg(m);
+ }
+ if let Some((flag, id)) = session_flag {
+ cmd.arg(flag).arg(id);
+ }
+ // One token, so a prompt starting with `-` isn't parsed as a flag.
+ cmd.arg(format!("--prompt={prompt}"));
+ cmd
+ }
+}
+
+impl ProviderRuntime for GeminiRuntime {
+ fn build_command(&self, prompt: &str, session_id: Option<&str>) -> Command {
+ self.command(prompt, session_id.map(|id| ("--resume", id)))
+ }
+
+ fn build_command_chat_first(&self, prompt: &str, preset_session_id: &str) -> Option {
+ Some(self.command(prompt, Some(("--session-id", preset_session_id))))
+ }
+
+ fn env_vars(&self) -> Vec<(&'static str, OsString)> {
+ let mut vars: Vec<(&'static str, OsString)> = Vec::new();
+ if let Some(home) = self.config_dir.parent() {
+ vars.push(("GEMINI_CLI_HOME", home.as_os_str().to_owned()));
+ }
+ if let Some(m) = &self.model {
+ vars.push(("MODULA_PROVIDER_MODEL", OsString::from(m)));
+ }
+ vars
+ }
+
+ fn mcp_summary(&self) -> JsonValue {
+ gemini_mcp_summary(&self.config_dir)
+ }
+
+ fn parse_line(&self, v: &JsonValue) -> Vec {
+ match v["type"].as_str().unwrap_or("") {
+ "init" => match v["session_id"].as_str() {
+ Some(id) => vec![ChatEvent::Session { id: id.to_string() }],
+ None => vec![],
+ },
+ "message" if v["role"].as_str() == Some("assistant") => match v["content"].as_str() {
+ Some(text) => vec![ChatEvent::Delta {
+ text: text.to_string(),
+ }],
+ None => vec![],
+ },
+ "tool_use" => {
+ let params = &v["parameters"];
+ let (name, input) = match v["tool_name"].as_str().unwrap_or("") {
+ "run_shell_command" => ("Bash", json!({ "command": params["command"] })),
+ "write_file" | "replace" => {
+ ("Edit", json!({ "file_path": params["file_path"] }))
+ }
+ other => (other, params.clone()),
+ };
+ vec![ChatEvent::ToolUse {
+ name: name.to_string(),
+ input,
+ }]
+ }
+ "tool_result" => vec![ChatEvent::ToolResult],
+ "result" if v["status"].as_str() == Some("success") => vec![ChatEvent::Done],
+ "result" => vec![ChatEvent::Error {
+ message: v["error"]["message"]
+ .as_str()
+ .unwrap_or("gemini run failed")
+ .to_string(),
+ }],
+ _ => vec![],
+ }
+ }
+}
+
+fn gemini_mcp_summary(config_dir: &Path) -> JsonValue {
+ let config_file = config_dir.join("settings.json");
+ let config_exists = config_file.is_file();
+ let data = read_jsonc(&config_file);
+
+ let mut projects: Vec = Vec::new();
+ if let Some(mcp) = data.get("mcpServers").and_then(|v| v.as_object()) {
+ let mut names: Vec<&String> = mcp.keys().collect();
+ names.sort();
+ let mut servers: Vec = Vec::new();
+ for name in names {
+ let Some(cfg) = mcp.get(name).and_then(|v| v.as_object()) else {
+ continue;
+ };
+ // Gemini's precedence: `httpUrl` > `url` > `command`.
+ let (kind, url) = if let Some(u) = cfg.get("httpUrl") {
+ (Some("http"), Some(u))
+ } else if let Some(u) = cfg.get("url") {
+ (Some("sse"), Some(u))
+ } else if cfg.contains_key("command") {
+ (Some("stdio"), None)
+ } else {
+ (None, None)
+ };
+ servers.push(json!({
+ "name": name,
+ "type": kind,
+ "url": url,
+ "command": cfg.get("command"),
+ "needs_auth": false,
+ }));
+ }
+ if !servers.is_empty() {
+ let count = servers.len();
+ projects.push(json!({
+ "path": "gemini (global)",
+ "mcp_servers": servers,
+ "count": count,
+ }));
+ }
+ }
+ json!({
+ "config_exists": config_exists,
+ "projects": projects,
+ "needs_auth": {},
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn args(cmd: &Command) -> Vec<&std::ffi::OsStr> {
+ cmd.get_args().collect()
+ }
+
+ fn gemini_rt() -> GeminiRuntime {
+ GeminiRuntime {
+ config_dir: "/home/u/.gemini".into(),
+ model: None,
+ }
+ }
+
+ #[test]
+ fn gemini_build_command_argv() {
+ let cmd = gemini_rt().build_command("- list", None);
+ assert_eq!(
+ args(&cmd),
+ &[
+ "-o",
+ "stream-json",
+ "--skip-trust",
+ "--yolo",
+ "--prompt=- list"
+ ]
+ );
+ }
+
+ #[test]
+ fn gemini_build_command_resume_and_model() {
+ let rt = GeminiRuntime {
+ config_dir: "/home/u/.gemini".into(),
+ model: Some("flash".to_string()),
+ };
+ let cmd = rt.build_command("hello", Some("sid-1"));
+ assert_eq!(
+ args(&cmd)[4..],
+ ["-m", "flash", "--resume", "sid-1", "--prompt=hello"]
+ );
+ }
+
+ #[test]
+ fn gemini_chat_first_presets_session_id() {
+ let cmd = gemini_rt()
+ .build_command_chat_first("hello", "uuid-1")
+ .unwrap();
+ assert_eq!(
+ args(&cmd)[4..],
+ ["--session-id", "uuid-1", "--prompt=hello"]
+ );
+ }
+
+ #[test]
+ fn gemini_env_vars() {
+ let rt = GeminiRuntime {
+ config_dir: "/home/u/.gemini".into(),
+ model: Some("pro".to_string()),
+ };
+ let vars = rt.env_vars();
+ assert_eq!(
+ vars,
+ vec![
+ ("GEMINI_CLI_HOME", OsString::from("/home/u")),
+ ("MODULA_PROVIDER_MODEL", OsString::from("pro")),
+ ]
+ );
+ }
+
+ #[test]
+ fn gemini_parse_events() {
+ let rt = gemini_rt();
+ let parse = |line: &str| rt.parse_stream_line(line);
+ assert!(matches!(
+ &parse(r#"{"type":"init","session_id":"s1","model":"auto"}"#)[..],
+ [ChatEvent::Session { id }] if id == "s1"
+ ));
+ assert!(parse(r#"{"type":"message","role":"user","content":"hi"}"#).is_empty());
+ assert!(matches!(
+ &parse(r#"{"type":"message","role":"assistant","content":"Red","delta":true}"#)[..],
+ [ChatEvent::Delta { text }] if text == "Red"
+ ));
+ assert!(matches!(
+ &parse(r#"{"type":"tool_use","tool_name":"run_shell_command","tool_id":"t","parameters":{"command":"echo hi","description":"x"}}"#)[..],
+ [ChatEvent::ToolUse { name, input }] if name == "Bash" && input == &json!({"command":"echo hi"})
+ ));
+ assert!(matches!(
+ &parse(r#"{"type":"tool_use","tool_name":"replace","tool_id":"t","parameters":{"file_path":"a.txt","old_string":"hi","new_string":"bye"}}"#)[..],
+ [ChatEvent::ToolUse { name, input }] if name == "Edit" && input == &json!({"file_path":"a.txt"})
+ ));
+ assert!(matches!(
+ &parse(r#"{"type":"tool_use","tool_name":"glob","tool_id":"t","parameters":{"pattern":"*.rs"}}"#)[..],
+ [ChatEvent::ToolUse { name, input }] if name == "glob" && input["pattern"] == "*.rs"
+ ));
+ assert!(matches!(
+ parse(r#"{"type":"tool_result","tool_id":"t","status":"success"}"#)[..],
+ [ChatEvent::ToolResult]
+ ));
+ assert!(parse(r#"{"type":"error","severity":"warning","message":"retrying"}"#).is_empty());
+ assert!(matches!(
+ parse(r#"{"type":"result","status":"success","stats":{}}"#)[..],
+ [ChatEvent::Done]
+ ));
+ assert!(matches!(
+ &parse(r#"{"type":"result","status":"error","error":{"type":"x","message":"quota"}}"#)[..],
+ [ChatEvent::Error { message }] if message == "quota"
+ ));
+ assert!(matches!(
+ &parse(r#"{"type":"result","status":"error"}"#)[..],
+ [ChatEvent::Error { message }] if message == "gemini run failed"
+ ));
+ }
+
+ #[test]
+ fn gemini_mcp_summary_missing_dir() {
+ let rt = GeminiRuntime {
+ config_dir: "/nonexistent/path/xyz".into(),
+ model: None,
+ };
+ let s = rt.mcp_summary();
+ assert_eq!(s["config_exists"], false);
+ assert_eq!(s["projects"].as_array().unwrap().len(), 0);
+ }
+
+ #[test]
+ fn gemini_mcp_summary_parses_mcp_servers() {
+ let tmp = tempfile::tempdir().unwrap();
+ std::fs::write(
+ tmp.path().join("settings.json"),
+ r#"{
+ // comments are allowed
+ "mcpServers": {
+ "modula": { "httpUrl": "http://127.0.0.1:1/mcp" },
+ "legacy": { "url": "http://127.0.0.1:2/sse" },
+ "local": { "command": "npx", "args": ["x"] }
+ }
+ }"#,
+ )
+ .unwrap();
+ let rt = GeminiRuntime {
+ config_dir: tmp.path().into(),
+ model: None,
+ };
+ let s = rt.mcp_summary();
+ assert_eq!(s["config_exists"], true);
+ let project = &s["projects"][0];
+ assert_eq!(project["path"], "gemini (global)");
+ assert_eq!(project["count"], 3);
+ let servers = project["mcp_servers"].as_array().unwrap();
+ assert_eq!(servers[0]["name"], "legacy");
+ assert_eq!(servers[0]["type"], "sse");
+ assert_eq!(servers[1]["name"], "local");
+ assert_eq!(servers[1]["type"], "stdio");
+ assert_eq!(servers[1]["command"], "npx");
+ assert_eq!(servers[2]["name"], "modula");
+ assert_eq!(servers[2]["type"], "http");
+ assert_eq!(servers[2]["url"], "http://127.0.0.1:1/mcp");
+ }
+}
diff --git a/crates/modula_services/src/providers/mod.rs b/crates/modula_services/src/providers/mod.rs
index 5c2a13b..8b478c3 100644
--- a/crates/modula_services/src/providers/mod.rs
+++ b/crates/modula_services/src/providers/mod.rs
@@ -16,11 +16,13 @@ use serde_json::Value as JsonValue;
mod claude;
mod codex;
+mod gemini;
mod opencode;
mod service;
use claude::ClaudeRuntime;
use codex::CodexRuntime;
+use gemini::GeminiRuntime;
use opencode::OpenCodeRuntime;
pub use service::{CatalogEntry, CreateParams, CreatedProvider, ProviderService, UpdateParams};
@@ -53,6 +55,18 @@ fn program(name: &str) -> PathBuf {
modula_platform::which(name).unwrap_or_else(|| PathBuf::from(name))
}
+/// Best-effort JSONC read (comments tolerated); a missing or unparsable file
+/// degrades to an empty object.
+fn read_jsonc(path: &std::path::Path) -> JsonValue {
+ let Ok(text) = std::fs::read_to_string(path) else {
+ return JsonValue::Object(Default::default());
+ };
+ jsonc_parser::parse_to_serde_value(&text, &jsonc_parser::ParseOptions::default())
+ .ok()
+ .flatten()
+ .unwrap_or(JsonValue::Object(Default::default()))
+}
+
/// One selectable model for a provider type, as served by the catalog API.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModel {
@@ -196,6 +210,7 @@ pub(super) fn build(
"claude" => Some(Arc::new(ClaudeRuntime { config_dir, model })),
"opencode" => Some(Arc::new(OpenCodeRuntime { config_dir, model })),
"codex" => Some(Arc::new(CodexRuntime { config_dir, model })),
+ "gemini" => Some(Arc::new(GeminiRuntime { config_dir, model })),
_ => None,
}
}
@@ -257,8 +272,10 @@ mod tests {
#[test]
fn from_provider_hydrates_known_type() {
let tmp = tempfile::tempdir().unwrap();
- let p = provider("claude", tmp.path().to_str().unwrap());
- assert!(ProviderService::runtime_from_provider(&p, Some("opus".to_string())).is_ok());
+ for ptype in ["claude", "gemini"] {
+ let p = provider(ptype, tmp.path().to_str().unwrap());
+ assert!(ProviderService::runtime_from_provider(&p, Some("opus".to_string())).is_ok());
+ }
}
#[test]
@@ -275,8 +292,8 @@ mod tests {
id: "opus".to_string(),
label: "Opus".to_string(),
}];
- // Claude and Codex use the trait's default (no discovery mechanism).
- for ptype in ["claude", "codex"] {
+ // These use the trait's default (no discovery mechanism).
+ for ptype in ["claude", "codex", "gemini"] {
let rt = ProviderService::runtime_for_type(ptype, "/tmp".into()).unwrap();
assert_eq!(rt.models(static_models.clone()).await, static_models);
}
diff --git a/crates/modula_services/src/providers/opencode.rs b/crates/modula_services/src/providers/opencode.rs
index 57f98b6..2b0b10b 100644
--- a/crates/modula_services/src/providers/opencode.rs
+++ b/crates/modula_services/src/providers/opencode.rs
@@ -6,7 +6,7 @@ use std::process::Command;
use serde_json::Value as JsonValue;
-use super::{program, BoxFuture, ChatEvent, ProviderModel, ProviderRuntime};
+use super::{program, read_jsonc, BoxFuture, ChatEvent, ProviderModel, ProviderRuntime};
pub struct OpenCodeRuntime {
pub config_dir: PathBuf,
@@ -139,18 +139,6 @@ fn parse_opencode_models(stdout: &str) -> Vec {
.collect()
}
-/// Best-effort JSONC read (comments tolerated) for opencode's `opencode.jsonc`;
-/// a missing or unparsable file degrades to an empty object.
-fn read_jsonc(path: &Path) -> JsonValue {
- let Ok(text) = std::fs::read_to_string(path) else {
- return JsonValue::Object(Default::default());
- };
- jsonc_parser::parse_to_serde_value(&text, &jsonc_parser::ParseOptions::default())
- .ok()
- .flatten()
- .unwrap_or(JsonValue::Object(Default::default()))
-}
-
fn opencode_mcp_summary(config_dir: &Path) -> JsonValue {
let json = config_dir.join("opencode.json");
let config_file = if json.is_file() {
diff --git a/crates/modula_services/src/tools.rs b/crates/modula_services/src/tools.rs
index a839474..8ed5a91 100644
--- a/crates/modula_services/src/tools.rs
+++ b/crates/modula_services/src/tools.rs
@@ -7,7 +7,7 @@
use modula_platform as platform;
-pub const TOOLS: [&str; 4] = ["gh", "claude", "codex", "opencode"];
+pub const TOOLS: [&str; 5] = ["gh", "claude", "codex", "opencode", "gemini"];
pub fn detect() -> Vec<(&'static str, bool)> {
TOOLS
diff --git a/crates/modula_services/src/usage.rs b/crates/modula_services/src/usage.rs
index 61bb791..0a8e66b 100644
--- a/crates/modula_services/src/usage.rs
+++ b/crates/modula_services/src/usage.rs
@@ -1,6 +1,6 @@
//! Per-agent-run cost + token parsing. Reads the `type: result` event that
-//! Claude emits at the end of every stream-json run. Pure log-file helpers with
-//! no repo/business logic — `RunService::usage` drives them over its runs.
+//! Claude and Gemini emit at the end of every stream-json run. Pure log-file
+//! helpers with no repo/business logic — `RunService::usage` drives them over its runs.
use std::fs;
use std::io::{BufRead, BufReader};
@@ -48,29 +48,68 @@ pub fn log_summary(path: &Path) -> Option {
if event.get("type").and_then(|v| v.as_str()) != Some("result") {
continue;
}
- let usage = event.get("usage");
- let token = |key: &str| {
- usage
- .and_then(|u| u.get(key))
+ let int = |v: Option<&JsonValue>, key: &str| {
+ v.and_then(|u| u.get(key))
.and_then(|v| v.as_i64())
.unwrap_or(0)
};
+ // Gemini reports `stats` instead of `usage`, and no cost. Its
+ // `input_tokens` includes `cached`; `input` is the uncached part.
+ if event.get("usage").is_none() {
+ if let Some(stats) = event.get("stats") {
+ return Some(LogSummary {
+ cost_usd: 0.0,
+ duration_ms: int(Some(stats), "duration_ms"),
+ tokens: UsageTokens {
+ input: int(Some(stats), "input"),
+ output: int(Some(stats), "output_tokens"),
+ cache_creation: 0,
+ cache_read: int(Some(stats), "cached"),
+ },
+ });
+ }
+ }
+ let usage = event.get("usage");
return Some(LogSummary {
cost_usd: event
.get("total_cost_usd")
.and_then(|v| v.as_f64())
.unwrap_or(0.0),
- duration_ms: event
- .get("duration_ms")
- .and_then(|v| v.as_i64())
- .unwrap_or(0),
+ duration_ms: int(Some(&event), "duration_ms"),
tokens: UsageTokens {
- input: token("input_tokens"),
- output: token("output_tokens"),
- cache_creation: token("cache_creation_input_tokens"),
- cache_read: token("cache_read_input_tokens"),
+ input: int(usage, "input_tokens"),
+ output: int(usage, "output_tokens"),
+ cache_creation: int(usage, "cache_creation_input_tokens"),
+ cache_read: int(usage, "cache_read_input_tokens"),
},
});
}
None
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn log_summary_reads_gemini_stats() {
+ let tmp = tempfile::tempdir().unwrap();
+ let path = tmp.path().join("run.log");
+ fs::write(
+ &path,
+ concat!(
+ r#"{"type":"init","session_id":"s1","model":"auto"}"#,
+ "\n",
+ r#"{"type":"result","status":"success","stats":{"total_tokens":36787,"input_tokens":34727,"output_tokens":144,"cached":512,"input":34215,"duration_ms":12992,"tool_calls":2,"models":{}}}"#,
+ "\n",
+ ),
+ )
+ .unwrap();
+ let s = log_summary(&path).unwrap();
+ assert_eq!(s.cost_usd, 0.0);
+ assert_eq!(s.duration_ms, 12992);
+ assert_eq!(s.tokens.input, 34215);
+ assert_eq!(s.tokens.output, 144);
+ assert_eq!(s.tokens.cache_read, 512);
+ }
+}
diff --git a/crates/modula_services/templates/overview.md b/crates/modula_services/templates/overview.md
index 2bd95c2..797a405 100644
--- a/crates/modula_services/templates/overview.md
+++ b/crates/modula_services/templates/overview.md
@@ -375,13 +375,13 @@ The engine is the only spawner of agents. For each invocation (manual, scheduled
- Builds the env:
- `MODULA_WORKSPACE` — the workspace id (also the directory name)
- `MODULA_ENGINE_SOCKET` — path to the engine's local IPC socket (the `modula` CLI resolves it automatically)
- - `CLAUDE_CONFIG_DIR` / `OPENCODE_CONFIG_DIR` — the resolved provider's config dir (name depends on provider type)
+ - `CLAUDE_CONFIG_DIR` / `OPENCODE_CONFIG_DIR` / `CODEX_HOME` / `GEMINI_CLI_HOME` — the resolved provider's config dir (name depends on provider type; `GEMINI_CLI_HOME` is the parent of the `.gemini` dir)
- `MODULA_AGENT_EXTRA` — rendered "Inputs for this run" block, built from the agent's declared `args[]`
- `MODULA_LOG_TS` — log file naming
- `MODULA_LOOP_ITER` + `MODULA_LOOP_TOTAL` — 1-based current iteration and configured total (both `1` for non-looping agents)
- - Optional `MODULA_CLAUDE_MODEL` (claude) / `MODULA_PROVIDER_MODEL` (opencode)
+ - Optional `MODULA_CLAUDE_MODEL` (claude) / `MODULA_PROVIDER_MODEL` (opencode, codex, gemini)
- Opens a log file at `/logs/[-]-.log`.
-- Spawns the provider binary (e.g. `claude` or `opencode run`) via `ProviderRuntime::build_command`, as a detached child (`setsid`). The process survives engine restarts.
+- Spawns the provider binary (e.g. `claude`, `opencode run`, or `gemini -p`) via `ProviderRuntime::build_command`, as a detached child (`setsid`). The process survives engine restarts.
Providers are rows in the `providers` table — each entry pairs an opaque `id` (UUID) with a human-readable `name` (e.g. "claude-personal"), a config dir, and a provider type. Many agents can share one provider.
diff --git a/crates/modula_test_support/src/lib.rs b/crates/modula_test_support/src/lib.rs
index 85058ae..beadbd2 100644
--- a/crates/modula_test_support/src/lib.rs
+++ b/crates/modula_test_support/src/lib.rs
@@ -1,6 +1,6 @@
//! E2E test harness — boots the engine binary against a tempdir-rooted
//! workspace root over a unique per-test local IPC socket, prepends provider
-//! shims (`claude`, `opencode`, `codex` → mock-claude) to PATH, and hands out
+//! shims (`claude`, `opencode`, `codex`, `gemini` → mock-claude) to PATH, and hands out
//! connected gRPC service clients.
//!
//! Build artifacts are picked up from the workspace target dir. Each test gets
@@ -361,7 +361,7 @@ fn exe(name: &str) -> String {
}
}
-/// Install mock-claude as `claude`, `opencode`, and `codex` under `dir`.
+/// Install mock-claude as `claude`, `opencode`, `codex`, and `gemini` under `dir`.
/// The mock keys on MODULA_AGENT_NAME for recipe lookup, not argv, so one
/// binary handles all provider types without change.
fn install_provider_shims(dir: &Path) -> Result<()> {
@@ -374,7 +374,7 @@ fn install_provider_shims(dir: &Path) -> Result<()> {
mock.display()
);
}
- for name in ["claude", "opencode", "codex"] {
+ for name in ["claude", "opencode", "codex", "gemini"] {
let shim = dir.join(exe(name));
#[cfg(unix)]
std::os::unix::fs::symlink(&mock, &shim)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index c4d0538..0d7f7a9 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -27,7 +27,7 @@ The engine is split by layer; each crate depends only on the ones below it.
Test-only: `crates/modula_mock_claude` (a `mock-claude` binary that replays JSON
recipes as stream-json), `crates/modula_test_support` (the `Harness` that boots
-an engine and shims `claude`/`opencode`/`codex` onto `PATH`), and `tests/e2e`
+an engine and shims `claude`/`opencode`/`codex`/`gemini` onto `PATH`), and `tests/e2e`
(one integration binary per `tests/*.rs`).
**`apps/engine` depends on `apps/cli`, not the reverse** — the engine ships the
@@ -107,7 +107,7 @@ apps/desktop/
5. **Spawn = pure Rust.** No bash launcher. `services::spawn` invokes the
provider binary via `ProviderRuntime::build_command` with provider-specific
env (`CLAUDE_CONFIG_DIR` + optional `MODULA_CLAUDE_MODEL`;
- `OPENCODE_CONFIG_DIR` / `CODEX_HOME` + optional `MODULA_PROVIDER_MODEL`) plus
+ `OPENCODE_CONFIG_DIR` / `CODEX_HOME` / `GEMINI_CLI_HOME` + optional `MODULA_PROVIDER_MODEL`) plus
common env (`MODULA_WORKSPACE`, `MODULA_ENGINE_SOCKET`, `MODULA_AGENT_NAME`,
`MODULA_AGENT_EXTRA`, `MODULA_LOG_TS`, `MODULA_LOOP_ITER`,
`MODULA_LOOP_TOTAL`) and `setsid`, so the child survives engine restarts.
diff --git a/docs/MODULA.md b/docs/MODULA.md
index 84a484b..587ef5e 100644
--- a/docs/MODULA.md
+++ b/docs/MODULA.md
@@ -12,7 +12,7 @@ See also: [`CLI.md`](CLI.md) for the `modula` command reference, [`PLUGINS.md`](
| **Rust** | Pinned via `rust-toolchain.toml` (channel `1.88`) |
| **Node** | Node 20+ with `pnpm` or `npm` for the desktop frontend |
| **Git** | 2.20+ (worktree support) |
-| **At least one provider** | `claude`, `opencode`, or `codex` on `PATH` with a configured account |
+| **At least one provider** | `claude`, `opencode`, `codex`, or `gemini` on `PATH` with a configured account |
## Build from source
diff --git a/tests/e2e/tests/gemini_provider.rs b/tests/e2e/tests/gemini_provider.rs
new file mode 100644
index 0000000..ca51b22
--- /dev/null
+++ b/tests/e2e/tests/gemini_provider.rs
@@ -0,0 +1,118 @@
+//! E2E: gemini-typed provider spawns correctly and the run reaches completed,
+//! over gRPC IPC.
+
+use anyhow::Result;
+use modula_rpc::v1::{
+ CreateAgentRequest, CreateProviderRequest, GetProviderRequest, ListRunsForAgentRequest,
+ TriggerAgentRequest,
+};
+use modula_test_support::Harness;
+use std::time::{Duration, Instant};
+
+use modula_test_support::fixtures as common;
+
+#[tokio::test]
+async fn gemini_provider_agent_completes() -> Result<()> {
+ let h = Harness::start().await?;
+ let ws = common::fresh_workspace(&h, "demo").await?;
+
+ let cfg_dir = h.modula_dir.join(".gemini");
+ std::fs::create_dir_all(&cfg_dir)?;
+ let cfg_str = cfg_dir.to_string_lossy().to_string();
+
+ // Create a gemini-typed provider.
+ let gm_id = h
+ .providers()
+ .create(CreateProviderRequest {
+ workspace_id: ws.clone(),
+ name: "Gemini".into(),
+ r#type: "gemini".into(),
+ config_dir: cfg_str.clone(),
+ description: None,
+ mcp_servers: vec![],
+ })
+ .await?
+ .into_inner()
+ .id;
+
+ // Round-trip — type and config_dir survive.
+ let detail = h
+ .providers()
+ .get(GetProviderRequest {
+ workspace_id: ws.clone(),
+ provider_id: gm_id.clone(),
+ })
+ .await?
+ .into_inner();
+ assert_eq!(detail.r#type, "gemini");
+ assert_eq!(detail.config_dir, cfg_str);
+
+ // Create a manual agent using the gemini provider.
+ let agent_id = h
+ .agents()
+ .create(CreateAgentRequest {
+ workspace_id: ws.clone(),
+ name: "gm-agent".into(),
+ description: "gemini test agent".into(),
+ provider_id: gm_id,
+ model: None,
+ manual: true,
+ schedule: None,
+ rules: vec![],
+ args: vec![],
+ prompt: "hello from gemini".into(),
+ spawn_per_variant: false,
+ skills: vec![],
+ })
+ .await?
+ .into_inner()
+ .id;
+
+ // mock-claude keys on MODULA_AGENT_NAME, not argv, so it runs identically
+ // whether invoked as "claude" or "gemini" — no custom recipe.
+
+ // Trigger a manual run.
+ h.agents()
+ .trigger(TriggerAgentRequest {
+ workspace_id: ws.clone(),
+ agent_id: agent_id.clone(),
+ args: None,
+ })
+ .await?;
+
+ // Poll until the run reaches completed (≤ 10s).
+ let deadline = Instant::now() + Duration::from_secs(10);
+ let final_run = loop {
+ let runs = h
+ .runs()
+ .list_for_agent(ListRunsForAgentRequest {
+ workspace_id: ws.clone(),
+ agent_id: agent_id.clone(),
+ })
+ .await?
+ .into_inner()
+ .runs;
+ if let Some(run) = runs.first() {
+ if run.status == "completed" || run.status == "failed" {
+ break Some(run.clone());
+ }
+ }
+ if Instant::now() >= deadline {
+ break None;
+ }
+ tokio::time::sleep(Duration::from_millis(300)).await;
+ };
+ let final_run = final_run.expect("run did not finish in time");
+ assert_eq!(final_run.status, "completed", "run did not complete");
+
+ // Confirm a log file was written.
+ let log_path = final_run.log_path.expect("log_path");
+ let log_file = h.workspace_path(&ws).join("logs").join(&log_path);
+ assert!(
+ log_file.exists(),
+ "log file missing: {}",
+ log_file.display()
+ );
+
+ Ok(())
+}
diff --git a/tests/e2e/tests/provider_mcp.rs b/tests/e2e/tests/provider_mcp.rs
index db7a4eb..904c9b0 100644
--- a/tests/e2e/tests/provider_mcp.rs
+++ b/tests/e2e/tests/provider_mcp.rs
@@ -251,3 +251,51 @@ async fn opencode_mcp_roundtrip() -> Result<()> {
assert_eq!(err.code(), Code::InvalidArgument);
Ok(())
}
+
+#[tokio::test]
+async fn gemini_mcp_roundtrip() -> Result<()> {
+ let h = Harness::start().await?;
+ let ws = common::fresh_workspace(&h, "demo").await?;
+ let dir = h.modula_dir.join(".gemini");
+ std::fs::create_dir_all(&dir)?;
+ let file = dir.join("settings.json");
+ std::fs::write(
+ &file,
+ serde_json::to_string_pretty(&json!({
+ "general": { "vimMode": true },
+ "mcpServers": {
+ "local-tool": { "command": "node", "args": ["x.js"] }
+ }
+ }))?,
+ )?;
+
+ let id = create_typed(
+ &h,
+ &ws,
+ "p-gemini",
+ "gemini",
+ &dir,
+ vec![mcp("atlassian", ATLASSIAN_URL, Some("tok-1"))],
+ )
+ .await?;
+
+ let servers = get_mcp(&h, &ws, &id).await?;
+ assert_eq!(servers.len(), 1);
+ assert_eq!(servers[0].url, ATLASSIAN_URL);
+ assert_eq!(servers[0].auth_token.as_deref(), Some("Bearer tok-1"));
+
+ // Streamable HTTP is `httpUrl` (plain `url` means SSE to Gemini).
+ let disk: Json = serde_json::from_str(&std::fs::read_to_string(&file)?)?;
+ assert_eq!(disk["general"]["vimMode"], true);
+ assert_eq!(disk["mcpServers"]["local-tool"]["command"], "node");
+ let atl = &disk["mcpServers"]["atlassian"];
+ assert_eq!(atl["httpUrl"], ATLASSIAN_URL);
+ assert!(atl.get("url").is_none());
+ assert_eq!(atl["headers"]["Authorization"], "Bearer tok-1");
+
+ put_mcp(&h, &ws, &id, vec![]).await?;
+ let disk: Json = serde_json::from_str(&std::fs::read_to_string(&file)?)?;
+ assert!(disk["mcpServers"].get("atlassian").is_none());
+ assert_eq!(disk["mcpServers"]["local-tool"]["command"], "node");
+ Ok(())
+}