diff --git a/.factory/droids/harness-integration-specialist.md b/.factory/droids/harness-integration-specialist.md new file mode 100644 index 0000000..b365707 --- /dev/null +++ b/.factory/droids/harness-integration-specialist.md @@ -0,0 +1,6 @@ +--- +name: harness-integration-specialist +description: This droid specializes in adding comprehensive support for new test harnesses to the Brittle project. Given a harness name, it researches the harness's configuration paths, capabilities, and feature alignment with Brittle's library crates (MCPs, skills, rules, etc.). It then implements support across both library crates, validates through testing, and only upon successful test passage, integrates the harness into the binary crate. +model: claude-opus-4-6 +--- +You are a harness integration specialist for the Brittle configuration and profile management system. When given a harness name, you must: (1) Research the harness thoroughly to understand its config file locations, directory structures, supported features, and capabilities; (2) Analyze how the harness's features map to Brittle's library functionality including MCPs, skills, and rules; (3) Implement harness support in both library crates with complete config path handling and feature detection; (4) Write and execute comprehensive tests for the library implementations; (5) Only after ALL tests pass without errors, implement the harness integration in the binary crate. Never skip the testing phase or proceed to binary integration with failing tests. Be methodical and thorough in researching harness-specific details like default config locations, environment variables, and version-specific behaviors. Prioritize correctness and test coverage over speed. Document any harness-specific quirks or limitations you discover during research. \ No newline at end of file diff --git a/README.md b/README.md index abe0488..23869d4 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ view = "Dashboard" # Will add more later :P | Claude Code | `~/.claude/` | Full support | | OpenCode | `~/.config/opencode/` | Full support | | Goose | `~/.config/goose/` | Full support | -| Amp | `~/.amp/` | Experimental (ish) | +| Amp | `~/.config/amp/` | Experimental (ish) | | Copilot CLI | `~/.copilot/` | Full support | | Crush | `~/.config/crush/` | Full support (skills + MCP) | diff --git a/crates/bridle/src/cli/install.rs b/crates/bridle/src/cli/install.rs index c235cbf..98946a5 100644 --- a/crates/bridle/src/cli/install.rs +++ b/crates/bridle/src/cli/install.rs @@ -398,6 +398,7 @@ fn select_targets(selected: &SelectedComponents) -> Result> { HarnessKind::CopilotCli, HarnessKind::Crush, HarnessKind::Droid, + HarnessKind::GeminiCli, ]; let mut groups: Vec = Vec::new(); diff --git a/crates/bridle/src/cli/profile.rs b/crates/bridle/src/cli/profile.rs index ae8dc58..4fc8c16 100644 --- a/crates/bridle/src/cli/profile.rs +++ b/crates/bridle/src/cli/profile.rs @@ -23,6 +23,7 @@ pub(crate) fn resolve_harness(name: &str) -> Result { "copilot-cli" | "copilot" | "ghcp" => HarnessKind::CopilotCli, "crush" => HarnessKind::Crush, "droid" | "factory" => HarnessKind::Droid, + "gemini-cli" | "gemini" => HarnessKind::GeminiCli, _ => return Err(Error::UnknownHarness(name.to_string())), }; Ok(Harness::new(kind)) diff --git a/crates/bridle/src/config/manager/extraction/mod.rs b/crates/bridle/src/config/manager/extraction/mod.rs index cfebea2..157478a 100644 --- a/crates/bridle/src/config/manager/extraction/mod.rs +++ b/crates/bridle/src/config/manager/extraction/mod.rs @@ -253,7 +253,11 @@ fn extract_mcp_from_ampcode_config(profile_path: &Path) -> Result obj, None => return Ok(Vec::new()), }; @@ -271,10 +275,23 @@ fn extract_mcp_from_ampcode_config(profile_path: &Path) -> Result Option parsed .get("amp.theme") .and_then(|v| v.as_str()) + .or_else(|| parsed.get("amp.terminal.theme").and_then(|v| v.as_str())) .map(String::from) } "claude-code" => { diff --git a/crates/bridle/src/harness/install_instructions.rs b/crates/bridle/src/harness/install_instructions.rs index b109163..ea94b23 100644 --- a/crates/bridle/src/harness/install_instructions.rs +++ b/crates/bridle/src/harness/install_instructions.rs @@ -9,6 +9,7 @@ pub fn get_install_instructions(kind: HarnessKind) -> Vec { HarnessKind::CopilotCli => copilot_cli_instructions(), HarnessKind::Crush => crush_instructions(), HarnessKind::Droid => droid_instructions(), + HarnessKind::GeminiCli => gemini_cli_instructions(), _ => vec!["Unknown harness".to_string()], } } @@ -48,6 +49,13 @@ fn droid_instructions() -> Vec { ] } +fn gemini_cli_instructions() -> Vec { + vec![ + "- npm install -g @google/gemini-cli".to_string(), + "- npx @google/gemini-cli".to_string(), + ] +} + fn claude_code_instructions() -> Vec { if cfg!(target_os = "macos") { vec![ @@ -143,6 +151,7 @@ pub fn get_empty_state_message( HarnessKind::CopilotCli => "Copilot CLI", HarnessKind::Crush => "Crush", HarnessKind::Droid => "Factory Droid", + HarnessKind::GeminiCli => "Gemini CLI", _ => "Unknown", }; @@ -192,6 +201,7 @@ pub fn get_empty_state_message( HarnessKind::CopilotCli => "copilot", HarnessKind::Crush => "crush", HarnessKind::Droid => "droid", + HarnessKind::GeminiCli => "gemini", _ => "", }; diff --git a/crates/bridle/src/harness/mod.rs b/crates/bridle/src/harness/mod.rs index 456d1ca..49c3050 100644 --- a/crates/bridle/src/harness/mod.rs +++ b/crates/bridle/src/harness/mod.rs @@ -61,6 +61,7 @@ impl HarnessConfig for harness_locate::Harness { harness_locate::HarnessKind::CopilotCli => "copilot-cli", harness_locate::HarnessKind::Crush => "crush", harness_locate::HarnessKind::Droid => "droid", + harness_locate::HarnessKind::GeminiCli => "gemini-cli", _ => "unknown", } } diff --git a/crates/bridle/src/install/mcp_config.rs b/crates/bridle/src/install/mcp_config.rs index 7166ac7..cd401cc 100644 --- a/crates/bridle/src/install/mcp_config.rs +++ b/crates/bridle/src/install/mcp_config.rs @@ -32,6 +32,7 @@ fn get_mcp_key(kind: HarnessKind) -> &'static str { HarnessKind::Goose => "extensions", HarnessKind::AmpCode => "amp.mcpServers", HarnessKind::Droid => "mcpServers", + HarnessKind::GeminiCli => "mcpServers", _ => "mcpServers", } } @@ -62,7 +63,13 @@ pub fn read_mcp_config( }; let key = get_mcp_key(kind); - let mcp_section = parsed.get(key).and_then(|v| v.as_object()); + let mcp_section = match kind { + HarnessKind::GeminiCli => parsed + .get(key) + .or_else(|| parsed.get("mcp")) + .and_then(|v| v.as_object()), + _ => parsed.get(key).and_then(|v| v.as_object()), + }; match mcp_section { Some(obj) => { diff --git a/crates/bridle/src/install/mcp_installer.rs b/crates/bridle/src/install/mcp_installer.rs index 8e1e1d2..20ea950 100644 --- a/crates/bridle/src/install/mcp_installer.rs +++ b/crates/bridle/src/install/mcp_installer.rs @@ -44,6 +44,7 @@ fn get_profile_config_path(profile_dir: &Path, harness_kind: HarnessKind) -> Pat HarnessKind::CopilotCli => profile_dir.join("mcp-config.json"), HarnessKind::Crush => profile_dir.join("crush.json"), HarnessKind::Droid => profile_dir.join("mcp.json"), + HarnessKind::GeminiCli => profile_dir.join("settings.json"), _ => profile_dir.join("config.json"), } } diff --git a/crates/bridle/src/install/types.rs b/crates/bridle/src/install/types.rs index 1ec2784..c9c63d4 100644 --- a/crates/bridle/src/install/types.rs +++ b/crates/bridle/src/install/types.rs @@ -18,6 +18,7 @@ pub fn parse_harness_kind(id: &str) -> Option { "copilot-cli" | "copilot" | "ghcp" => Some(HarnessKind::CopilotCli), "crush" => Some(HarnessKind::Crush), "droid" | "factory" => Some(HarnessKind::Droid), + "gemini-cli" | "gemini" => Some(HarnessKind::GeminiCli), _ => None, } } diff --git a/crates/bridle/src/tui/mod.rs b/crates/bridle/src/tui/mod.rs index 4f580e5..587d047 100644 --- a/crates/bridle/src/tui/mod.rs +++ b/crates/bridle/src/tui/mod.rs @@ -55,6 +55,7 @@ fn harness_id(kind: &HarnessKind) -> &'static str { HarnessKind::CopilotCli => "copilot-cli", HarnessKind::Crush => "crush", HarnessKind::Droid => "droid", + HarnessKind::GeminiCli => "gemini-cli", _ => "unknown", } } @@ -68,6 +69,7 @@ fn harness_name(kind: &HarnessKind) -> &'static str { HarnessKind::CopilotCli => "Copilot CLI", HarnessKind::Crush => "Crush", HarnessKind::Droid => "Factory Droid", + HarnessKind::GeminiCli => "Gemini CLI", _ => "Unknown", } } diff --git a/crates/harness-locate/src/harness/amp_code.rs b/crates/harness-locate/src/harness/amp_code.rs index a2f5a42..5db0e88 100644 --- a/crates/harness-locate/src/harness/amp_code.rs +++ b/crates/harness-locate/src/harness/amp_code.rs @@ -2,9 +2,10 @@ //! //! AMP Code stores its configuration in: //! - **Global**: `~/.config/amp/` -//! - **Project**: Not supported (AMP has no project-scoped config directory) +//! - **Project**: `.amp/` in project root //! -//! Note: Skills are shared with Goose at `~/.config/agents/skills/`. +//! Note: Skills are shared with Goose at `~/.config/agents/skills/`, but Amp +//! also supports `~/.config/amp/skills/` as a secondary global location. use std::path::PathBuf; @@ -26,21 +27,28 @@ pub fn global_config_dir() -> Result { Ok(platform::config_dir()?.join("amp")) } +/// Returns the project-local AMP Code configuration directory. +/// +/// # Arguments +/// +/// * `project_root` - Path to the project root directory +#[must_use] +pub fn project_config_dir(project_root: &std::path::Path) -> PathBuf { + project_root.join(".amp") +} + /// Returns the config directory for the given scope. /// /// - **Global**: `~/.config/amp/` -/// - **Project**: Returns `UnsupportedScope` error (AMP has no project config) +/// - **Project**: `.amp/` /// /// # Errors /// -/// Returns `Error::UnsupportedScope` for project scope. +/// Returns an error if the configuration directory cannot be determined. pub fn config_dir(scope: &Scope) -> Result { match scope { Scope::Global => global_config_dir(), - Scope::Project(_) => Err(Error::UnsupportedScope { - harness: "AMP Code".to_string(), - scope: "project".to_string(), - }), + Scope::Project(root) => Ok(project_config_dir(root)), Scope::Custom(path) => Ok(path.clone()), } } @@ -48,11 +56,11 @@ pub fn config_dir(scope: &Scope) -> Result { /// Returns the commands directory for the given scope. /// /// - **Global**: `~/.config/amp/commands/` -/// - **Project**: `.agents/commands/` +/// - **Project**: `.amp/commands/` pub fn commands_dir(scope: &Scope) -> Result { match scope { Scope::Global => Ok(global_config_dir()?.join("commands")), - Scope::Project(root) => Ok(root.join(".agents").join("commands")), + Scope::Project(root) => Ok(project_config_dir(root).join("commands")), Scope::Custom(path) => Ok(path.join("commands")), } } @@ -62,26 +70,32 @@ pub fn commands_dir(scope: &Scope) -> Result { /// AMP stores MCP configuration in `settings.json` within the config directory. /// /// - **Global**: `~/.config/amp/` -/// - **Project**: Returns `UnsupportedScope` error +/// - **Project**: `.amp/` /// /// # Errors /// -/// Returns `Error::UnsupportedScope` for project scope. +/// Returns an error if the configuration directory cannot be determined. pub fn mcp_dir(scope: &Scope) -> Result { config_dir(scope) } /// Returns the skills directory for the given scope. /// -/// AMP shares the skills directory with Goose: -/// - **Global**: `~/.config/agents/skills/` +/// AMP supports multiple skills directories: +/// - **Global**: `~/.config/agents/skills/` (preferred) or `~/.config/amp/skills/` /// - **Project**: `.agents/skills/` #[must_use] pub fn skills_dir(scope: &Scope) -> Option { match scope { - Scope::Global => platform::config_dir() - .ok() - .map(|p| p.join("agents").join("skills")), + Scope::Global => { + let config = platform::config_dir().ok()?; + let shared = config.join("agents").join("skills"); + if shared.exists() { + Some(shared) + } else { + Some(config.join("amp").join("skills")) + } + } Scope::Project(root) => Some(root.join(".agents").join("skills")), Scope::Custom(path) => Some(path.join("skills")), } @@ -226,15 +240,9 @@ mod tests { #[test] fn config_dir_project_returns_unsupported_scope() { let root = PathBuf::from("/some/project"); - let result = config_dir(&Scope::Project(root)); - assert!(result.is_err()); - - if let Err(Error::UnsupportedScope { harness, scope }) = result { - assert_eq!(harness, "AMP Code"); - assert_eq!(scope, "project"); - } else { - panic!("Expected UnsupportedScope error"); - } + let result = config_dir(&Scope::Project(root.clone())); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), root.join(".amp")); } #[test] @@ -255,7 +263,7 @@ mod tests { let result = commands_dir(&Scope::Project(root)); assert!(result.is_ok()); let path = result.unwrap(); - assert_eq!(path, PathBuf::from("/some/project/.agents/commands")); + assert_eq!(path, PathBuf::from("/some/project/.amp/commands")); } #[test] @@ -267,7 +275,7 @@ mod tests { let result = skills_dir(&Scope::Global); assert!(result.is_some()); let path = result.unwrap(); - assert!(path.ends_with("agents/skills")); + assert!(path.ends_with("agents/skills") || path.ends_with("amp/skills")); } #[test] @@ -303,8 +311,8 @@ mod tests { fn mcp_dir_project_returns_unsupported_scope() { let root = PathBuf::from("/some/project"); let result = mcp_dir(&Scope::Project(root)); - assert!(result.is_err()); - assert!(matches!(result, Err(Error::UnsupportedScope { .. }))); + assert!(result.is_ok()); + assert!(result.unwrap().ends_with(".amp")); } #[test] diff --git a/crates/harness-locate/src/harness/gemini_cli.rs b/crates/harness-locate/src/harness/gemini_cli.rs new file mode 100644 index 0000000..75aca1f --- /dev/null +++ b/crates/harness-locate/src/harness/gemini_cli.rs @@ -0,0 +1,394 @@ +//! Gemini CLI harness implementation. +//! +//! Gemini CLI (Google's AI coding assistant) stores its configuration in: +//! - **Global**: `~/.gemini/` +//! - **Project**: `.gemini/` in project root + +use std::path::PathBuf; + +use crate::error::{Error, Result}; +use crate::mcp::McpServer; +use crate::platform; +use crate::types::Scope; + +use super::mcp_parse::{self, ParseConfig}; + +/// Returns the global Gemini CLI configuration directory. +/// +/// Returns `~/.gemini/` on all platforms. +/// +/// # Errors +/// +/// Returns an error if the home directory cannot be determined. +pub fn global_config_dir() -> Result { + Ok(platform::home_dir()?.join(".gemini")) +} + +/// Returns the project-local Gemini CLI configuration directory. +/// +/// # Arguments +/// +/// * `project_root` - Path to the project root directory +#[must_use] +pub fn project_config_dir(project_root: &std::path::Path) -> PathBuf { + project_root.join(".gemini") +} + +/// Returns the config directory for the given scope. +/// +/// This is the base configuration directory. +pub fn config_dir(scope: &Scope) -> Result { + match scope { + Scope::Global => global_config_dir(), + Scope::Project(root) => Ok(project_config_dir(root)), + Scope::Custom(path) => Ok(path.clone()), + } +} + +/// Returns the MCP configuration directory for the given scope. +/// +/// Gemini CLI stores MCP configuration in the base config directory +/// (settings.json). +pub fn mcp_dir(scope: &Scope) -> Result { + config_dir(scope) +} + +/// Returns the rules directory for the given scope. +/// +/// Gemini CLI stores rules files (GEMINI.md) in `~/.gemini/` and within the +/// project tree (root, ancestors, and subdirectories). This returns the +/// primary directory for the scope. +#[must_use] +pub fn rules_dir(scope: &Scope) -> Option { + match scope { + Scope::Global => global_config_dir().ok(), + Scope::Project(root) => Some(root.clone()), + Scope::Custom(path) => Some(path.clone()), + } +} + +/// Checks if Gemini CLI is installed on this system. +/// +/// Currently checks if the global config directory exists. +pub fn is_installed() -> bool { + global_config_dir().map(|p| p.exists()).unwrap_or(false) +} + +/// Parses a single MCP server from Gemini CLI's native JSON format. +/// +/// Gemini CLI uses `settings.json` with `mcpServers` entries. Each server +/// specifies one of `command` (stdio), `url` (SSE), or `httpUrl` (HTTP). +/// +/// # Arguments +/// * `value` - The JSON value representing the server config +/// +/// # Errors +/// Returns an error if the JSON is malformed or missing required fields. +pub(crate) fn parse_mcp_server(value: &serde_json::Value) -> Result { + let config = ParseConfig::GEMINI_CLI; + let obj = value + .as_object() + .ok_or_else(|| Error::UnsupportedMcpConfig { + harness: config.harness_name.into(), + reason: "Server config must be an object".into(), + })?; + + if let Some(http_url) = obj.get("httpUrl") { + let url = http_url + .as_str() + .ok_or_else(|| Error::UnsupportedMcpConfig { + harness: config.harness_name.into(), + reason: "'httpUrl' must be a string".into(), + })? + .to_string(); + let mut normalized = obj.clone(); + normalized.insert("url".to_string(), serde_json::Value::String(url)); + return mcp_parse::parse_http_server(&normalized, &config); + } + + if obj.get("url").is_some() { + return mcp_parse::parse_sse_server(obj, &config); + } + + if obj.get("command").is_some() { + return mcp_parse::parse_stdio_server(obj, &config); + } + + Err(Error::UnsupportedMcpConfig { + harness: config.harness_name.into(), + reason: "MCP server missing 'command', 'url', or 'httpUrl'".into(), + }) +} + +/// Parses all MCP servers from a Gemini CLI config JSON. +/// +/// Gemini CLI's MCP configuration is stored under `mcpServers`. If not found, +/// returns an empty vec +/// (no error). +/// +/// # Arguments +/// * `config` - The full config JSON +/// +/// # Errors +/// Returns an error if the JSON is malformed. +pub(crate) fn parse_mcp_servers(config: &serde_json::Value) -> Result> { + // Gemini CLI may not have MCP servers configured - return empty vec if key missing + if config.get("mcpServers").is_none() { + return Ok(Vec::new()); + } + mcp_parse::parse_servers_from_key( + config, + "mcpServers", + &ParseConfig::GEMINI_CLI, + parse_mcp_server, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::EnvValue; + use serde_json::json; + + #[test] + fn global_config_dir_is_absolute() { + // Skip if home dir cannot be determined (CI environments) + if platform::home_dir().is_err() { + return; + } + + let result = global_config_dir(); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.is_absolute()); + assert!(path.ends_with(".gemini")); + } + + #[test] + fn project_config_dir_is_relative_to_root() { + let root = PathBuf::from("/some/project"); + let config = project_config_dir(&root); + assert_eq!(config, PathBuf::from("/some/project/.gemini")); + } + + #[test] + fn config_dir_global_returns_home_gemini() { + if platform::home_dir().is_err() { + return; + } + + let result = config_dir(&Scope::Global); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.ends_with(".gemini")); + } + + #[test] + fn config_dir_project_returns_dot_gemini() { + let root = PathBuf::from("/some/project"); + let result = config_dir(&Scope::Project(root)); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PathBuf::from("/some/project/.gemini")); + } + + #[test] + fn mcp_dir_returns_config_dir() { + if platform::home_dir().is_err() { + return; + } + + let result = mcp_dir(&Scope::Global); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.ends_with(".gemini")); + } + + #[test] + fn rules_dir_global_returns_config() { + if platform::home_dir().is_err() { + return; + } + + let result = rules_dir(&Scope::Global); + assert!(result.is_some()); + assert!(result.unwrap().ends_with(".gemini")); + } + + #[test] + fn rules_dir_project_returns_root() { + let root = PathBuf::from("/some/project"); + let result = rules_dir(&Scope::Project(root.clone())); + assert!(result.is_some()); + assert_eq!(result.unwrap(), root); + } + + #[test] + fn parse_stdio_server_basic() { + let json = json!({ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server"] + }); + + let result = parse_mcp_server(&json).unwrap(); + + if let McpServer::Stdio(server) = result { + assert_eq!(server.command, "npx"); + assert_eq!(server.args, vec!["-y", "@modelcontextprotocol/server"]); + assert!(server.enabled); + assert!(server.env.is_empty()); + assert_eq!(server.timeout_ms, None); + } else { + panic!("Expected Stdio variant"); + } + } + + #[test] + fn parse_stdio_server_with_env() { + let json = json!({ + "command": "node", + "args": ["server.js"], + "env": { + "API_KEY": "${MY_API_KEY}", + "DEBUG": "true" + }, + "timeout": 30000 + }); + + let result = parse_mcp_server(&json).unwrap(); + + if let McpServer::Stdio(server) = result { + assert_eq!(server.command, "node"); + assert_eq!(server.args, vec!["server.js"]); + assert_eq!(server.env.len(), 2); + assert_eq!( + server.env.get("API_KEY"), + Some(&EnvValue::env("MY_API_KEY")) + ); + assert_eq!(server.env.get("DEBUG"), Some(&EnvValue::plain("true"))); + assert_eq!(server.timeout_ms, Some(30000)); + assert!(server.enabled); + } else { + panic!("Expected Stdio variant"); + } + } + + #[test] + fn parse_http_server_basic() { + let json = json!({ + "httpUrl": "https://api.example.com/mcp" + }); + + let result = parse_mcp_server(&json).unwrap(); + + if let McpServer::Http(server) = result { + assert_eq!(server.url, "https://api.example.com/mcp"); + assert!(server.enabled); + assert!(server.headers.is_empty()); + assert!(server.oauth.is_none()); + } else { + panic!("Expected Http variant"); + } + } + + #[test] + fn parse_sse_server_basic() { + let json = json!({ + "url": "https://example.com/sse", + "timeout": 45000 + }); + + let result = parse_mcp_server(&json).unwrap(); + + if let McpServer::Sse(server) = result { + assert_eq!(server.url, "https://example.com/sse"); + assert_eq!(server.timeout_ms, Some(45000)); + assert!(server.enabled); + assert!(server.headers.is_empty()); + } else { + panic!("Expected Sse variant"); + } + } + + #[test] + fn parse_http_server_with_http_url() { + let json = json!({ + "httpUrl": "https://example.com/http" + }); + + let result = parse_mcp_server(&json).unwrap(); + + if let McpServer::Http(server) = result { + assert_eq!(server.url, "https://example.com/http"); + } else { + panic!("Expected Http variant"); + } + } + + #[test] + fn parse_mcp_server_missing_transport_fields() { + let json = json!({ + "args": ["test"] + }); + + let result = parse_mcp_server(&json); + assert!(result.is_err()); + } + + #[test] + fn parse_mcp_servers_missing_mcp_key_returns_empty() { + let config = json!({ + "other_key": {} + }); + + let result = parse_mcp_servers(&config).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn parse_mcp_servers_empty_mcp() { + let config = json!({ + "mcpServers": {} + }); + + let result = parse_mcp_servers(&config).unwrap(); + assert_eq!(result.len(), 0); + } + + #[test] + fn parse_mcp_servers_with_servers() { + let config = json!({ + "mcpServers": { + "server1": { + "command": "npx", + "args": ["-y", "server1"] + }, + "server2": { + "url": "https://example.com/sse" + } + } + }); + + let result = parse_mcp_servers(&config).unwrap(); + assert_eq!(result.len(), 2); + + let names: Vec<&str> = result.iter().map(|(n, _)| n.as_str()).collect(); + assert!(names.contains(&"server1")); + assert!(names.contains(&"server2")); + } + + #[test] + fn parse_stdio_server_without_args() { + let json = json!({ + "command": "test" + }); + + let result = parse_mcp_server(&json).unwrap(); + + if let McpServer::Stdio(server) = result { + assert_eq!(server.command, "test"); + assert!(server.args.is_empty()); + } else { + panic!("Expected Stdio variant"); + } + } +} diff --git a/crates/harness-locate/src/harness/mcp_parse.rs b/crates/harness-locate/src/harness/mcp_parse.rs index cf7126a..96ae10c 100644 --- a/crates/harness-locate/src/harness/mcp_parse.rs +++ b/crates/harness-locate/src/harness/mcp_parse.rs @@ -4,6 +4,7 @@ //! to reduce code duplication when parsing MCP server configurations from JSON. use std::collections::HashMap; +use std::path::PathBuf; use crate::error::{Error, Result}; use crate::mcp::{HttpMcpServer, McpServer, OAuthConfig, SseMcpServer, StdioMcpServer}; @@ -32,6 +33,8 @@ pub struct ParseConfig { pub timeout_field: &'static str, /// Whether timeout is in seconds (needs conversion to ms). pub timeout_in_seconds: bool, + /// Field name for working directory, if supported. + pub cwd_field: Option<&'static str>, } impl ParseConfig { @@ -47,6 +50,7 @@ impl ParseConfig { disabled_field: None, timeout_field: "timeout", timeout_in_seconds: false, + cwd_field: None, }; /// OpenCode style parsing config. @@ -61,6 +65,7 @@ impl ParseConfig { disabled_field: None, timeout_field: "timeout", timeout_in_seconds: false, + cwd_field: None, }; /// Goose style parsing config. @@ -75,6 +80,7 @@ impl ParseConfig { disabled_field: None, timeout_field: "timeout", timeout_in_seconds: true, + cwd_field: None, }; /// Crush style parsing config. @@ -89,6 +95,7 @@ impl ParseConfig { disabled_field: Some("disabled"), timeout_field: "timeout_ms", timeout_in_seconds: false, + cwd_field: None, }; /// Droid style parsing config. @@ -103,6 +110,7 @@ impl ParseConfig { disabled_field: Some("disabled"), timeout_field: "timeout", timeout_in_seconds: false, + cwd_field: None, }; /// AMP Code style parsing config. @@ -117,6 +125,22 @@ impl ParseConfig { disabled_field: None, timeout_field: "timeout", timeout_in_seconds: false, + cwd_field: None, + }; + + /// Gemini CLI style parsing config. + pub const GEMINI_CLI: Self = Self { + harness_name: "Gemini CLI", + harness_kind: HarnessKind::GeminiCli, + args_field: "args", + env_field: "env", + command_field: "command", + url_field: "url", + plain_env_values: false, + disabled_field: None, + timeout_field: "timeout", + timeout_in_seconds: false, + cwd_field: Some("cwd"), }; /// Copilot CLI style parsing config. @@ -131,6 +155,7 @@ impl ParseConfig { disabled_field: None, timeout_field: "timeout", timeout_in_seconds: false, + cwd_field: None, }; } @@ -230,6 +255,23 @@ pub fn parse_timeout( } } +fn parse_cwd( + obj: &serde_json::Map, + field: &str, + harness: &str, +) -> Result> { + let Some(value) = obj.get(field) else { + return Ok(None); + }; + + let raw = value.as_str().ok_or_else(|| Error::UnsupportedMcpConfig { + harness: harness.to_string(), + reason: format!("'{}' must be a string", field), + })?; + + Ok(Some(PathBuf::from(raw))) +} + /// Parse enabled/disabled flag from JSON object. pub fn parse_enabled( obj: &serde_json::Map, @@ -270,13 +312,17 @@ pub fn parse_stdio_server( config.timeout_in_seconds, config.harness_name, )?; + let cwd = match config.cwd_field { + Some(field) => parse_cwd(obj, field, config.harness_name)?, + None => None, + }; let enabled = parse_enabled(obj, config.disabled_field); Ok(McpServer::Stdio(StdioMcpServer { command, args, env, - cwd: None, + cwd, enabled, timeout_ms, })) diff --git a/crates/harness-locate/src/harness/mod.rs b/crates/harness-locate/src/harness/mod.rs index 960d849..f0cf3fe 100644 --- a/crates/harness-locate/src/harness/mod.rs +++ b/crates/harness-locate/src/harness/mod.rs @@ -15,6 +15,7 @@ pub mod claude_code; pub mod copilot_cli; pub mod crush; pub mod droid; +pub mod gemini_cli; pub mod goose; pub(crate) mod mcp_parse; pub mod opencode; @@ -56,6 +57,7 @@ impl Harness { HarnessKind::CopilotCli => copilot_cli::is_installed(), HarnessKind::Crush => crush::is_installed(), HarnessKind::Droid => droid::is_installed(), + HarnessKind::GeminiCli => gemini_cli::is_installed(), }; if is_installed { @@ -135,6 +137,7 @@ impl Harness { HarnessKind::CopilotCli => copilot_cli::is_installed(), HarnessKind::Crush => crush::is_installed(), HarnessKind::Droid => droid::is_installed(), + HarnessKind::GeminiCli => gemini_cli::is_installed(), } } @@ -156,6 +159,7 @@ impl Harness { HarnessKind::CopilotCli => copilot_cli::global_config_dir().ok(), HarnessKind::Crush => crush::global_config_dir().ok(), HarnessKind::Droid => droid::global_config_dir().ok(), + HarnessKind::GeminiCli => gemini_cli::global_config_dir().ok(), } .filter(|p| p.exists()); @@ -324,6 +328,7 @@ impl Harness { file_format: FileFormat::MarkdownWithFrontmatter, })) } + HarnessKind::GeminiCli => Ok(None), } } @@ -348,7 +353,10 @@ impl Harness { let path = match self.kind { HarnessKind::ClaudeCode => claude_code::commands_dir(scope)?, HarnessKind::OpenCode => opencode::commands_dir(scope)?, - HarnessKind::Goose | HarnessKind::CopilotCli | HarnessKind::Crush => return Ok(None), + HarnessKind::Goose + | HarnessKind::CopilotCli + | HarnessKind::Crush + | HarnessKind::GeminiCli => return Ok(None), HarnessKind::AmpCode => amp_code::commands_dir(scope)?, HarnessKind::Droid => droid::commands_dir(scope)?, }; @@ -414,7 +422,8 @@ impl Harness { | HarnessKind::AmpCode | HarnessKind::CopilotCli | HarnessKind::Crush - | HarnessKind::Droid => Ok(None), + | HarnessKind::Droid + | HarnessKind::GeminiCli => Ok(None), } } @@ -490,7 +499,10 @@ impl Harness { file_format: FileFormat::MarkdownWithFrontmatter, })) } - HarnessKind::Goose | HarnessKind::AmpCode | HarnessKind::Crush => Ok(None), + HarnessKind::Goose + | HarnessKind::AmpCode + | HarnessKind::Crush + | HarnessKind::GeminiCli => Ok(None), } } @@ -524,6 +536,7 @@ impl Harness { HarnessKind::CopilotCli => copilot_cli::config_dir(scope), HarnessKind::Crush => crush::config_dir(scope), HarnessKind::Droid => droid::config_dir(scope), + HarnessKind::GeminiCli => gemini_cli::config_dir(scope), } } @@ -600,6 +613,14 @@ impl Harness { FileFormat::Json, ) } + HarnessKind::GeminiCli => { + let base = gemini_cli::config_dir(scope)?; + ( + base.join("settings.json"), + "/mcpServers".into(), + FileFormat::Json, + ) + } }; Ok(Some(ConfigResource { file_exists: file.exists(), @@ -765,6 +786,7 @@ impl Harness { HarnessKind::CopilotCli => copilot_cli::rules_dir(scope), HarnessKind::Crush => crush::rules_dir(scope), HarnessKind::Droid => droid::rules_dir(scope), + HarnessKind::GeminiCli => gemini_cli::rules_dir(scope), }; match path { Some(p) => Ok(Some(DirectoryResource { @@ -859,6 +881,7 @@ impl Harness { HarnessKind::CopilotCli => copilot_cli::parse_mcp_servers(config)?, HarnessKind::Crush => crush::parse_mcp_servers(config)?, HarnessKind::Droid => droid::parse_mcp_servers(config)?, + HarnessKind::GeminiCli => gemini_cli::parse_mcp_servers(config)?, }; Ok(servers.into_iter().collect()) } @@ -898,6 +921,7 @@ impl Harness { HarnessKind::CopilotCli => copilot_cli::parse_mcp_server(value), HarnessKind::Crush => crush::parse_mcp_server(value), HarnessKind::Droid => droid::parse_mcp_server(value), + HarnessKind::GeminiCli => gemini_cli::parse_mcp_server(value), }; result.map_err(|e| match e { @@ -1199,7 +1223,7 @@ mod tests { #[test] fn harness_kind_all_contains_all_variants() { - assert_eq!(HarnessKind::ALL.len(), 7); + assert_eq!(HarnessKind::ALL.len(), 8); assert!(HarnessKind::ALL.contains(&HarnessKind::ClaudeCode)); assert!(HarnessKind::ALL.contains(&HarnessKind::OpenCode)); assert!(HarnessKind::ALL.contains(&HarnessKind::Goose)); @@ -1207,6 +1231,7 @@ mod tests { assert!(HarnessKind::ALL.contains(&HarnessKind::CopilotCli)); assert!(HarnessKind::ALL.contains(&HarnessKind::Crush)); assert!(HarnessKind::ALL.contains(&HarnessKind::Droid)); + assert!(HarnessKind::ALL.contains(&HarnessKind::GeminiCli)); } #[test] diff --git a/crates/harness-locate/src/mcp.rs b/crates/harness-locate/src/mcp.rs index fa4009f..ee742ce 100644 --- a/crates/harness-locate/src/mcp.rs +++ b/crates/harness-locate/src/mcp.rs @@ -141,6 +141,7 @@ impl McpServer { match kind { HarnessKind::ClaudeCode => self.to_claude_code_value(kind), + HarnessKind::GeminiCli => self.to_gemini_cli_value(kind), HarnessKind::CopilotCli => self.to_copilot_cli_value(kind), HarnessKind::OpenCode | HarnessKind::Crush => self.to_opencode_value(kind), HarnessKind::Goose => self.to_goose_value(kind, name), @@ -257,6 +258,66 @@ impl McpServer { } } + fn to_gemini_cli_value(&self, kind: HarnessKind) -> Result { + match self { + Self::Stdio(s) => { + let mut obj = serde_json::json!({ + "command": s.command, + "args": s.args, + }); + if !s.env.is_empty() { + let env: std::collections::HashMap = s + .env + .iter() + .map(|(k, v)| Ok((k.clone(), v.try_to_native(kind)?))) + .collect::>()?; + obj["env"] = serde_json::to_value(env).unwrap(); + } + if let Some(timeout_ms) = s.timeout_ms { + obj["timeout"] = serde_json::json!(timeout_ms); + } + if let Some(cwd) = &s.cwd { + obj["cwd"] = serde_json::json!(cwd.to_string_lossy()); + } + Ok(obj) + } + Self::Sse(s) => { + let mut obj = serde_json::json!({ + "url": s.url, + }); + if !s.headers.is_empty() { + let headers: std::collections::HashMap = s + .headers + .iter() + .map(|(k, v)| Ok((k.clone(), v.try_to_native(kind)?))) + .collect::>()?; + obj["headers"] = serde_json::to_value(headers).unwrap(); + } + if let Some(timeout_ms) = s.timeout_ms { + obj["timeout"] = serde_json::json!(timeout_ms); + } + Ok(obj) + } + Self::Http(h) => { + let mut obj = serde_json::json!({ + "httpUrl": h.url, + }); + if !h.headers.is_empty() { + let headers: std::collections::HashMap = h + .headers + .iter() + .map(|(k, v)| Ok((k.clone(), v.try_to_native(kind)?))) + .collect::>()?; + obj["headers"] = serde_json::to_value(headers).unwrap(); + } + if let Some(timeout_ms) = h.timeout_ms { + obj["timeout"] = serde_json::json!(timeout_ms); + } + Ok(obj) + } + } + } + fn to_opencode_value(&self, kind: HarnessKind) -> Result { match self { Self::Stdio(s) => { @@ -781,6 +842,16 @@ impl McpCapabilities { headers: true, cwd: false, }, + HarnessKind::GeminiCli => Self { + stdio: true, + sse: true, + http: true, + oauth: false, + timeout: true, + toggle: false, + headers: true, + cwd: true, + }, } } } @@ -1008,6 +1079,19 @@ mod tests { assert!(!caps.cwd); } + #[test] + fn mcp_capabilities_for_gemini_cli() { + let caps = McpCapabilities::for_kind(HarnessKind::GeminiCli); + assert!(caps.stdio); + assert!(caps.sse); + assert!(caps.http); + assert!(!caps.oauth); + assert!(caps.timeout); + assert!(!caps.toggle); + assert!(caps.headers); + assert!(caps.cwd); + } + #[test] fn mcp_capabilities_serialization() { let caps = McpCapabilities::for_kind(HarnessKind::OpenCode); diff --git a/crates/harness-locate/src/types.rs b/crates/harness-locate/src/types.rs index f3721fa..dbbd08e 100644 --- a/crates/harness-locate/src/types.rs +++ b/crates/harness-locate/src/types.rs @@ -31,6 +31,8 @@ pub enum HarnessKind { Crush, /// Factory Droid (Factory's AI coding assistant) Droid, + /// Gemini CLI (Google's AI coding assistant) + GeminiCli, } impl fmt::Display for HarnessKind { @@ -43,6 +45,7 @@ impl fmt::Display for HarnessKind { Self::CopilotCli => write!(f, "Copilot CLI"), Self::Crush => write!(f, "Crush"), Self::Droid => write!(f, "Droid"), + Self::GeminiCli => write!(f, "Gemini CLI"), } } } @@ -58,6 +61,7 @@ impl HarnessKind { Self::CopilotCli => "Copilot CLI", Self::Crush => "Crush", Self::Droid => "Droid", + Self::GeminiCli => "Gemini CLI", } } @@ -83,6 +87,7 @@ impl HarnessKind { Self::CopilotCli, Self::Crush, Self::Droid, + Self::GeminiCli, ]; /// Returns the known CLI binary names for this harness. @@ -109,6 +114,7 @@ impl HarnessKind { Self::CopilotCli => &["copilot"], Self::Crush => &["crush"], Self::Droid => &["droid"], + Self::GeminiCli => &["gemini"], } } @@ -178,6 +184,8 @@ impl HarnessKind { (Self::Droid, ResourceKind::Commands) => Some(&["commands"]), (Self::Droid, ResourceKind::Agents) => Some(&["droids"]), + // GeminiCli - no resource directory support + // Unsupported combinations _ => None, } @@ -540,7 +548,8 @@ impl EnvValue { HarnessKind::ClaudeCode | HarnessKind::AmpCode | HarnessKind::CopilotCli - | HarnessKind::Droid => { + | HarnessKind::Droid + | HarnessKind::GeminiCli => { format!("${{{env}}}") } HarnessKind::OpenCode | HarnessKind::Crush => format!("{{env:{env}}}"), @@ -586,7 +595,8 @@ impl EnvValue { HarnessKind::ClaudeCode | HarnessKind::AmpCode | HarnessKind::CopilotCli - | HarnessKind::Droid => Ok(format!("${{{env}}}")), + | HarnessKind::Droid + | HarnessKind::GeminiCli => Ok(format!("${{{env}}}")), HarnessKind::OpenCode | HarnessKind::Crush => Ok(format!("{{env:{env}}}")), HarnessKind::Goose => std::env::var(env) .map_err(|_| crate::Error::MissingEnvVar { name: env.clone() }), @@ -628,7 +638,8 @@ impl EnvValue { HarnessKind::ClaudeCode | HarnessKind::AmpCode | HarnessKind::CopilotCli - | HarnessKind::Droid => { + | HarnessKind::Droid + | HarnessKind::GeminiCli => { if let Some(var) = s.strip_prefix("${").and_then(|s| s.strip_suffix('}')) { Self::EnvRef { env: var.to_string(), @@ -994,10 +1005,18 @@ mod tests { #[test] fn directory_names_all_harnesses_support_skills() { for kind in HarnessKind::ALL { - assert!( - kind.directory_names(ResourceKind::Skills).is_some(), - "{kind} should support skills" - ); + // GeminiCli doesn't support skills directories + if *kind == HarnessKind::GeminiCli { + assert!( + kind.directory_names(ResourceKind::Skills).is_none(), + "{kind} should not support skills" + ); + } else { + assert!( + kind.directory_names(ResourceKind::Skills).is_some(), + "{kind} should support skills" + ); + } } } } diff --git a/crates/harness-locate/src/validation.rs b/crates/harness-locate/src/validation.rs index 96ec252..3b3d178 100644 --- a/crates/harness-locate/src/validation.rs +++ b/crates/harness-locate/src/validation.rs @@ -180,7 +180,7 @@ impl AgentCapabilities { color_format: ColorFormat::NamedOrHex, supported_modes: &["subagent", "primary"], }), - HarnessKind::Goose | HarnessKind::Crush => None, + HarnessKind::Goose | HarnessKind::Crush | HarnessKind::GeminiCli => None, } } } @@ -226,7 +226,7 @@ impl SkillCapabilities { name_must_match_directory: true, description_required: true, }), - HarnessKind::Goose => None, + HarnessKind::Goose | HarnessKind::GeminiCli => None, HarnessKind::Crush => Some(Self { name_format: NameFormat::Any, name_must_match_directory: false, @@ -980,7 +980,7 @@ mod tests { // Harness-specific validation tests #[test] - fn cwd_on_any_harness_returns_error() { + fn cwd_unsupported_on_non_gemini_harnesses() { let server = McpServer::Stdio(StdioMcpServer { command: "node".to_string(), args: vec![], @@ -992,7 +992,11 @@ mod tests { for kind in HarnessKind::ALL { let issues = validate_for_harness(&server, *kind); - assert!(issues.iter().any(|i| i.code == Some(CODE_CWD_UNSUPPORTED))); + if *kind == HarnessKind::GeminiCli { + assert!(!issues.iter().any(|i| i.code == Some(CODE_CWD_UNSUPPORTED))); + } else { + assert!(issues.iter().any(|i| i.code == Some(CODE_CWD_UNSUPPORTED))); + } } }