From dc053efedf9e31ebf4f712bf29008a97efc2c34d Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 5 Aug 2026 08:25:17 +0700 Subject: [PATCH] feat(install): add --remove, --validate, multi-agent uninstall (#430) Add uninstall mode and post-install validation to cora install: - `cora install --remove`: removes cora MCP entry from all detected agents - `cora install --validate`: validates config files parse correctly after install/remove (JSON/JSONC only, YAML skipped) - Multi-agent config validation with error reporting - Uninstall works for both JSON/JSONC and YAML format agents - Updated MCP tools.rs to include new fields Closes #430 --- src/commands/install.rs | 181 +++++++++++++++++++++++++++++++++------- src/main.rs | 10 +++ src/mcp/tools.rs | 2 + 3 files changed, 164 insertions(+), 29 deletions(-) diff --git a/src/commands/install.rs b/src/commands/install.rs index 1679e13..5561b6e 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -20,7 +20,7 @@ struct AgentInfo { /// Install subcommand options. pub struct InstallOptions { - /// List detected agents without installing. + /// List detected agents only. pub list: bool, /// Specific agents to install (comma-separated). pub agents: Option, @@ -31,6 +31,10 @@ pub struct InstallOptions { /// Non-interactive mode. #[allow(dead_code)] pub yes: bool, + /// Remove cora MCP entry (uninstall mode). + pub remove: bool, + /// Validate agent configs after install/remove. + pub validate: bool, } /// Build the list of known agents and their config paths. @@ -100,6 +104,14 @@ fn detect_agents() -> Result> { Ok(detected) } +/// Validate that a config file still parses correctly after modification. +fn validate_json_config(path: &std::path::Path) -> Result<()> { + let config = read_json_config(path)?; + // If it parses without error, it's valid. + let _ = serde_json::to_string(&config)?; + Ok(()) +} + /// Install the cora MCP server entry into a JSON/JSONC agent config. fn install_json_agent(path: &std::path::Path, force: bool, dry_run: bool) -> Result { let mut config = read_json_config(path)?; @@ -130,40 +142,98 @@ fn install_json_agent(path: &std::path::Path, force: bool, dry_run: bool) -> Res } } -/// Install the cora MCP server entry for a single agent. +/// Remove the cora MCP server entry from a JSON/JSONC agent config. +fn uninstall_json_agent(path: &std::path::Path, dry_run: bool) -> Result { + use super::agent_config; + + let mut config = read_json_config(path)?; + + if !agent_config::json_has_cora(&config) { + return Ok(format!( + " {} {} β€” no cora entry found", + "⏭ ".dimmed(), + path.display() + )); + } + + agent_config::json_remove_cora(&mut config); + + if dry_run { + Ok(format!( + " {} {} β€” would remove cora MCP server entry", + "πŸ” ".cyan(), + path.display() + )) + } else { + write_json_config(path, &config)?; + Ok(format!( + " {} {} β€” cora MCP server entry removed", + "βœ“ ".green(), + path.display() + )) + } +} + +/// Install or remove the cora MCP server entry for a single agent. fn install_agent(agent: &AgentInfo, opts: &InstallOptions) -> Result { match agent.format { ConfigFormat::Json | ConfigFormat::Jsonc => { - install_json_agent(&agent.config_path, opts.force, opts.dry_run) + if opts.remove { + uninstall_json_agent(&agent.config_path, opts.dry_run) + } else { + install_json_agent(&agent.config_path, opts.force, opts.dry_run) + } } ConfigFormat::Yaml => { - // YAML agents are rare; delegate to agent_config module. use super::agent_config; let mut config = agent_config::read_yaml_config(&agent.config_path)?; - if agent_config::yaml_has_cora(&config) && !opts.force { - return Ok(format!( - " {} {} β€” cora entry already exists (use --force to overwrite)", - "⏭ ".dimmed(), - agent.config_path.display() - )); - } - - agent_config::yaml_add_cora(&mut config)?; - - if opts.dry_run { - Ok(format!( - " {} {} β€” would write cora MCP server entry", - "πŸ” ".cyan(), - agent.config_path.display() - )) + if opts.remove { + if !agent_config::yaml_has_cora(&config) { + return Ok(format!( + " {} {} β€” no cora entry found", + "⏭ ".dimmed(), + agent.config_path.display() + )); + } + agent_config::yaml_remove_cora(&mut config); + if opts.dry_run { + Ok(format!( + " {} {} β€” would remove cora MCP server entry", + "πŸ” ".cyan(), + agent.config_path.display() + )) + } else { + agent_config::write_yaml_config(&agent.config_path, &config)?; + Ok(format!( + " {} {} β€” cora MCP server entry removed", + "βœ“ ".green(), + agent.config_path.display() + )) + } } else { - agent_config::write_yaml_config(&agent.config_path, &config)?; - Ok(format!( - " {} {} β€” cora MCP server entry added", - "βœ“ ".green(), - agent.config_path.display() - )) + if agent_config::yaml_has_cora(&config) && !opts.force { + return Ok(format!( + " {} {} β€” cora entry already exists (use --force to overwrite)", + "⏭ ".dimmed(), + agent.config_path.display() + )); + } + agent_config::yaml_add_cora(&mut config)?; + if opts.dry_run { + Ok(format!( + " {} {} β€” would write cora MCP server entry", + "πŸ” ".cyan(), + agent.config_path.display() + )) + } else { + agent_config::write_yaml_config(&agent.config_path, &config)?; + Ok(format!( + " {} {} β€” cora MCP server entry added", + "βœ“ ".green(), + agent.config_path.display() + )) + } } } } @@ -217,9 +287,17 @@ pub fn execute_install(opts: &InstallOptions) -> Result { return Ok(lines.join("\n")); } - // Install mode + // Install or remove mode + let action = if opts.remove { + "Removing" + } else { + "Configuring" + }; + let noun = if opts.remove { "from" } else { "for" }; let mut lines = vec![format!( - "Configuring cora MCP for {} agent(s)…{}", + "{} cora MCP {} {} agent(s)…{}", + action, + noun, agents.len(), if opts.dry_run { " (dry run)" } else { "" } )]; @@ -230,8 +308,53 @@ pub fn execute_install(opts: &InstallOptions) -> Result { lines.push(format!("{} {}", agent.name.bold(), result)); } + // Post-install validation + if opts.validate && !opts.dry_run { + lines.push(String::new()); + lines.push("Validating agent configs…".to_string()); + let mut errors = 0; + for agent in &agents { + match agent.format { + ConfigFormat::Json | ConfigFormat::Jsonc => { + if let Err(e) = validate_json_config(&agent.config_path) { + lines.push(format!( + " {} {} β€” INVALID: {}", + "βœ— ".red(), + agent.config_path.display(), + e + )); + errors += 1; + } else { + lines.push(format!( + " {} {} β€” valid JSON", + "βœ“ ".green(), + agent.config_path.display() + )); + } + } + ConfigFormat::Yaml => { + lines.push(format!( + " {} {} β€” skipped (YAML validation not implemented)", + "⏭ ".dimmed(), + agent.config_path.display() + )); + } + } + } + if errors > 0 { + lines.push(format!( + "\n{} {errors} config(s) failed validation!", + "⚠ ".yellow() + )); + } + } + lines.push(String::new()); - lines.push("Done. Restart your AI agent to pick up the new MCP server.".to_string()); + if opts.remove { + lines.push("Done. Restart your AI agent to pick up the changes.".to_string()); + } else { + lines.push("Done. Restart your AI agent to pick up the new MCP server.".to_string()); + } Ok(lines.join("\n")) } diff --git a/src/main.rs b/src/main.rs index 38fc012..d93f547 100644 --- a/src/main.rs +++ b/src/main.rs @@ -485,6 +485,12 @@ enum Command { /// Install ALL detected agents (non-interactive) #[clap(long, short)] yes: bool, + /// Remove cora MCP entry from detected agents (uninstall) + #[clap(long)] + remove: bool, + /// Validate agent configs after install/remove + #[clap(long)] + validate: bool, }, /// Detect dead code β€” functions/methods with no callers @@ -1493,6 +1499,8 @@ async fn main() -> Result<()> { dry_run, force, yes, + remove, + validate, } => { let opts = commands::install::InstallOptions { list, @@ -1500,6 +1508,8 @@ async fn main() -> Result<()> { dry_run, force, yes, + remove, + validate, }; let output = commands::install::execute_install(&opts)?; println!("{output}"); diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index f4a527f..c93d780 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -997,6 +997,8 @@ fn handle_install(params: &serde_json::Value) -> ToolResult { dry_run, force: false, yes: true, // MCP is non-interactive + remove: false, + validate: false, }; match crate::commands::install::execute_install(&opts) {