use anyhow::{Context, Result};
use colored::Colorize;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConfigFormat {
Json,
Jsonc,
#[allow(dead_code)]
Yaml,
}
#[derive(Debug, Clone)]
struct AgentInfo {
name: &'static str,
config_path: PathBuf,
format: ConfigFormat,
}
pub struct InstallOptions {
pub list: bool,
pub agents: Option<String>,
pub dry_run: bool,
pub force: bool,
#[allow(dead_code)]
pub yes: bool,
}
fn cora_mcp_entry() -> serde_json::Value {
serde_json::json!({
"command": "cora",
"args": ["mcp"],
"description": "Cora Code — AI code review, code intelligence, dead code detection"
})
}
fn known_agents(home: &std::path::Path) -> Vec<AgentInfo> {
vec![
AgentInfo {
name: "cline",
config_path: home.join(".cline/cline_mcp_settings.json"),
format: ConfigFormat::Json,
},
AgentInfo {
name: "cursor",
config_path: home.join(".cursor/mcp.json"),
format: ConfigFormat::Jsonc,
},
AgentInfo {
name: "windsurf",
config_path: home.join(".codeium/windsurf/mcp_config.json"),
format: ConfigFormat::Json,
},
AgentInfo {
name: "roo",
config_path: home.join(".roo/mcp.json"),
format: ConfigFormat::Json,
},
AgentInfo {
name: "copilot",
config_path: home.join(".github-copilot/mcp.json"),
format: ConfigFormat::Json,
},
AgentInfo {
name: "continue",
config_path: home.join(".continue/config.json"),
format: ConfigFormat::Json,
},
AgentInfo {
name: "amp",
config_path: home.join(".amp/amprc.json"),
format: ConfigFormat::Json,
},
AgentInfo {
name: "trae",
config_path: home.join(".trae/mcp.json"),
format: ConfigFormat::Json,
},
AgentInfo {
name: "kodu",
config_path: home.join(".kodu/settings.json"),
format: ConfigFormat::Json,
},
AgentInfo {
name: "fabric",
config_path: home.join(".fabric/mcp.json"),
format: ConfigFormat::Json,
},
]
}
fn detect_agents() -> Result<Vec<AgentInfo>> {
let home = dirs::home_dir().context("Could not determine home directory")?;
let candidates = known_agents(&home);
let detected: Vec<AgentInfo> = candidates
.into_iter()
.filter(|a| a.config_path.exists())
.collect();
Ok(detected)
}
fn strip_jsonc_comments(input: &str) -> String {
let re = regex::Regex::new(r"//.*|/\*[\s\S]*?\*/").expect("valid regex");
re.replace_all(input, "").to_string()
}
fn merge_json_config(path: &std::path::Path, force: bool, dry_run: bool) -> Result<String> {
let raw =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
let clean = strip_jsonc_comments(&raw);
let mut config: serde_json::Value =
serde_json::from_str(&clean).context("Failed to parse JSON config")?;
let is_jsonc = raw.contains("//") || raw.contains("/*");
let has_existing = config
.get("mcpServers")
.and_then(|m| m.get("cora"))
.is_some();
if has_existing && !force {
return Ok(format!(
" {} {} — cora entry already exists (use --force to overwrite)",
"⏭ ".dimmed(),
path.display()
));
}
let servers = config
.as_object_mut()
.context("Config root is not a JSON object")?
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
servers
.as_object_mut()
.context("mcpServers is not an object")?
.insert("cora".to_string(), cora_mcp_entry());
if dry_run {
Ok(format!(
" {} {} — would write cora MCP server entry",
"🔍 ".cyan(),
path.display()
))
} else {
let output = if is_jsonc {
format!(
"// Modified by cora install\n{}",
serde_json::to_string_pretty(&config)?
)
} else {
serde_json::to_string_pretty(&config)?
};
fs::write(path, &output).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(format!(
" {} {} — cora MCP server entry added",
"✓ ".green(),
path.display()
))
}
}
fn merge_yaml_config(path: &std::path::Path, force: bool, dry_run: bool) -> Result<String> {
let raw =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
let mut config: serde_yaml_ng::Value =
serde_yaml_ng::from_str(&raw).context("Failed to parse YAML config")?;
let has_existing = config
.get("mcpServers")
.and_then(|m| m.get("cora"))
.is_some();
if has_existing && !force {
return Ok(format!(
" {} {} — cora entry already exists (use --force to overwrite)",
"⏭ ".dimmed(),
path.display()
));
}
let servers = config
.as_mapping_mut()
.context("Config root is not a YAML mapping")?
.entry(serde_yaml_ng::Value::String("mcpServers".to_string()))
.or_insert_with(|| serde_yaml_ng::Value::Mapping(serde_yaml_ng::Mapping::new()));
if let Some(mapping) = servers.as_mapping_mut() {
let cora_entry_yaml: serde_yaml_ng::Value =
serde_yaml_ng::from_str("command: cora\nargs:\n - mcp\ndescription: 'Cora Code — AI code review, code intelligence, dead code detection'")
.expect("valid yaml");
mapping.insert(
serde_yaml_ng::Value::String("cora".to_string()),
cora_entry_yaml,
);
}
if dry_run {
Ok(format!(
" {} {} — would write cora MCP server entry",
"🔍 ".cyan(),
path.display()
))
} else {
let output = serde_yaml_ng::to_string(&config)?;
fs::write(path, output).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(format!(
" {} {} — cora MCP server entry added",
"✓ ".green(),
path.display()
))
}
}
fn install_agent(agent: &AgentInfo, opts: &InstallOptions) -> Result<String> {
match agent.format {
ConfigFormat::Json | ConfigFormat::Jsonc => {
merge_json_config(&agent.config_path, opts.force, opts.dry_run)
}
ConfigFormat::Yaml => merge_yaml_config(&agent.config_path, opts.force, opts.dry_run),
}
}
pub fn execute_install(opts: &InstallOptions) -> Result<String> {
let all_agents = detect_agents()?;
if all_agents.is_empty() {
return Ok("No AI coding agents detected. Install an agent (e.g. Cline, Cursor, Windsurf) and run again.".to_string());
}
let agents: Vec<&AgentInfo> = if let Some(ref agent_list) = opts.agents {
let requested: std::collections::HashSet<&str> =
agent_list.split(',').map(|s| s.trim()).collect();
all_agents
.iter()
.filter(|a| requested.contains(a.name))
.collect()
} else {
all_agents.iter().collect()
};
if agents.is_empty() {
if let Some(ref agent_list) = opts.agents {
return Ok(format!(
"None of the specified agents found: {agent_list}\nDetected agents: {}",
all_agents
.iter()
.map(|a| a.name)
.collect::<Vec<_>>()
.join(", ")
));
}
unreachable!();
}
if opts.list {
let mut lines = vec![format!("Detected {} AI coding agent(s):\n", agents.len())];
for agent in &agents {
lines.push(format!(
" {} {} {}",
"● ".cyan(),
agent.name.bold(),
agent.config_path.display()
));
}
return Ok(lines.join("\n"));
}
let mut lines = vec![format!(
"Configuring cora MCP for {} agent(s)…{}",
agents.len(),
if opts.dry_run { " (dry run)" } else { "" }
)];
lines.push(String::new());
for agent in &agents {
let result = install_agent(agent, opts)?;
lines.push(format!("{} {}", agent.name.bold(), result));
}
lines.push(String::new());
lines.push("Done. Restart your AI agent to pick up the new MCP server.".to_string());
Ok(lines.join("\n"))
}