Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/components/icons/GeminiIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function GeminiIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-label="Gemini">
<title>Gemini</title>
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" />
</svg>
);
}
8 changes: 6 additions & 2 deletions apps/desktop/src/components/provider-form/ProviderFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function ProviderFields({
</Field>
<Field
label="Type"
description="Which CLI tool this provider drives (claude, codex, opencode)."
description="Which CLI tool this provider drives (claude, codex, opencode, gemini)."
>
<DropdownSelect
variant="field"
Expand All @@ -68,7 +68,11 @@ export function ProviderFields({
</Field>
<Field
label="Config dir"
description="Directory the provider reads its config and credentials from."
description={
state.type === "gemini"
? "Gemini reads a directory named .gemini, e.g. ~/.gemini."
: "Directory the provider reads its config and credentials from."
}
>
<FileInput
value={state.config_dir}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/components/provider-form/useProviderForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export function defaultConfigDir(type: string): string {
return "~/.config/opencode";
case "codex":
return "~/.codex";
case "gemini":
return "~/.gemini";
default:
return "~/.claude";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useSystemTools } from "../../queries/system";
import { errorMessage } from "../../services/client";
import { ClaudeIcon } from "../icons/ClaudeIcon";
import { CodexIcon } from "../icons/CodexIcon";
import { GeminiIcon } from "../icons/GeminiIcon";
import { GitHubCliIcon } from "../icons/GitHubCliIcon";
import { OpenCodeIcon } from "../icons/OpenCodeIcon";
import { openUrl } from "../openUrl";
Expand All @@ -15,6 +16,7 @@ const TOOLS: Record<
claude: { name: "Claude", Icon: ClaudeIcon, installUrl: "https://code.claude.com/docs/en/setup" },
codex: { name: "Codex", Icon: CodexIcon, installUrl: "https://developers.openai.com/codex/cli" },
opencode: { name: "OpenCode", Icon: OpenCodeIcon, installUrl: "https://opencode.ai" },
gemini: { name: "Gemini CLI", Icon: GeminiIcon, installUrl: "https://geminicli.com" },
};

/**
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/providerTypes.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ClaudeIcon } from "../components/icons/ClaudeIcon";
import { CodexIcon } from "../components/icons/CodexIcon";
import { GeminiIcon } from "../components/icons/GeminiIcon";
import { GenericProviderIcon } from "../components/icons/GenericProviderIcon";
import { OpenCodeIcon } from "../components/icons/OpenCodeIcon";

Expand All @@ -16,6 +17,7 @@ export const PROVIDER_TYPES: ProviderType[] = [
{ id: "claude", label: "Claude", color: "#D97757", Icon: ClaudeIcon },
{ id: "codex", label: "Codex", color: "#000000", Icon: CodexIcon },
{ id: "opencode", label: "OpenCode", color: "#4B4646", Icon: OpenCodeIcon },
{ id: "gemini", label: "Gemini", color: "#8E75B2", Icon: GeminiIcon },
];

const BY_ID: Record<string, ProviderType> = Object.fromEntries(
Expand Down
15 changes: 15 additions & 0 deletions crates/modula_services/provider_catalog.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
3 changes: 2 additions & 1 deletion crates/modula_services/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ impl GenerationService {

let prompt = compose_prompt(&params.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),
Expand Down
2 changes: 1 addition & 1 deletion crates/modula_services/src/mcp_config/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?)
}
}
Expand Down
207 changes: 207 additions & 0 deletions crates/modula_services/src/mcp_config/gemini.rs
Original file line number Diff line number Diff line change
@@ -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<Value> {
let parsed: Option<Value> =
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<Vec<McpServer>> {
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());
}
}
14 changes: 9 additions & 5 deletions crates/modula_services/src/mcp_config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -40,6 +41,7 @@ pub fn for_type(provider_type: &str) -> Option<Box<dyn McpConfigStrategy>> {
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,
}
Expand All @@ -64,16 +66,17 @@ fn auth_token(server: &McpServer) -> Option<String> {
}
}

/// 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<String, Value>,
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));
}
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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.
}
Loading
Loading