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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .factory/droids/harness-integration-specialist.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
1 change: 1 addition & 0 deletions crates/bridle/src/cli/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ fn select_targets(selected: &SelectedComponents) -> Result<Vec<InstallTarget>> {
HarnessKind::CopilotCli,
HarnessKind::Crush,
HarnessKind::Droid,
HarnessKind::GeminiCli,
];

let mut groups: Vec<TargetGroup> = Vec::new();
Expand Down
1 change: 1 addition & 0 deletions crates/bridle/src/cli/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub(crate) fn resolve_harness(name: &str) -> Result<Harness> {
"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))
Expand Down
22 changes: 20 additions & 2 deletions crates/bridle/src/config/manager/extraction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,11 @@ fn extract_mcp_from_ampcode_config(profile_path: &Path) -> Result<Vec<McpServerI
let config: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| Error::Config(format!("Failed to parse settings.json: {}", e)))?;

let mcp_obj = match config.get("amp.mcpServers").and_then(|v| v.as_object()) {
let mcp_obj = match config
.get("amp.mcpServers")
.or_else(|| config.get("amp").and_then(|v| v.get("mcpServers")))
.and_then(|v| v.as_object())
{
Some(obj) => obj,
None => return Ok(Vec::new()),
};
Expand All @@ -271,10 +275,23 @@ fn extract_mcp_from_ampcode_config(profile_path: &Path) -> Result<Vec<McpServerI
.collect()
});
let url = value.get("url").and_then(|v| v.as_str()).map(String::from);
let server_type = value
.get("type")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
if url.is_some() {
Some("http".to_string())
} else if command.is_some() {
Some("stdio".to_string())
} else {
None
}
});
McpServerInfo {
name: name.clone(),
enabled: true,
server_type: Some("stdio".to_string()),
server_type,
command,
args,
url,
Expand Down Expand Up @@ -316,6 +333,7 @@ pub fn extract_theme(harness: &dyn HarnessConfig, profile_path: &Path) -> 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" => {
Expand Down
10 changes: 10 additions & 0 deletions crates/bridle/src/harness/install_instructions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub fn get_install_instructions(kind: HarnessKind) -> Vec<String> {
HarnessKind::CopilotCli => copilot_cli_instructions(),
HarnessKind::Crush => crush_instructions(),
HarnessKind::Droid => droid_instructions(),
HarnessKind::GeminiCli => gemini_cli_instructions(),
_ => vec!["Unknown harness".to_string()],
}
}
Expand Down Expand Up @@ -48,6 +49,13 @@ fn droid_instructions() -> Vec<String> {
]
}

fn gemini_cli_instructions() -> Vec<String> {
vec![
"- npm install -g @google/gemini-cli".to_string(),
"- npx @google/gemini-cli".to_string(),
]
}

fn claude_code_instructions() -> Vec<String> {
if cfg!(target_os = "macos") {
vec![
Expand Down Expand Up @@ -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",
};

Expand Down Expand Up @@ -192,6 +201,7 @@ pub fn get_empty_state_message(
HarnessKind::CopilotCli => "copilot",
HarnessKind::Crush => "crush",
HarnessKind::Droid => "droid",
HarnessKind::GeminiCli => "gemini",
_ => "<unknown>",
};

Expand Down
1 change: 1 addition & 0 deletions crates/bridle/src/harness/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
}
Expand Down
9 changes: 8 additions & 1 deletion crates/bridle/src/install/mcp_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
}
Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions crates/bridle/src/install/mcp_installer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/bridle/src/install/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub fn parse_harness_kind(id: &str) -> Option<HarnessKind> {
"copilot-cli" | "copilot" | "ghcp" => Some(HarnessKind::CopilotCli),
"crush" => Some(HarnessKind::Crush),
"droid" | "factory" => Some(HarnessKind::Droid),
"gemini-cli" | "gemini" => Some(HarnessKind::GeminiCli),
_ => None,
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/bridle/src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
}
Expand All @@ -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",
}
}
Expand Down
68 changes: 38 additions & 30 deletions crates/harness-locate/src/harness/amp_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -26,33 +27,40 @@ pub fn global_config_dir() -> Result<PathBuf> {
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<PathBuf> {
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()),
}
}

/// 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<PathBuf> {
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")),
}
}
Expand All @@ -62,26 +70,32 @@ pub fn commands_dir(scope: &Scope) -> Result<PathBuf> {
/// 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<PathBuf> {
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<PathBuf> {
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")),
}
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading