diff --git a/crates/bridle/src/cli/install.rs b/crates/bridle/src/cli/install.rs index c235cbf..dca5156 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::GrokBuild, ]; let mut groups: Vec = Vec::new(); diff --git a/crates/bridle/src/cli/profile.rs b/crates/bridle/src/cli/profile.rs index ae8dc58..01c1e1e 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, + "grok-build" | "grok" | "grokbuild" => HarnessKind::GrokBuild, _ => 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..b798900 100644 --- a/crates/bridle/src/config/manager/extraction/mod.rs +++ b/crates/bridle/src/config/manager/extraction/mod.rs @@ -109,6 +109,7 @@ pub fn extract_mcp_servers( "amp-code" => extract_mcp_from_ampcode_config(profile_path), "claude-code" => extract_mcp_from_claudecode_config(profile_path), "goose" => extract_mcp_from_goose_config(profile_path), + "grok-build" => extract_mcp_from_grok_build_config(profile_path), _ => extract_mcp_generic(harness, profile_path), } } @@ -142,6 +143,59 @@ fn extract_mcp_generic( .collect()) } +fn extract_mcp_from_grok_build_config(profile_path: &Path) -> Result> { + let config_path = profile_path.join("config.toml"); + if !config_path.exists() { + return Ok(Vec::new()); + } + + let content = std::fs::read_to_string(&config_path) + .map_err(|e| Error::Config(format!("Failed to read config.toml: {}", e)))?; + let config: toml::Value = toml::from_str(&content) + .map_err(|e| Error::Config(format!("Failed to parse config.toml: {}", e)))?; + + let Some(mcp_table) = config.get("mcp_servers").and_then(|v| v.as_table()) else { + return Ok(Vec::new()); + }; + + let servers = mcp_table + .iter() + .filter_map(|(name, value)| { + let table = value.as_table()?; + // Grok uses `enabled` (default true); tolerate legacy `disabled` if present. + let enabled = if let Some(e) = table.get("enabled").and_then(|v| v.as_bool()) { + e + } else { + !table + .get("disabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + }; + let server_type = table.get("type").and_then(|v| v.as_str()).map(String::from); + let command = table + .get("command") + .and_then(|v| v.as_str()) + .map(String::from); + let args = table.get("args").and_then(|v| v.as_array()).map(|arr| { + arr.iter() + .filter_map(|a| a.as_str().map(String::from)) + .collect() + }); + let url = table.get("url").and_then(|v| v.as_str()).map(String::from); + Some(McpServerInfo { + name: name.clone(), + enabled, + server_type, + command, + args, + url, + }) + }) + .collect(); + + Ok(servers) +} + fn extract_mcp_from_claudecode_config(profile_path: &Path) -> Result> { let config_path = profile_path.join(".mcp.json"); if !config_path.exists() { @@ -327,6 +381,16 @@ pub fn extract_theme(harness: &dyn HarnessConfig, profile_path: &Path) -> Option .and_then(|v| v.as_str()) .map(String::from) } + "grok-build" => { + let config_path = profile_path.join("config.toml"); + let content = std::fs::read_to_string(&config_path).ok()?; + let parsed: toml::Value = toml::from_str(&content).ok()?; + parsed + .get("ui") + .and_then(|ui| ui.get("theme")) + .and_then(|v| v.as_str()) + .map(String::from) + } _ => None, } } @@ -336,6 +400,7 @@ pub fn extract_model(harness: &dyn HarnessConfig, profile_path: &Path) -> Option "opencode" => extract_model_opencode(profile_path), "claude-code" => extract_model_claude_code(profile_path), "goose" => extract_model_goose(profile_path), + "grok-build" => extract_model_grok_build(profile_path), "amp-code" => extract_model_ampcode(profile_path), "crush" => extract_model_crush(profile_path), _ => None, @@ -381,6 +446,19 @@ fn extract_model_goose(profile_path: &Path) -> Option { .map(String::from) } +fn extract_model_grok_build(profile_path: &Path) -> Option { + let config_path = profile_path.join("config.toml"); + let content = std::fs::read_to_string(&config_path).ok()?; + let parsed: toml::Value = toml::from_str(&content).ok()?; + + parsed + .get("models") + .and_then(|m| m.get("default")) + .and_then(|v| v.as_str()) + .or_else(|| parsed.get("model").and_then(|v| v.as_str())) + .map(String::from) +} + fn extract_model_ampcode(profile_path: &Path) -> Option { let config_path = profile_path.join("settings.json"); let content = std::fs::read_to_string(&config_path).ok()?; @@ -696,6 +774,11 @@ pub fn extract_plugins( return extract_claude_code_plugins(profile_path); } + // Grok plugins are dirs under plugins/; plugin.json is optional. + if harness.id() == "grok-build" { + return extract_subdir_plugins(profile_path); + } + match harness.plugins(&Scope::Global) { Ok(Some(dir)) => ( Some(extract_resource_summary( @@ -710,6 +793,35 @@ pub fn extract_plugins( } } +/// List plugin subdirectories (no required marker file). +fn extract_subdir_plugins(profile_path: &Path) -> (Option, Option) { + let plugins_dir = profile_path.join("plugins"); + if !plugins_dir.exists() { + return (None, None); + } + + let entries = match std::fs::read_dir(&plugins_dir) { + Ok(e) => e, + Err(e) => return (None, Some(format!("plugins: {}", e))), + }; + + let mut plugins: Vec = entries + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .filter_map(|e| e.file_name().to_str().map(String::from)) + .filter(|name| !name.starts_with('.')) + .collect(); + plugins.sort(); + + ( + Some(ResourceSummary { + items: plugins, + directory_exists: true, + }), + None, + ) +} + fn extract_plugins_from_opencode_config( profile_path: &Path, ) -> (Option, Option) { diff --git a/crates/bridle/src/config/manager/files.rs b/crates/bridle/src/config/manager/files.rs index b98088d..c6ac6c3 100644 --- a/crates/bridle/src/config/manager/files.rs +++ b/crates/bridle/src/config/manager/files.rs @@ -15,7 +15,11 @@ const ALWAYS_EXCLUDED: &[&str] = &[ "node_modules", ]; +/// Runtime / session paths preserved across profile switches and never copied +/// into profiles. Includes Claude-style session data plus Grok install root +/// artifacts (`~/.grok` is both config and install home). const SESSION_DATA: &[&str] = &[ + // Claude / generic session data "transcripts", "debug", "statsig", @@ -23,14 +27,41 @@ const SESSION_DATA: &[&str] = &[ "todos", "shell-snapshots", "history.jsonl", + // Grok Build install + runtime (must not enter profiles or be wiped on switch) + "bin", + "downloads", + "bundled", + "sessions", + "marketplace-cache", + "logs", + "memtrace", + "vendor", + "upload_queue", + "relocations", + "docs", + "completions", + "auth.json", + "auth.json.lock", + "active_sessions.json", + "active_sessions.lock", + "worktrees.db", + "models_cache.json", + "agent_id", + "version.json", + ".metadata_version", + ".config-init.lock", + "managed_config.lock", + "slash-mru.json", + "tip_cursor.json", + "README.md", ]; fn is_excluded(name: &str) -> bool { - ALWAYS_EXCLUDED.contains(&name) || SESSION_DATA.contains(&name) + ALWAYS_EXCLUDED.contains(&name) || SESSION_DATA.contains(&name) || name.ends_with(".lock") } fn is_session_data(name: &str) -> bool { - SESSION_DATA.contains(&name) + SESSION_DATA.contains(&name) || name.ends_with(".lock") } const MAX_EXTRA_BACKUPS: usize = 5; diff --git a/crates/bridle/src/config/manager/mod.rs b/crates/bridle/src/config/manager/mod.rs index 1311127..d991301 100644 --- a/crates/bridle/src/config/manager/mod.rs +++ b/crates/bridle/src/config/manager/mod.rs @@ -200,6 +200,15 @@ impl ProfileManager { } std::fs::remove_dir_all(&path)?; + + // Clear stale active pointer so status/list don't reference a deleted profile. + if let Ok(mut config) = BridleConfig::load() + && config.active_profile_for(harness.id()) == Some(name.as_str()) + { + config.clear_active_profile(harness.id()); + let _ = config.save(); + } + Ok(()) } diff --git a/crates/bridle/src/display/mod.rs b/crates/bridle/src/display/mod.rs index 6c0eb48..1de8516 100644 --- a/crates/bridle/src/display/mod.rs +++ b/crates/bridle/src/display/mod.rs @@ -478,18 +478,17 @@ fn render_node_lines(lines: &mut Vec>, node: &ProfileNode, tree: & )); } } - SectionKind::RulesFile { exists } => { - if *exists { - lines.push(Line::styled( - format!( - " {} Rules: {}", - tree.branch, - node.text.as_deref().unwrap_or("") - ), - Style::default().fg(Color::Gray), - )); - } + SectionKind::RulesFile { exists } if *exists => { + lines.push(Line::styled( + format!( + " {} Rules: {}", + tree.branch, + node.text.as_deref().unwrap_or("") + ), + Style::default().fg(Color::Gray), + )); } + SectionKind::RulesFile { .. } => {} SectionKind::Error => { if node.label == "Errors" { for child in &node.children { diff --git a/crates/bridle/src/harness/install_instructions.rs b/crates/bridle/src/harness/install_instructions.rs index b109163..4d74457 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::GrokBuild => grok_build_instructions(), _ => vec!["Unknown harness".to_string()], } } @@ -41,6 +42,15 @@ fn crush_instructions() -> Vec { ] } +fn grok_build_instructions() -> Vec { + vec![ + "Install Grok Build:".to_string(), + "- macOS/Linux: curl -fsSL https://x.ai/cli/install.sh | bash".to_string(), + "- Then run: grok".to_string(), + "- For headless/non-browser use: export XAI_API_KEY=\"xai-...\"".to_string(), + ] +} + fn droid_instructions() -> Vec { vec![ "- Visit https://factory.ai for installation instructions".to_string(), @@ -143,6 +153,7 @@ pub fn get_empty_state_message( HarnessKind::CopilotCli => "Copilot CLI", HarnessKind::Crush => "Crush", HarnessKind::Droid => "Factory Droid", + HarnessKind::GrokBuild => "Grok Build", _ => "Unknown", }; @@ -192,6 +203,7 @@ pub fn get_empty_state_message( HarnessKind::CopilotCli => "copilot", HarnessKind::Crush => "crush", HarnessKind::Droid => "droid", + HarnessKind::GrokBuild => "grok", _ => "", }; diff --git a/crates/bridle/src/harness/mod.rs b/crates/bridle/src/harness/mod.rs index 456d1ca..2c3197a 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::GrokBuild => "grok-build", _ => "unknown", } } @@ -88,9 +89,13 @@ impl HarnessConfig for harness_locate::Harness { fn parse_mcp_servers(&self, content: &str, filename: &str) -> Result> { let is_yaml = filename.ends_with(".yaml") || filename.ends_with(".yml"); + let is_toml = filename.ends_with(".toml"); let mut parsed: serde_json::Value = if is_yaml { let yaml: serde_yaml::Value = serde_yaml::from_str(content)?; serde_json::to_value(yaml)? + } else if is_toml { + let toml_val: toml::Value = toml::from_str(content)?; + serde_json::to_value(toml_val)? } else { serde_json::from_str(content)? }; diff --git a/crates/bridle/src/install/mcp_config.rs b/crates/bridle/src/install/mcp_config.rs index 7166ac7..60211ef 100644 --- a/crates/bridle/src/install/mcp_config.rs +++ b/crates/bridle/src/install/mcp_config.rs @@ -19,6 +19,9 @@ pub enum McpConfigError { #[error("Failed to parse YAML: {0}")] YamlParse(#[from] serde_yaml::Error), + #[error("Failed to parse TOML: {0}")] + TomlParse(#[from] toml::de::Error), + #[error("Failed to write config: {0}")] Write(String), } @@ -32,6 +35,7 @@ fn get_mcp_key(kind: HarnessKind) -> &'static str { HarnessKind::Goose => "extensions", HarnessKind::AmpCode => "amp.mcpServers", HarnessKind::Droid => "mcpServers", + HarnessKind::GrokBuild => "mcp_servers", _ => "mcpServers", } } @@ -58,6 +62,10 @@ pub fn read_mcp_config( let stripped = strip_jsonc_comments(&content); serde_json::from_str(&stripped)? } + HarnessKind::GrokBuild => { + let toml: toml::Value = toml::from_str(&content)?; + serde_json::to_value(toml)? + } _ => serde_json::from_str(&content)?, }; @@ -93,6 +101,9 @@ pub fn write_mcp_config( if kind == HarnessKind::Goose { return write_goose_yaml_preserving_comments(config_path, servers); } + if kind == HarnessKind::GrokBuild { + return write_grok_toml_config(config_path, servers); + } let key = get_mcp_key(kind); @@ -136,6 +147,47 @@ pub fn write_mcp_config( Ok(()) } +fn write_grok_toml_config( + config_path: &Path, + servers: &HashMap, +) -> Result<(), McpConfigError> { + let mut existing: toml::Table = if config_path.exists() { + let content = fs::read_to_string(config_path)?; + if content.trim().is_empty() { + toml::Table::new() + } else { + toml::from_str::(&content)? + } + } else { + toml::Table::new() + }; + + let mcp = existing + .entry("mcp_servers".to_string()) + .or_insert_with(|| toml::Value::Table(toml::Table::new())); + let mcp_table = mcp + .as_table_mut() + .ok_or_else(|| McpConfigError::Write("mcp_servers section is not a table".to_string()))?; + + for (name, value) in servers { + let toml_value = json_to_toml_value(value)?; + mcp_table.insert(name.clone(), toml_value); + } + + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + let output = toml::to_string_pretty(&existing) + .map_err(|e| McpConfigError::Write(format!("Failed to serialize TOML: {}", e)))?; + fs::write(config_path, output)?; + Ok(()) +} + +fn json_to_toml_value(value: &serde_json::Value) -> Result { + toml::Value::try_from(value.clone()) + .map_err(|e| McpConfigError::Write(format!("Failed to convert MCP server to TOML: {}", e))) +} + fn write_goose_yaml_preserving_comments( config_path: &Path, servers: &HashMap, @@ -435,6 +487,78 @@ extensions: assert!(content.contains("claude-4")); } + #[test] + fn read_write_grok_mcp_servers_toml() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("config.toml"); + fs::write( + &path, + r#" +[cli] +installer = "manual" + +[ui] +theme = "dark" + +[mcp] +max_output_bytes = 40000 +"#, + ) + .unwrap(); + + let mut servers = HashMap::new(); + servers.insert( + "filesystem".to_string(), + serde_json::json!({ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "enabled": true + }), + ); + + write_mcp_config(HarnessKind::GrokBuild, &path, &servers).unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + assert!( + content.contains("mcp_servers"), + "must write mcp_servers key: {content}" + ); + assert!( + !content.contains("[mcp.filesystem]"), + "must not write servers under [mcp.*]: {content}" + ); + assert!(content.contains("filesystem")); + assert!(content.contains("installer")); + assert!(content.contains("theme")); + // [mcp] max_output_bytes section should still be present + assert!(content.contains("max_output_bytes") || content.contains("40000")); + + let result = read_mcp_config(HarnessKind::GrokBuild, &path).unwrap(); + assert_eq!(result.len(), 1); + assert!(result.contains_key("filesystem")); + } + + #[test] + fn read_grok_mcp_servers_key() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("config.toml"); + fs::write( + &path, + r#" +[mcp_servers.github] +command = "npx" +args = ["-y", "@modelcontextprotocol/server-github"] +enabled = false +"#, + ) + .unwrap(); + + let result = read_mcp_config(HarnessKind::GrokBuild, &path).unwrap(); + assert_eq!(result.len(), 1); + assert!(result.contains_key("github")); + assert_eq!(result["github"]["enabled"], false); + } + #[test] fn mcp_exists_returns_true_for_existing() { let tmp = TempDir::new().unwrap(); diff --git a/crates/bridle/src/install/mcp_installer.rs b/crates/bridle/src/install/mcp_installer.rs index 8e1e1d2..e37fdb5 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::GrokBuild => profile_dir.join("config.toml"), _ => profile_dir.join("config.json"), } } diff --git a/crates/bridle/src/install/types.rs b/crates/bridle/src/install/types.rs index 1ec2784..374e2f6 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), + "grok-build" | "grok" | "grokbuild" => Some(HarnessKind::GrokBuild), _ => None, } } diff --git a/crates/bridle/src/tui/mod.rs b/crates/bridle/src/tui/mod.rs index 4f580e5..8185439 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::GrokBuild => "grok-build", _ => "unknown", } } @@ -68,6 +69,7 @@ fn harness_name(kind: &HarnessKind) -> &'static str { HarnessKind::CopilotCli => "Copilot CLI", HarnessKind::Crush => "Crush", HarnessKind::Droid => "Factory Droid", + HarnessKind::GrokBuild => "Grok Build", _ => "Unknown", } } @@ -594,15 +596,11 @@ impl App { #[cfg(feature = "tui-cards")] ViewMode::Cards => self.next_profile(), }, - KeyCode::Left | KeyCode::Char('h') => { - if self.view_mode == ViewMode::Dashboard { - self.prev_harness(); - } + KeyCode::Left | KeyCode::Char('h') if self.view_mode == ViewMode::Dashboard => { + self.prev_harness(); } - KeyCode::Right | KeyCode::Char('l') => { - if self.view_mode == ViewMode::Dashboard { - self.next_harness(); - } + KeyCode::Right | KeyCode::Char('l') if self.view_mode == ViewMode::Dashboard => { + self.next_harness(); } KeyCode::Enter => match self.view_mode { ViewMode::Dashboard => { @@ -622,10 +620,8 @@ impl App { self.switch_to_selected(); } }, - KeyCode::Char(' ') => { - if self.active_pane == Pane::Profiles { - self.toggle_expansion(); - } + KeyCode::Char(' ') if self.active_pane == Pane::Profiles => { + self.toggle_expansion(); } KeyCode::Char('r') => { self.sync_active_profiles(); @@ -659,12 +655,11 @@ impl App { self.input_mode = InputMode::ConfirmingDelete; } } - KeyCode::Char('e') => { + KeyCode::Char('e') if matches!(self.view_mode, ViewMode::Dashboard) - || self.active_pane == Pane::Profiles - { - self.edit_selected(); - } + || self.active_pane == Pane::Profiles => + { + self.edit_selected(); } KeyCode::Char('f') => { if let Some(harness_kind) = self.selected_harness() { diff --git a/crates/harness-locate/src/harness/dot_dir.rs b/crates/harness-locate/src/harness/dot_dir.rs new file mode 100644 index 0000000..feb6962 --- /dev/null +++ b/crates/harness-locate/src/harness/dot_dir.rs @@ -0,0 +1,99 @@ +//! Shared helpers for harnesses with a single dot-directory layout. + +use std::path::{Path, PathBuf}; + +use crate::error::{Error, Result}; +use crate::mcp::McpServer; +use crate::platform; +use crate::types::Scope; + +use super::mcp_parse::{self, ParseConfig}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct DotDirHarness { + pub(crate) dot_dir: &'static str, + pub(crate) agents_dir: &'static str, + pub(crate) mcp_key: &'static str, + pub(crate) mcp_config: &'static ParseConfig, +} + +impl DotDirHarness { + pub(crate) fn global_config_dir(self) -> Result { + Ok(platform::home_dir()?.join(self.dot_dir)) + } + + pub(crate) fn project_config_dir(self, project_root: &Path) -> PathBuf { + project_root.join(self.dot_dir) + } + + pub(crate) fn config_dir(self, scope: &Scope) -> Result { + match scope { + Scope::Global => self.global_config_dir(), + Scope::Project(root) => Ok(self.project_config_dir(root)), + Scope::Custom(path) => Ok(path.clone()), + } + } + + pub(crate) fn child_dir(self, scope: &Scope, child: &str) -> Result { + Ok(self.config_dir(scope)?.join(child)) + } + + pub(crate) fn optional_child_dir(self, scope: &Scope, child: &str) -> Option { + match scope { + Scope::Global => self.global_config_dir().ok().map(|p| p.join(child)), + Scope::Project(root) => Some(self.project_config_dir(root).join(child)), + Scope::Custom(path) => Some(path.join(child)), + } + } + + pub(crate) fn rules_dir(self, scope: &Scope) -> Option { + match scope { + Scope::Global => self.global_config_dir().ok(), + Scope::Project(root) => Some(root.clone()), + Scope::Custom(path) => Some(path.clone()), + } + } + + pub(crate) fn agents_dir(self, scope: &Scope) -> Option { + self.optional_child_dir(scope, self.agents_dir) + } + + pub(crate) fn is_installed(self) -> bool { + self.global_config_dir() + .map(|p| p.exists()) + .unwrap_or(false) + } + + pub(crate) fn parse_mcp_server(self, value: &serde_json::Value) -> Result { + let config = self.mcp_config; + let obj = value + .as_object() + .ok_or_else(|| Error::UnsupportedMcpConfig { + harness: config.harness_name.to_string(), + reason: "Server configuration must be an object".to_string(), + })?; + + if let Some(server_type) = obj.get("type").and_then(|v| v.as_str()) { + match server_type { + "http" => mcp_parse::parse_http_server(obj, config), + "stdio" => mcp_parse::parse_stdio_server(obj, config), + _ => Err(Error::UnsupportedMcpConfig { + harness: config.harness_name.to_string(), + reason: format!("Unknown server type: {server_type}"), + }), + } + } else if obj.contains_key("url") { + mcp_parse::parse_sse_server(obj, config) + } else { + mcp_parse::parse_stdio_server(obj, config) + } + } + + pub(crate) fn parse_mcp_servers( + self, + config: &serde_json::Value, + parse_server: fn(&serde_json::Value) -> Result, + ) -> Result> { + mcp_parse::parse_servers_from_key(config, self.mcp_key, self.mcp_config, parse_server) + } +} diff --git a/crates/harness-locate/src/harness/droid.rs b/crates/harness-locate/src/harness/droid.rs index 1ba5ca1..34b81c1 100644 --- a/crates/harness-locate/src/harness/droid.rs +++ b/crates/harness-locate/src/harness/droid.rs @@ -1,544 +1,172 @@ //! Factory Droid harness implementation. //! -//! Factory Droid stores its configuration in: -//! - **Global**: `~/.factory/` -//! - **Project**: `.factory/` in project root +//! Factory Droid stores its configuration in `.factory/` directories. use std::path::PathBuf; -use crate::error::{Error, Result}; +use crate::error::Result; use crate::mcp::McpServer; -use crate::platform; use crate::types::Scope; -use super::mcp_parse::{self, ParseConfig}; +use super::dot_dir::DotDirHarness; +use super::mcp_parse::ParseConfig; + +const HARNESS: DotDirHarness = DotDirHarness { + dot_dir: ".factory", + agents_dir: "droids", + mcp_key: "mcpServers", + mcp_config: &ParseConfig::DROID, +}; /// Returns the global Droid configuration directory. -/// -/// Returns `~/.factory/`. -/// -/// # Errors -/// -/// Returns an error if the home directory cannot be determined. pub fn global_config_dir() -> Result { - Ok(platform::home_dir()?.join(".factory")) + HARNESS.global_config_dir() } /// Returns the project-local Droid 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(".factory") + HARNESS.project_config_dir(project_root) } /// 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()), - } + HARNESS.config_dir(scope) } /// Returns the commands directory for the given scope. -/// -/// - **Global**: `~/.factory/commands/` -/// - **Project**: `.factory/commands/` pub fn commands_dir(scope: &Scope) -> Result { - match scope { - Scope::Global => Ok(global_config_dir()?.join("commands")), - Scope::Project(root) => Ok(project_config_dir(root).join("commands")), - Scope::Custom(path) => Ok(path.join("commands")), - } + HARNESS.child_dir(scope, "commands") } /// Returns the MCP configuration directory for the given scope. -/// -/// Droid stores MCP configuration in `mcp.json` at the base config directory. pub fn mcp_dir(scope: &Scope) -> Result { config_dir(scope) } /// Returns the skills directory for the given scope. -/// -/// Droid stores skills in nested directories with `SKILL.md` files: -/// - **Global**: `~/.factory/skills/` -/// - **Project**: `.factory/skills/` #[must_use] pub fn skills_dir(scope: &Scope) -> Option { - match scope { - Scope::Global => global_config_dir().ok().map(|p| p.join("skills")), - Scope::Project(root) => Some(project_config_dir(root).join("skills")), - Scope::Custom(path) => Some(path.join("skills")), - } + HARNESS.optional_child_dir(scope, "skills") } /// Returns the rules directory for the given scope. -/// -/// Droid stores rules files at: -/// - **Global**: `~/.factory/` -/// - **Project**: Project root directory (not `.factory/`) #[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()), - } + HARNESS.rules_dir(scope) } -/// Returns the agents (droids) directory for the given scope. -/// -/// Droid stores agents as markdown files with YAML frontmatter: -/// - **Global**: `~/.factory/droids/` -/// - **Project**: `.factory/droids/` +/// Returns the agents directory for the given scope. #[must_use] pub fn agents_dir(scope: &Scope) -> Option { - match scope { - Scope::Global => global_config_dir().ok().map(|p| p.join("droids")), - Scope::Project(root) => Some(project_config_dir(root).join("droids")), - Scope::Custom(path) => Some(path.join("droids")), - } + HARNESS.agents_dir(scope) } /// Checks if Droid 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) + HARNESS.is_installed() } -/// Parses a single MCP server from Droid's native JSON format. -/// -/// # Arguments -/// * `value` - The JSON value representing the server config -/// -/// # Errors -/// Returns an error if the JSON is malformed or missing required fields. +/// Parses a single MCP server from Droid's native format. pub(crate) fn parse_mcp_server(value: &serde_json::Value) -> Result { - let config = ParseConfig::DROID; - let obj = value - .as_object() - .ok_or_else(|| Error::UnsupportedMcpConfig { - harness: config.harness_name.to_string(), - reason: "Server configuration must be an object".to_string(), - })?; - - // Check if this is an SSE or HTTP server (has "type" field) - if let Some(server_type) = obj.get("type").and_then(|v| v.as_str()) { - match server_type { - "http" => mcp_parse::parse_http_server(obj, &config), - "stdio" => mcp_parse::parse_stdio_server(obj, &config), - _ => Err(Error::UnsupportedMcpConfig { - harness: config.harness_name.to_string(), - reason: format!("Unknown server type: {}", server_type), - }), - } - } else if obj.contains_key("url") { - // SSE server (remote without explicit type, or with url field) - mcp_parse::parse_sse_server(obj, &config) - } else { - mcp_parse::parse_stdio_server(obj, &config) - } + HARNESS.parse_mcp_server(value) } /// Parses all MCP servers from a Droid config JSON. -/// -/// # Arguments -/// * `config` - The full config JSON (expects mcpServers key) -/// -/// # Errors -/// Returns an error if the JSON is malformed. pub(crate) fn parse_mcp_servers(config: &serde_json::Value) -> Result> { - mcp_parse::parse_servers_from_key(config, "mcpServers", &ParseConfig::DROID, parse_mcp_server) + HARNESS.parse_mcp_servers(config, parse_mcp_server) } #[cfg(test)] mod tests { use super::*; + use crate::platform; use crate::types::EnvValue; use serde_json::json; #[test] - fn global_config_dir_is_absolute() { - 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(".factory")); - } - - #[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/.factory")); - } - - #[test] - fn commands_dir_global() { - if platform::home_dir().is_err() { - return; - } - - let result = commands_dir(&Scope::Global); - assert!(result.is_ok()); - let path = result.unwrap(); - assert!(path.ends_with("commands")); - } - - #[test] - fn commands_dir_project() { + fn directories_use_expected_names() { let root = PathBuf::from("/some/project"); - let result = commands_dir(&Scope::Project(root)); - assert!(result.is_ok()); - let path = result.unwrap(); - assert_eq!(path, PathBuf::from("/some/project/.factory/commands")); + assert_eq!( + project_config_dir(&root), + PathBuf::from("/some/project/.factory") + ); + assert_eq!( + commands_dir(&Scope::Project(root.clone())).unwrap(), + PathBuf::from("/some/project/.factory/commands") + ); + assert_eq!( + skills_dir(&Scope::Project(root.clone())).unwrap(), + PathBuf::from("/some/project/.factory/skills") + ); + assert_eq!( + agents_dir(&Scope::Project(root.clone())).unwrap(), + PathBuf::from("/some/project/.factory/droids") + ); + assert_eq!(rules_dir(&Scope::Project(root.clone())).unwrap(), root); } #[test] - fn skills_dir_global() { - if platform::home_dir().is_err() { - return; - } - - let result = skills_dir(&Scope::Global); - assert!(result.is_some()); - let path = result.unwrap(); - assert!(path.ends_with("skills")); - } - - #[test] - fn skills_dir_project() { - let root = PathBuf::from("/some/project"); - let result = skills_dir(&Scope::Project(root)); - assert!(result.is_some()); - let path = result.unwrap(); - assert_eq!(path, PathBuf::from("/some/project/.factory/skills")); - } - - #[test] - fn rules_dir_global_returns_config_dir() { + fn global_config_dir_is_absolute() { if platform::home_dir().is_err() { return; } - - let result = rules_dir(&Scope::Global); - assert!(result.is_some()); - let path = result.unwrap(); + let path = global_config_dir().unwrap(); + assert!(path.is_absolute()); assert!(path.ends_with(".factory")); } #[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 agents_dir_returns_droids_path() { - if platform::home_dir().is_err() { - return; - } - - let result = agents_dir(&Scope::Global); - assert!(result.is_some()); - let path = result.unwrap(); - assert!(path.ends_with("droids")); - } - - #[test] - fn agents_dir_project() { - let root = PathBuf::from("/some/project"); - let result = agents_dir(&Scope::Project(root)); - assert!(result.is_some()); - let path = result.unwrap(); - assert_eq!(path, PathBuf::from("/some/project/.factory/droids")); - } - - #[test] - fn parse_stdio_server_basic() { - let json = json!({ - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem"] - }); - - let result = parse_mcp_server(&json); - assert!(result.is_ok()); - - if let McpServer::Stdio(server) = result.unwrap() { - assert_eq!(server.command, "npx"); - assert_eq!(server.args.len(), 2); - assert_eq!(server.args[0], "-y"); - assert_eq!(server.args[1], "@modelcontextprotocol/server-filesystem"); - assert!(server.env.is_empty()); - assert!(server.enabled); - 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" - } - }); - - let result = parse_mcp_server(&json); - assert!(result.is_ok()); - - if let McpServer::Stdio(server) = result.unwrap() { - assert_eq!(server.command, "node"); - 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"))); - } else { - panic!("Expected Stdio variant"); - } + fn parses_stdio_http_and_sse_servers() { + assert!(matches!( + parse_mcp_server(&json!({"command":"npx","args":["-y","pkg"]})).unwrap(), + McpServer::Stdio(_) + )); + assert!(matches!( + parse_mcp_server(&json!({"type":"http","url":"https://api.example.com/mcp"})).unwrap(), + McpServer::Http(_) + )); + assert!(matches!( + parse_mcp_server(&json!({"url":"https://example.com/sse"})).unwrap(), + McpServer::Sse(_) + )); } #[test] - fn parse_stdio_server_with_disabled() { - let json = json!({ + fn parses_stdio_options() { + let server = parse_mcp_server(&json!({ "command": "node", "args": ["server.js"], + "env": { "API_KEY": "${MY_API_KEY}" }, + "timeout": 30000, "disabled": true - }); - - let result = parse_mcp_server(&json); - assert!(result.is_ok()); - - if let McpServer::Stdio(server) = result.unwrap() { - assert!(!server.enabled); - } else { + })) + .unwrap(); + let McpServer::Stdio(server) = server else { panic!("Expected Stdio variant"); - } + }; + assert_eq!( + server.env.get("API_KEY"), + Some(&EnvValue::env("MY_API_KEY")) + ); + assert_eq!(server.timeout_ms, Some(30000)); + assert!(!server.enabled); } #[test] - fn parse_stdio_server_with_timeout() { - let json = json!({ - "command": "node", - "args": ["server.js"], - "timeout": 30000 - }); - - let result = parse_mcp_server(&json); - assert!(result.is_ok()); - - if let McpServer::Stdio(server) = result.unwrap() { - assert_eq!(server.timeout_ms, Some(30000)); - } else { - panic!("Expected Stdio variant"); - } + fn parses_server_map() { + let config = json!({ "mcpServers": { + "filesystem": { "command": "npx" }, + "remote-server": { "url": "https://example.com/sse" } + } }); + let servers = parse_mcp_servers(&config).unwrap(); + assert_eq!(servers.len(), 2); } #[test] - fn parse_http_server_basic() { - let json = json!({ - "type": "http", - "url": "https://api.example.com/mcp" - }); - - let result = parse_mcp_server(&json); - assert!(result.is_ok()); - - if let McpServer::Http(server) = result.unwrap() { - assert_eq!(server.url, "https://api.example.com/mcp"); - assert!(server.headers.is_empty()); - assert!(server.oauth.is_none()); - assert!(server.enabled); - assert_eq!(server.timeout_ms, None); - } else { - panic!("Expected Http variant"); - } - } - - #[test] - fn parse_http_server_with_headers() { - let json = json!({ - "type": "http", - "url": "https://api.example.com/mcp", - "headers": { - "X-API-Key": "${API_KEY}" - } - }); - - let result = parse_mcp_server(&json); - assert!(result.is_ok()); - - if let McpServer::Http(server) = result.unwrap() { - assert_eq!(server.url, "https://api.example.com/mcp"); - assert_eq!(server.headers.len(), 1); - assert_eq!( - server.headers.get("X-API-Key"), - Some(&EnvValue::env("API_KEY")) - ); - } else { - panic!("Expected Http variant"); - } - } - - #[test] - fn parse_sse_server_with_url_only() { - let json = json!({ - "url": "https://example.com/sse" - }); - - let result = parse_mcp_server(&json); - assert!(result.is_ok()); - - if let McpServer::Sse(server) = result.unwrap() { - assert_eq!(server.url, "https://example.com/sse"); - assert!(server.headers.is_empty()); - assert!(server.enabled); - } else { - panic!("Expected Sse variant"); - } - } - - #[test] - fn parse_sse_server_with_headers() { - let json = json!({ - "url": "https://example.com/sse", - "headers": { - "Authorization": "${TOKEN}" - } - }); - - let result = parse_mcp_server(&json); - assert!(result.is_ok()); - - if let McpServer::Sse(server) = result.unwrap() { - assert_eq!(server.url, "https://example.com/sse"); - assert_eq!(server.headers.len(), 1); - assert_eq!( - server.headers.get("Authorization"), - Some(&EnvValue::env("TOKEN")) - ); - } else { - panic!("Expected Sse variant"); - } - } - - #[test] - fn parse_mcp_server_missing_command_fails() { - let json = json!({ - "args": ["server.js"] - }); - - let result = parse_mcp_server(&json); - assert!(result.is_err()); - } - - #[test] - fn parse_mcp_server_missing_url_for_http_fails() { - let json = json!({ - "type": "http" - }); - - let result = parse_mcp_server(&json); - assert!(result.is_err()); - } - - #[test] - fn parse_mcp_server_unknown_type_fails() { - let json = json!({ - "type": "unknown", - "url": "https://example.com" - }); - - let result = parse_mcp_server(&json); - assert!(result.is_err()); - } - - #[test] - fn parse_mcp_servers_full_config() { - let config = json!({ - "mcpServers": { - "filesystem": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem"], - "env": { - "ROOT_DIR": "${HOME}" - } - }, - "remote-server": { - "url": "https://example.com/sse", - "headers": { - "Authorization": "${TOKEN}" - } - }, - "http-server": { - "type": "http", - "url": "https://api.example.com/mcp" - } - } - }); - - let result = parse_mcp_servers(&config); - assert!(result.is_ok()); - - let servers = result.unwrap(); - assert_eq!(servers.len(), 3); - - let filesystem = servers - .iter() - .find(|(name, _)| name == "filesystem") - .unwrap(); - assert!(matches!(filesystem.1, McpServer::Stdio(_))); - - let remote_server = servers - .iter() - .find(|(name, _)| name == "remote-server") - .unwrap(); - assert!(matches!(remote_server.1, McpServer::Sse(_))); - - let http_server = servers - .iter() - .find(|(name, _)| name == "http-server") - .unwrap(); - assert!(matches!(http_server.1, McpServer::Http(_))); - } - - #[test] - fn parse_mcp_servers_empty_config() { - let config = json!({ - "mcpServers": {} - }); - - let result = parse_mcp_servers(&config); - assert!(result.is_ok()); - assert!(result.unwrap().is_empty()); - } - - #[test] - fn parse_mcp_servers_missing_mcp_servers_key_fails() { - let config = json!({ - "other": "data" - }); - - let result = parse_mcp_servers(&config); - assert!(result.is_err()); + fn invalid_mcp_config_fails() { + assert!(parse_mcp_server(&json!({"args":["server.js"]})).is_err()); + assert!(parse_mcp_server(&json!({"type":"unknown","url":"https://example.com"})).is_err()); + assert!(parse_mcp_servers(&json!({"other":"data"})).is_err()); } } diff --git a/crates/harness-locate/src/harness/grok_build.rs b/crates/harness-locate/src/harness/grok_build.rs new file mode 100644 index 0000000..5784b4c --- /dev/null +++ b/crates/harness-locate/src/harness/grok_build.rs @@ -0,0 +1,187 @@ +//! Grok Build harness implementation. +//! +//! Grok Build stores its configuration in `.grok/` directories. + +use std::path::PathBuf; + +use crate::error::Result; +use crate::mcp::McpServer; +use crate::types::Scope; + +use super::dot_dir::DotDirHarness; +use super::mcp_parse::ParseConfig; + +const HARNESS: DotDirHarness = DotDirHarness { + dot_dir: ".grok", + agents_dir: "agents", + // Grok stores servers under [mcp_servers.]; [mcp] is only for max_output_bytes. + mcp_key: "mcp_servers", + mcp_config: &ParseConfig::GROK_BUILD, +}; + +/// Returns the global Grok Build configuration directory. +pub fn global_config_dir() -> Result { + HARNESS.global_config_dir() +} + +/// Returns the project-local Grok Build configuration directory. +#[must_use] +pub fn project_config_dir(project_root: &std::path::Path) -> PathBuf { + HARNESS.project_config_dir(project_root) +} + +/// Returns the config directory for the given scope. +pub fn config_dir(scope: &Scope) -> Result { + HARNESS.config_dir(scope) +} + +/// Returns the commands directory for the given scope. +pub fn commands_dir(scope: &Scope) -> Result { + HARNESS.child_dir(scope, "commands") +} + +/// Returns the MCP configuration directory for the given scope. +pub fn mcp_dir(scope: &Scope) -> Result { + config_dir(scope) +} + +/// Returns the skills directory for the given scope. +#[must_use] +pub fn skills_dir(scope: &Scope) -> Option { + HARNESS.optional_child_dir(scope, "skills") +} + +/// Returns the rules directory for the given scope. +#[must_use] +pub fn rules_dir(scope: &Scope) -> Option { + HARNESS.rules_dir(scope) +} + +/// Returns the agents directory for the given scope. +#[must_use] +pub fn agents_dir(scope: &Scope) -> Option { + HARNESS.agents_dir(scope) +} + +/// Checks if Grok Build is installed on this system. +pub fn is_installed() -> bool { + HARNESS.is_installed() +} + +/// Parses a single MCP server from Grok Build's native format. +pub(crate) fn parse_mcp_server(value: &serde_json::Value) -> Result { + HARNESS.parse_mcp_server(value) +} + +/// Parses all MCP servers from a Grok Build config JSON. +pub(crate) fn parse_mcp_servers(config: &serde_json::Value) -> Result> { + HARNESS.parse_mcp_servers(config, parse_mcp_server) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform; + use crate::types::EnvValue; + use serde_json::json; + + #[test] + fn directories_use_expected_names() { + let root = PathBuf::from("/some/project"); + assert_eq!( + project_config_dir(&root), + PathBuf::from("/some/project/.grok") + ); + assert_eq!( + commands_dir(&Scope::Project(root.clone())).unwrap(), + PathBuf::from("/some/project/.grok/commands") + ); + assert_eq!( + skills_dir(&Scope::Project(root.clone())).unwrap(), + PathBuf::from("/some/project/.grok/skills") + ); + assert_eq!( + agents_dir(&Scope::Project(root.clone())).unwrap(), + PathBuf::from("/some/project/.grok/agents") + ); + assert_eq!(rules_dir(&Scope::Project(root.clone())).unwrap(), root); + } + + #[test] + fn global_config_dir_is_absolute() { + if platform::home_dir().is_err() { + return; + } + let path = global_config_dir().unwrap(); + assert!(path.is_absolute()); + assert!(path.ends_with(".grok")); + } + + #[test] + fn parses_stdio_http_and_sse_servers() { + assert!(matches!( + parse_mcp_server(&json!({"command":"npx","args":["-y","pkg"]})).unwrap(), + McpServer::Stdio(_) + )); + assert!(matches!( + parse_mcp_server(&json!({"type":"http","url":"https://api.example.com/mcp"})).unwrap(), + McpServer::Http(_) + )); + assert!(matches!( + parse_mcp_server(&json!({"url":"https://example.com/sse"})).unwrap(), + McpServer::Sse(_) + )); + } + + #[test] + fn parses_stdio_options() { + let server = parse_mcp_server(&json!({ + "command": "node", + "args": ["server.js"], + "env": { "API_KEY": "${MY_API_KEY}" }, + "startup_timeout_sec": 30, + "enabled": false + })) + .unwrap(); + let McpServer::Stdio(server) = server else { + panic!("Expected Stdio variant"); + }; + assert_eq!( + server.env.get("API_KEY"), + Some(&EnvValue::env("MY_API_KEY")) + ); + assert_eq!(server.timeout_ms, Some(30_000)); + assert!(!server.enabled); + } + + #[test] + fn parses_server_map() { + let config = json!({ "mcp_servers": { + "filesystem": { "command": "npx" }, + "remote-server": { "url": "https://example.com/sse" } + } }); + let servers = parse_mcp_servers(&config).unwrap(); + assert_eq!(servers.len(), 2); + } + + #[test] + fn parses_enabled_false() { + let server = parse_mcp_server(&json!({ + "command": "npx", + "args": ["-y", "pkg"], + "enabled": false + })) + .unwrap(); + let McpServer::Stdio(server) = server else { + panic!("Expected Stdio variant"); + }; + assert!(!server.enabled); + } + + #[test] + fn invalid_mcp_config_fails() { + assert!(parse_mcp_server(&json!({"args":["server.js"]})).is_err()); + assert!(parse_mcp_server(&json!({"type":"unknown","url":"https://example.com"})).is_err()); + assert!(parse_mcp_servers(&json!({"other":"data"})).is_err()); + } +} diff --git a/crates/harness-locate/src/harness/mcp_parse.rs b/crates/harness-locate/src/harness/mcp_parse.rs index cf7126a..04e1b66 100644 --- a/crates/harness-locate/src/harness/mcp_parse.rs +++ b/crates/harness-locate/src/harness/mcp_parse.rs @@ -91,7 +91,22 @@ impl ParseConfig { timeout_in_seconds: false, }; - /// Droid style parsing config. + /// Grok Build style parsing config (`[mcp_servers.*]` in config.toml). + /// + /// Uses `enabled` (not `disabled`) and `startup_timeout_sec` in seconds. + pub const GROK_BUILD: Self = Self { + harness_name: "Grok Build", + harness_kind: HarnessKind::GrokBuild, + args_field: "args", + env_field: "env", + command_field: "command", + url_field: "url", + plain_env_values: false, + disabled_field: None, // uses `enabled` field + timeout_field: "startup_timeout_sec", + timeout_in_seconds: true, + }; + pub const DROID: Self = Self { harness_name: "Droid", harness_kind: HarnessKind::Droid, diff --git a/crates/harness-locate/src/harness/mod.rs b/crates/harness-locate/src/harness/mod.rs index 960d849..c55b711 100644 --- a/crates/harness-locate/src/harness/mod.rs +++ b/crates/harness-locate/src/harness/mod.rs @@ -14,8 +14,10 @@ pub mod amp_code; pub mod claude_code; pub mod copilot_cli; pub mod crush; +pub(crate) mod dot_dir; pub mod droid; pub mod goose; +pub mod grok_build; pub(crate) mod mcp_parse; pub mod opencode; @@ -56,6 +58,7 @@ impl Harness { HarnessKind::CopilotCli => copilot_cli::is_installed(), HarnessKind::Crush => crush::is_installed(), HarnessKind::Droid => droid::is_installed(), + HarnessKind::GrokBuild => grok_build::is_installed(), }; if is_installed { @@ -135,6 +138,7 @@ impl Harness { HarnessKind::CopilotCli => copilot_cli::is_installed(), HarnessKind::Crush => crush::is_installed(), HarnessKind::Droid => droid::is_installed(), + HarnessKind::GrokBuild => grok_build::is_installed(), } } @@ -156,6 +160,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::GrokBuild => grok_build::global_config_dir().ok(), } .filter(|p| p.exists()); @@ -311,6 +316,19 @@ impl Harness { file_format: FileFormat::Markdown, })) } + HarnessKind::GrokBuild => { + let path = grok_build::skills_dir(scope) + .ok_or_else(|| Error::NotFound("skills directory".into()))?; + Ok(Some(DirectoryResource { + exists: path.exists(), + path, + structure: DirectoryStructure::Nested { + subdir_pattern: "*".into(), + file_name: "SKILL.md".into(), + }, + file_format: FileFormat::MarkdownWithFrontmatter, + })) + } HarnessKind::Droid => { let path = droid::skills_dir(scope) .ok_or_else(|| Error::NotFound("skills directory".into()))?; @@ -351,6 +369,7 @@ impl Harness { HarnessKind::Goose | HarnessKind::CopilotCli | HarnessKind::Crush => return Ok(None), HarnessKind::AmpCode => amp_code::commands_dir(scope)?, HarnessKind::Droid => droid::commands_dir(scope)?, + HarnessKind::GrokBuild => grok_build::commands_dir(scope)?, }; Ok(Some(DirectoryResource { exists: path.exists(), @@ -415,6 +434,22 @@ impl Harness { | HarnessKind::CopilotCli | HarnessKind::Crush | HarnessKind::Droid => Ok(None), + HarnessKind::GrokBuild => { + let path = grok_build::config_dir(scope)?.join("plugins"); + // Plugins are directories under plugins/; plugin.json is optional. + // List all subdirs via Flat-style discovery on the directory itself + // using a Nested pattern that accepts any of the common markers. + Ok(Some(DirectoryResource { + exists: path.exists(), + path, + structure: DirectoryStructure::Nested { + subdir_pattern: "*".into(), + // Optional marker — extraction also falls back to listing subdirs + file_name: "plugin.json".into(), + }, + file_format: FileFormat::Json, + })) + } } } @@ -478,6 +513,18 @@ impl Harness { file_format: FileFormat::MarkdownWithFrontmatter, })) } + HarnessKind::GrokBuild => { + let path = grok_build::agents_dir(scope) + .ok_or_else(|| Error::NotFound("agents directory".into()))?; + Ok(Some(DirectoryResource { + exists: path.exists(), + path, + structure: DirectoryStructure::Flat { + file_pattern: "*.md".into(), + }, + file_format: FileFormat::MarkdownWithFrontmatter, + })) + } HarnessKind::Droid => { let path = droid::agents_dir(scope) .ok_or_else(|| Error::NotFound("agents directory".into()))?; @@ -524,6 +571,7 @@ impl Harness { HarnessKind::CopilotCli => copilot_cli::config_dir(scope), HarnessKind::Crush => crush::config_dir(scope), HarnessKind::Droid => droid::config_dir(scope), + HarnessKind::GrokBuild => grok_build::config_dir(scope), } } @@ -600,6 +648,14 @@ impl Harness { FileFormat::Json, ) } + HarnessKind::GrokBuild => { + let base = grok_build::mcp_dir(scope)?; + ( + base.join("config.toml"), + "/mcp_servers".into(), + FileFormat::Toml, + ) + } }; Ok(Some(ConfigResource { file_exists: file.exists(), @@ -765,6 +821,7 @@ impl Harness { HarnessKind::CopilotCli => copilot_cli::rules_dir(scope), HarnessKind::Crush => crush::rules_dir(scope), HarnessKind::Droid => droid::rules_dir(scope), + HarnessKind::GrokBuild => grok_build::rules_dir(scope), }; match path { Some(p) => Ok(Some(DirectoryResource { @@ -859,6 +916,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::GrokBuild => grok_build::parse_mcp_servers(config)?, }; Ok(servers.into_iter().collect()) } @@ -898,6 +956,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::GrokBuild => grok_build::parse_mcp_server(value), }; result.map_err(|e| match e { @@ -1199,7 +1258,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 +1266,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::GrokBuild)); } #[test] diff --git a/crates/harness-locate/src/mcp.rs b/crates/harness-locate/src/mcp.rs index fa4009f..2867dd2 100644 --- a/crates/harness-locate/src/mcp.rs +++ b/crates/harness-locate/src/mcp.rs @@ -146,6 +146,7 @@ impl McpServer { HarnessKind::Goose => self.to_goose_value(kind, name), HarnessKind::AmpCode => self.to_ampcode_value(kind), HarnessKind::Droid => self.to_droid_value(kind), + HarnessKind::GrokBuild => self.to_grok_build_value(kind), } } @@ -477,6 +478,85 @@ impl McpServer { } } } + + /// Grok Build native format for `[mcp_servers.]` in config.toml. + /// + /// ```toml + /// [mcp_servers.my-server] + /// command = "npx" + /// args = ["-y", "pkg"] + /// env = { KEY = "value" } + /// enabled = true + /// startup_timeout_sec = 30 + /// + /// [mcp_servers.remote] + /// url = "https://example.com/mcp" + /// headers = { Authorization = "Bearer …" } + /// ``` + fn to_grok_build_value(&self, kind: HarnessKind) -> Result { + match self { + Self::Stdio(s) => { + let mut obj = serde_json::json!({ + "command": s.command, + "args": s.args, + "enabled": s.enabled, + }); + 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 { + // Grok uses startup_timeout_sec (seconds) + obj["startup_timeout_sec"] = + serde_json::json!(timeout_ms.div_ceil(1000).max(1)); + } + Ok(obj) + } + Self::Sse(s) => { + // Grok remote servers are url-based; transport is inferred. + let mut obj = serde_json::json!({ + "url": s.url, + "enabled": s.enabled, + }); + 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["startup_timeout_sec"] = + serde_json::json!(timeout_ms.div_ceil(1000).max(1)); + } + Ok(obj) + } + Self::Http(h) => { + let mut obj = serde_json::json!({ + "url": h.url, + "enabled": h.enabled, + }); + 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["startup_timeout_sec"] = + serde_json::json!(timeout_ms.div_ceil(1000).max(1)); + } + Ok(obj) + } + } + } } /// Configuration for a stdio-based MCP server. @@ -771,7 +851,7 @@ impl McpCapabilities { headers: true, cwd: false, }, - HarnessKind::Droid => Self { + HarnessKind::Droid | HarnessKind::GrokBuild => Self { stdio: true, sse: true, http: true, @@ -1245,4 +1325,48 @@ mod tests { assert_eq!(value["type"], "http"); assert_eq!(value["url"], "http://localhost:8080"); } + + #[test] + fn to_native_value_stdio_grok_build() { + let mut env = HashMap::new(); + env.insert("KEY".to_string(), EnvValue::env("SECRET")); + + let server = McpServer::Stdio(StdioMcpServer { + command: "npx".to_string(), + args: vec!["-y".to_string(), "pkg".to_string()], + env, + cwd: None, + enabled: false, + timeout_ms: Some(30_000), + }); + + let value = server + .to_native_value(HarnessKind::GrokBuild, "my-server") + .unwrap(); + // Must NOT be OpenCode format + assert!(value.get("type").is_none() || value["type"] != "local"); + assert_eq!(value["command"], "npx"); + assert_eq!(value["args"], serde_json::json!(["-y", "pkg"])); + assert_eq!(value["enabled"], false); + assert_eq!(value["startup_timeout_sec"], 30); + assert_eq!(value["env"]["KEY"], "${SECRET}"); + } + + #[test] + fn to_native_value_http_grok_build() { + let server = McpServer::Http(HttpMcpServer { + url: "https://example.com/mcp".to_string(), + headers: HashMap::new(), + oauth: None, + enabled: true, + timeout_ms: None, + }); + + let value = server + .to_native_value(HarnessKind::GrokBuild, "remote") + .unwrap(); + assert_eq!(value["url"], "https://example.com/mcp"); + assert_eq!(value["enabled"], true); + assert!(value.get("type").is_none()); + } } diff --git a/crates/harness-locate/src/types.rs b/crates/harness-locate/src/types.rs index f3721fa..3fae5de 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, + /// Grok Build (xAI's coding agent) + GrokBuild, } 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::GrokBuild => write!(f, "Grok Build"), } } } @@ -58,6 +61,7 @@ impl HarnessKind { Self::CopilotCli => "Copilot CLI", Self::Crush => "Crush", Self::Droid => "Droid", + Self::GrokBuild => "Grok Build", } } @@ -83,6 +87,7 @@ impl HarnessKind { Self::CopilotCli, Self::Crush, Self::Droid, + Self::GrokBuild, ]; /// 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::GrokBuild => &["grok"], } } @@ -178,6 +184,12 @@ impl HarnessKind { (Self::Droid, ResourceKind::Commands) => Some(&["commands"]), (Self::Droid, ResourceKind::Agents) => Some(&["droids"]), + // Grok Build - plural names under .grok + (Self::GrokBuild, ResourceKind::Skills) => Some(&["skills"]), + (Self::GrokBuild, ResourceKind::Commands) => Some(&["commands"]), + (Self::GrokBuild, ResourceKind::Agents) => Some(&["agents"]), + (Self::GrokBuild, ResourceKind::Plugins) => Some(&["plugins"]), + // Unsupported combinations _ => None, } @@ -371,6 +383,8 @@ pub enum FileFormat { Jsonc, /// YAML format. Yaml, + /// TOML format. + Toml, /// Plain Markdown. Markdown, /// Markdown with YAML frontmatter. @@ -540,7 +554,8 @@ impl EnvValue { HarnessKind::ClaudeCode | HarnessKind::AmpCode | HarnessKind::CopilotCli - | HarnessKind::Droid => { + | HarnessKind::Droid + | HarnessKind::GrokBuild => { format!("${{{env}}}") } HarnessKind::OpenCode | HarnessKind::Crush => format!("{{env:{env}}}"), @@ -586,7 +601,8 @@ impl EnvValue { HarnessKind::ClaudeCode | HarnessKind::AmpCode | HarnessKind::CopilotCli - | HarnessKind::Droid => Ok(format!("${{{env}}}")), + | HarnessKind::Droid + | HarnessKind::GrokBuild => 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 +644,8 @@ impl EnvValue { HarnessKind::ClaudeCode | HarnessKind::AmpCode | HarnessKind::CopilotCli - | HarnessKind::Droid => { + | HarnessKind::Droid + | HarnessKind::GrokBuild => { if let Some(var) = s.strip_prefix("${").and_then(|s| s.strip_suffix('}')) { Self::EnvRef { env: var.to_string(), diff --git a/crates/harness-locate/src/validation.rs b/crates/harness-locate/src/validation.rs index 96ec252..e22cdf7 100644 --- a/crates/harness-locate/src/validation.rs +++ b/crates/harness-locate/src/validation.rs @@ -175,7 +175,7 @@ impl AgentCapabilities { color_format: ColorFormat::NamedOrHex, supported_modes: &["subagent", "primary"], }), - HarnessKind::CopilotCli | HarnessKind::Droid => Some(Self { + HarnessKind::CopilotCli | HarnessKind::Droid | HarnessKind::GrokBuild => Some(Self { tools_format: ToolsFormat::CommaSeparatedString, color_format: ColorFormat::NamedOrHex, supported_modes: &["subagent", "primary"], @@ -214,7 +214,10 @@ impl SkillCapabilities { name_must_match_directory: true, description_required: true, }), - HarnessKind::ClaudeCode | HarnessKind::AmpCode | HarnessKind::Droid => Some(Self { + HarnessKind::ClaudeCode + | HarnessKind::AmpCode + | HarnessKind::Droid + | HarnessKind::GrokBuild => Some(Self { name_format: NameFormat::Any, name_must_match_directory: false, description_required: false,