Skip to content
Draft
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
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::GrokBuild,
];

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,
"grok-build" | "grok" | "grokbuild" => HarnessKind::GrokBuild,
_ => return Err(Error::UnknownHarness(name.to_string())),
};
Ok(Harness::new(kind))
Expand Down
112 changes: 112 additions & 0 deletions crates/bridle/src/config/manager/extraction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
Expand Down Expand Up @@ -142,6 +143,59 @@ fn extract_mcp_generic(
.collect())
}

fn extract_mcp_from_grok_build_config(profile_path: &Path) -> Result<Vec<McpServerInfo>> {
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<Vec<McpServerInfo>> {
let config_path = profile_path.join(".mcp.json");
if !config_path.exists() {
Expand Down Expand Up @@ -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,
}
}
Expand All @@ -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,
Expand Down Expand Up @@ -381,6 +446,19 @@ fn extract_model_goose(profile_path: &Path) -> Option<String> {
.map(String::from)
}

fn extract_model_grok_build(profile_path: &Path) -> Option<String> {
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<String> {
let config_path = profile_path.join("settings.json");
let content = std::fs::read_to_string(&config_path).ok()?;
Expand Down Expand Up @@ -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(
Expand All @@ -710,6 +793,35 @@ pub fn extract_plugins(
}
}

/// List plugin subdirectories (no required marker file).
fn extract_subdir_plugins(profile_path: &Path) -> (Option<ResourceSummary>, Option<String>) {
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<String> = 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<ResourceSummary>, Option<String>) {
Expand Down
35 changes: 33 additions & 2 deletions crates/bridle/src/config/manager/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,53 @@ 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",
"projects",
"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;
Expand Down
9 changes: 9 additions & 0 deletions crates/bridle/src/config/manager/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
21 changes: 10 additions & 11 deletions crates/bridle/src/display/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,18 +478,17 @@ fn render_node_lines(lines: &mut Vec<Line<'static>>, 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 {
Expand Down
12 changes: 12 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::GrokBuild => grok_build_instructions(),
_ => vec!["Unknown harness".to_string()],
}
}
Expand Down Expand Up @@ -41,6 +42,15 @@ fn crush_instructions() -> Vec<String> {
]
}

fn grok_build_instructions() -> Vec<String> {
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<String> {
vec![
"- Visit https://factory.ai for installation instructions".to_string(),
Expand Down Expand Up @@ -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",
};

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

Expand Down
5 changes: 5 additions & 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::GrokBuild => "grok-build",
_ => "unknown",
}
}
Expand Down Expand Up @@ -88,9 +89,13 @@ impl HarnessConfig for harness_locate::Harness {

fn parse_mcp_servers(&self, content: &str, filename: &str) -> Result<Vec<(String, bool)>> {
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)?
};
Expand Down
Loading
Loading