use super::agent_config;
use super::init::{
read_shell_hook_owners, shell_hook_owners_path, write_shell_hook_owners, CLAUDE_MD_STUB,
SUPPORTED_AGENTS,
};
use anyhow::Result;
use std::path::{Path, PathBuf};
const SHELLS: &[&str] = &["zsh", "bash", "fish"];
pub fn handle(agent: Option<String>) -> Result<()> {
match agent.as_deref() {
Some(name) => {
let was_configured = is_configured(name);
uninstall_agent(name)?;
match read_shell_hook_owners() {
Some(mut owners) => {
owners.retain(|o| o != name);
if owners.is_empty() {
remove_all_shell_hooks()?;
std::fs::remove_file(shell_hook_owners_path()).ok();
} else {
write_shell_hook_owners(&owners)?;
}
}
None if was_configured => remove_shell_hooks_if_last(name)?,
None => {}
}
}
None => {
let mut removed = 0u32;
for name in SUPPORTED_AGENTS {
if is_configured(name) {
println!("Removing: {name}");
uninstall_agent(name)?;
removed += 1;
println!();
}
}
std::fs::remove_file(shell_hook_owners_path()).ok();
if removed == 0 {
println!("No bctx agent configurations found.");
println!("Run with --agent to remove a specific agent:");
for name in SUPPORTED_AGENTS {
println!(" bctx uninstall --agent {name}");
}
}
}
}
Ok(())
}
fn remove_shell_hooks_if_last(removed: &str) -> Result<()> {
if SHELLS.contains(&removed) {
return Ok(());
}
if SUPPORTED_AGENTS
.iter()
.any(|a| !SHELLS.contains(a) && is_configured(a))
{
return Ok(());
}
remove_all_shell_hooks()
}
fn remove_all_shell_hooks() -> Result<()> {
for shell in SHELLS {
if is_configured(shell) {
uninstall_agent(shell)?;
}
}
Ok(())
}
fn is_configured(agent: &str) -> bool {
let home = super::home_dir();
let h = |rel: &str| PathBuf::from(&home).join(rel);
match agent {
"claude" => {
has_bctx_mcp_key(&h(".claude.json"), "mcpServers")
|| has_bctx_mcp_key(&h(".claude/mcp.json"), "mcpServers")
|| h(".claude/bctx-hook.sh").exists()
}
"cursor" => has_bctx_mcp_key(&h(".cursor/mcp.json"), "mcpServers"),
"windsurf" => has_bctx_mcp_key(&h(".codeium/windsurf/mcp_config.json"), "mcpServers"),
"gemini" => has_bctx_mcp_key(&h(".gemini/settings.json"), "mcpServers"),
"zed" => has_bctx_mcp_key(&h(".config/zed/settings.json"), "context_servers"),
"continue" => has_bctx_continue_entry(&h(".continue/config.json")),
"cline" | "roo" | "copilot" => {
#[cfg(target_os = "macos")]
{
let p = PathBuf::from(&home).join("Library/Application Support/Code/User/mcp.json");
has_bctx_mcp_key(&p, "servers")
}
#[cfg(not(target_os = "macos"))]
{
let p = h(".config/Code/User/mcp.json");
has_bctx_mcp_key(&p, "servers")
}
}
"antigravity" => has_bctx_mcp_key(&h(".gemini/antigravity/mcp_config.json"), "mcpServers"),
"amazonq" => has_bctx_mcp_key(&h(".aws/amazonq/mcp.json"), "mcpServers"),
"kiro" => has_bctx_mcp_key(&h(".kiro/settings/mcp.json"), "mcpServers"),
"codex" => has_bctx_mcp_key(&h(".codex/mcp.json"), "mcpServers"),
"trae" => {
#[cfg(target_os = "macos")]
{
let p = PathBuf::from(&home).join("Library/Application Support/Trae/User/mcp.json");
has_bctx_mcp_key(&p, "servers")
}
#[cfg(not(target_os = "macos"))]
false
}
"aider" => file_contains(&h(".aider.conf.yml"), AIDER_START),
"goose" => file_contains(&h(".config/goose/config.yaml"), GOOSE_START),
"jetbrains" => jetbrains_mcp_files(&home)
.iter()
.any(|p| has_bctx_mcp_key(p, "mcpServers")),
"opencode" => has_bctx_mcp_key(&h(".opencode/config.json"), "mcpServers"),
"amp" => has_bctx_mcp_key(&h(".config/amp/settings.json"), "amp.mcpServers"),
"pear" => has_bctx_mcp_key(&vscode_family_mcp(&home, "PearAI"), "servers"),
"void" => has_bctx_mcp_key(&vscode_family_mcp(&home, "Void"), "servers"),
"neovim" => h(NEOVIM_SNIPPET).exists(),
"zsh" => shell_has_bctx_hook(&h(".zshrc")),
"bash" => shell_has_bctx_hook(&h(".bashrc")) || shell_has_bctx_hook(&h(".bash_profile")),
"fish" => h(FISH_HOOK).exists(),
_ => false,
}
}
const AIDER_START: &str = "# bctx — route git/bash through the context-aware lens pipeline";
const AIDER_END: &str = "# bctx binary:";
const GOOSE_START: &str = "# bctx MCP server — added by `bctx init --agent goose`";
const GOOSE_END: &str = "- mcp";
const NEOVIM_SNIPPET: &str = ".config/nvim/lua/bctx_mcp.lua";
const FISH_HOOK: &str = ".config/fish/conf.d/bctx.fish";
fn file_contains(path: &Path, needle: &str) -> bool {
std::fs::read_to_string(path).is_ok_and(|s| s.contains(needle))
}
fn vscode_family_mcp(home: &str, app: &str) -> PathBuf {
#[cfg(target_os = "macos")]
let base = PathBuf::from(home).join("Library/Application Support");
#[cfg(not(target_os = "macos"))]
let base = PathBuf::from(home).join(".config");
base.join(app).join("User/mcp.json")
}
fn jetbrains_mcp_files(home: &str) -> Vec<PathBuf> {
#[cfg(target_os = "macos")]
let root = PathBuf::from(home).join("Library/Application Support/JetBrains");
#[cfg(not(target_os = "macos"))]
let root = PathBuf::from(home).join(".config/JetBrains");
std::fs::read_dir(root)
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.map(|p| p.join("mcp.json"))
.collect()
})
.unwrap_or_default()
}
fn has_bctx_mcp_key(path: &Path, key: &str) -> bool {
let Ok(raw) = std::fs::read_to_string(path) else {
return false;
};
let Some(cfg) = agent_config::parse_lenient(&raw) else {
return false;
};
cfg[key].get("bctx").is_some()
}
fn has_bctx_continue_entry(path: &Path) -> bool {
let Ok(raw) = std::fs::read_to_string(path) else {
return false;
};
let Some(cfg) = agent_config::parse_lenient(&raw) else {
return false;
};
cfg["mcpServers"].as_array().is_some_and(|arr| {
arr.iter()
.any(|e| e.get("name").and_then(|v| v.as_str()) == Some("bctx"))
})
}
fn shell_has_bctx_hook(path: &Path) -> bool {
std::fs::read_to_string(path).is_ok_and(|s| s.contains("# bctx —"))
}
fn uninstall_agent(agent: &str) -> Result<()> {
match agent {
"claude" => uninstall_claude(),
"cursor" => uninstall_cursor(),
"windsurf" => uninstall_windsurf(),
"gemini" => uninstall_gemini(),
"zed" => uninstall_zed(),
"continue" => uninstall_continue(),
"cline" | "roo" => uninstall_vscode_extension(agent),
"copilot" => uninstall_vscode_extension("copilot"),
"antigravity" => uninstall_antigravity(),
"amazonq" => uninstall_amazonq(),
"kiro" => uninstall_kiro(),
"codex" => uninstall_codex(),
"trae" => uninstall_trae(),
"aider" => uninstall_aider(),
"goose" => remove_block(
&home_path(".config/goose/config.yaml"),
GOOSE_START,
GOOSE_END,
),
"jetbrains" => jetbrains_mcp_files(&super::home_dir())
.iter()
.try_for_each(|p| remove_mcp_key(p, "mcpServers")),
"opencode" => remove_mcp_key(&home_path(".opencode/config.json"), "mcpServers"),
"amp" => remove_mcp_key(&home_path(".config/amp/settings.json"), "amp.mcpServers"),
"pear" => remove_servers_key(&vscode_family_mcp(&super::home_dir(), "PearAI")),
"void" => remove_servers_key(&vscode_family_mcp(&super::home_dir(), "Void")),
"neovim" => uninstall_neovim(),
"zsh" => uninstall_shell("zsh"),
"bash" => uninstall_shell("bash"),
"fish" => uninstall_shell("fish"),
other => {
eprintln!("bctx uninstall: unknown agent '{other}'");
eprintln!("Supported agents:");
for name in SUPPORTED_AGENTS {
eprintln!(" {name}");
}
std::process::exit(1);
}
}
}
fn uninstall_claude() -> Result<()> {
let home = super::home_dir();
let claude_dir = PathBuf::from(&home).join(".claude");
let claude_json = PathBuf::from(&home).join(".claude.json");
remove_mcp_key(&claude_json, "mcpServers")?;
let claude_mcp = claude_dir.join("mcp.json");
remove_mcp_key(&claude_mcp, "mcpServers")?;
#[cfg(target_os = "macos")]
remove_vscode_mcp_entry(&home, "Code/User")?;
let settings_path = claude_dir.join("settings.json");
let settings = std::fs::read_to_string(&settings_path)
.ok()
.and_then(|raw| parse_for_removal(&settings_path, &raw, settings_list_bctx));
if let Some(mut cfg) = settings {
let mut changed = false;
if let Some(arr) = cfg
.pointer_mut("/hooks/PreToolUse")
.and_then(serde_json::Value::as_array_mut)
{
let before = arr.len();
arr.retain(|h| {
!h["hooks"]
.as_array()
.and_then(|hs| hs.first())
.and_then(|h| h["command"].as_str())
.is_some_and(|c| c.contains("bctx-hook"))
});
changed |= arr.len() < before;
}
if let Some(arr) = cfg
.pointer_mut("/permissions/allow")
.and_then(serde_json::Value::as_array_mut)
{
let before = arr.len();
arr.retain(|p| !p.as_str().is_some_and(|s| s.starts_with("mcp__bctx__")));
changed |= arr.len() < before;
}
if changed {
std::fs::write(&settings_path, serde_json::to_string_pretty(&cfg)?)?;
println!(" ✓ settings.json hook entry and bctx skill permissions removed");
}
}
remove_file_if(&claude_dir.join("rules/bctx.md"), |_| true)?;
let rules_dir = claude_dir.join("rules");
if std::fs::read_dir(&rules_dir).is_ok_and(|mut rd| rd.next().is_none()) {
std::fs::remove_dir(&rules_dir)?;
}
let claude_md = claude_dir.join("CLAUDE.md");
remove_file_if(&claude_md, |content| content == CLAUDE_MD_STUB)?;
if file_contains(&claude_md, "@rules/bctx.md") {
println!(
" → {} still imports @rules/bctx.md (you edited it); remove that section by hand",
claude_md.display()
);
}
let hook_path = claude_dir.join("bctx-hook.sh");
if hook_path.exists() {
std::fs::remove_file(&hook_path)?;
println!(" ✓ bctx-hook.sh removed");
}
println!(" → Restart Claude Code to deactivate.");
Ok(())
}
fn uninstall_cursor() -> Result<()> {
let home = super::home_dir();
let path = PathBuf::from(&home).join(".cursor/mcp.json");
remove_mcp_key(&path, "mcpServers")?;
#[cfg(target_os = "macos")]
remove_vscode_mcp_entry(&home, "Cursor/User")?;
println!(" → Restart Cursor to deactivate.");
Ok(())
}
fn uninstall_windsurf() -> Result<()> {
let home = super::home_dir();
let path = PathBuf::from(&home).join(".codeium/windsurf/mcp_config.json");
remove_mcp_key(&path, "mcpServers")?;
#[cfg(target_os = "macos")]
remove_vscode_mcp_entry(&home, "Windsurf/User")?;
println!(" → Restart Windsurf to deactivate.");
Ok(())
}
fn uninstall_gemini() -> Result<()> {
let home = super::home_dir();
let path = PathBuf::from(&home).join(".gemini/settings.json");
remove_mcp_key(&path, "mcpServers")?;
println!(" → Restart Gemini CLI to deactivate.");
Ok(())
}
fn uninstall_zed() -> Result<()> {
let home = super::home_dir();
let path = PathBuf::from(&home).join(".config/zed/settings.json");
remove_mcp_key(&path, "context_servers")?;
println!(" → Restart Zed to deactivate.");
Ok(())
}
fn uninstall_continue() -> Result<()> {
let home = super::home_dir();
let path = PathBuf::from(&home).join(".continue/config.json");
if !path.exists() {
return Ok(());
}
let raw = std::fs::read_to_string(&path)?;
let lists_bctx = |c: &serde_json::Value| {
c["mcpServers"].as_array().is_some_and(|arr| {
arr.iter()
.any(|e| e.get("name").and_then(|v| v.as_str()) == Some("bctx"))
})
};
let Some(mut cfg) = parse_for_removal(&path, &raw, lists_bctx) else {
return Ok(());
};
if let Some(arr) = cfg
.get_mut("mcpServers")
.and_then(serde_json::Value::as_array_mut)
{
let before = arr.len();
arr.retain(|e| e.get("name").and_then(|v| v.as_str()) != Some("bctx"));
if arr.len() < before {
std::fs::write(&path, serde_json::to_string_pretty(&cfg)?)?;
println!(" ✓ ~/.continue/config.json — bctx entry removed");
}
}
println!(" → Reload Continue extension to deactivate.");
Ok(())
}
fn uninstall_vscode_extension(label: &str) -> Result<()> {
let home = super::home_dir();
#[cfg(target_os = "macos")]
remove_vscode_mcp_entry(&home, "Code/User")?;
#[cfg(not(target_os = "macos"))]
{
let path = PathBuf::from(&home).join(".config/Code/User/mcp.json");
remove_servers_key(&path)?;
}
println!(" ({label}) → Reload VSCode window to deactivate.");
Ok(())
}
fn uninstall_antigravity() -> Result<()> {
let home = super::home_dir();
let path = PathBuf::from(&home).join(".gemini/antigravity/mcp_config.json");
remove_mcp_key(&path, "mcpServers")?;
#[cfg(target_os = "macos")]
remove_vscode_mcp_entry(&home, "Antigravity/User")?;
println!(" → Restart Antigravity to deactivate.");
Ok(())
}
fn uninstall_amazonq() -> Result<()> {
let home = super::home_dir();
let path = PathBuf::from(&home).join(".aws/amazonq/mcp.json");
remove_mcp_key(&path, "mcpServers")?;
println!(" → Restart Amazon Q to deactivate.");
Ok(())
}
fn uninstall_kiro() -> Result<()> {
let home = super::home_dir();
let path = PathBuf::from(&home).join(".kiro/settings/mcp.json");
remove_mcp_key(&path, "mcpServers")?;
println!(" → Restart Kiro to deactivate.");
Ok(())
}
fn uninstall_codex() -> Result<()> {
let home = super::home_dir();
let path = PathBuf::from(&home).join(".codex/mcp.json");
remove_mcp_key(&path, "mcpServers")?;
println!(" → Restart Codex CLI to deactivate.");
Ok(())
}
fn uninstall_trae() -> Result<()> {
#[cfg(target_os = "macos")]
{
let home = super::home_dir();
remove_vscode_mcp_entry(&home, "Trae/User")?;
}
println!(" → Restart Trae to deactivate.");
Ok(())
}
fn uninstall_shell(shell: &str) -> Result<()> {
let home = super::home_dir();
if shell == "fish" {
return remove_file_if(&home_path(FISH_HOOK), |_| true);
}
let candidates: &[&str] = match shell {
"zsh" => &[".zshrc"],
"bash" => &[".bashrc", ".bash_profile"],
_ => unreachable!(),
};
for rel in candidates {
let path = PathBuf::from(&home).join(rel);
remove_shell_hook(&path)?;
}
Ok(())
}
fn remove_shell_hook(path: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(path)?;
if !content.contains("# bctx —") {
return Ok(());
}
if !end_follows_start(
&content,
|l| l.contains("# bctx —"),
|l| l.starts_with("unset _bctx_cmd") || l.starts_with("# bctx end"),
) {
println!(
" → {}: the bctx block has no end marker; left as is, remove it by hand",
path.display()
);
return Ok(());
}
let mut output = String::with_capacity(content.len());
let mut skip = false;
for line in content.lines() {
if line.contains("# bctx —") {
skip = true;
if output.ends_with('\n') {
output.truncate(output.trim_end_matches('\n').len());
if !output.is_empty() {
output.push('\n');
}
}
continue;
}
if skip {
if line.starts_with("unset _bctx_cmd") || line.starts_with("# bctx end") {
skip = false;
}
continue;
}
output.push_str(line);
output.push('\n');
}
std::fs::write(path, &output)?;
println!(" ✓ {} — bctx hook removed", path.display());
println!(" → Run: source {}", path.display());
Ok(())
}
fn uninstall_aider() -> Result<()> {
remove_block(&home_path(".aider.conf.yml"), AIDER_START, AIDER_END)?;
remove_file_if(&home_path(".bctx/aider-hook.sh"), |_| true)
}
fn uninstall_neovim() -> Result<()> {
remove_file_if(&home_path(NEOVIM_SNIPPET), |content| {
content.starts_with("-- bctx MCP server — generated by")
})
}
fn home_path(rel: &str) -> PathBuf {
PathBuf::from(super::home_dir()).join(rel)
}
fn remove_file_if(path: &Path, ours: impl Fn(&str) -> bool) -> Result<()> {
let Ok(content) = std::fs::read_to_string(path) else {
return Ok(());
};
if ours(&content) {
std::fs::remove_file(path)?;
println!(" ✓ {} removed", path.display());
}
Ok(())
}
fn end_follows_start(
content: &str,
start: impl Fn(&str) -> bool,
end: impl Fn(&str) -> bool,
) -> bool {
let mut lines = content.lines().skip_while(|l| !start(l));
lines.next().is_some() && lines.any(end)
}
fn remove_block(path: &Path, start: &str, end: &str) -> Result<()> {
let Ok(content) = std::fs::read_to_string(path) else {
return Ok(());
};
if !content.contains(start) {
return Ok(());
}
if !end_follows_start(
&content,
|l| l.contains(start),
|l| l.trim_start().starts_with(end),
) {
println!(
" → {}: the bctx block has no end marker; left as is, remove it by hand",
path.display()
);
return Ok(());
}
let mut kept: Vec<&str> = Vec::new();
let mut skipping = false;
for line in content.lines() {
if !skipping && line.contains(start) {
skipping = true;
if kept.last().is_some_and(|l| l.trim().is_empty()) {
kept.pop();
}
continue;
}
if skipping {
skipping = !line.trim_start().starts_with(end);
continue;
}
kept.push(line);
}
if kept.iter().all(|l| l.trim().is_empty()) {
std::fs::remove_file(path)?;
println!(" ✓ {} removed", path.display());
} else {
std::fs::write(path, kept.join("\n") + "\n")?;
println!(" ✓ {} — bctx removed", path.display());
}
Ok(())
}
fn parse_for_removal(
path: &Path,
raw: &str,
lists_bctx: impl Fn(&serde_json::Value) -> bool,
) -> Option<serde_json::Value> {
if let Ok(cfg) = serde_json::from_str(raw.strip_prefix('\u{feff}').unwrap_or(raw)) {
return Some(cfg);
}
if agent_config::parse_lenient(raw).is_some_and(|c| lists_bctx(&c)) {
println!(
" ! {} still lists bctx, but it has comments, so bctx left it untouched; remove the bctx entry by hand",
path.display()
);
}
None
}
fn settings_list_bctx(cfg: &serde_json::Value) -> bool {
let hook = cfg["hooks"]["PreToolUse"].as_array().is_some_and(|arr| {
arr.iter().any(|h| {
h["hooks"]
.as_array()
.and_then(|hs| hs.first())
.and_then(|h| h["command"].as_str())
.is_some_and(|c| c.contains("bctx-hook"))
})
});
let allowed = cfg["permissions"]["allow"].as_array().is_some_and(|arr| {
arr.iter()
.any(|p| p.as_str().is_some_and(|s| s.starts_with("mcp__bctx__")))
});
hook || allowed
}
fn remove_mcp_key(path: &Path, key: &str) -> Result<()> {
if !path.exists() {
return Ok(());
}
let raw = std::fs::read_to_string(path)?;
let Some(mut cfg) = parse_for_removal(path, &raw, |c| agent_config::has_bctx(c, key)) else {
return Ok(());
};
if cfg[key].get("bctx").is_some() {
cfg[key]
.as_object_mut()
.expect("mcp key is object")
.remove("bctx");
std::fs::write(path, serde_json::to_string_pretty(&cfg)?)?;
println!(" ✓ {} — bctx removed", path.display());
}
Ok(())
}
fn remove_servers_key(path: &Path) -> Result<()> {
remove_mcp_key(path, "servers")
}
#[cfg(target_os = "macos")]
fn remove_vscode_mcp_entry(home: &str, app_subdir: &str) -> Result<()> {
let path = PathBuf::from(home)
.join("Library/Application Support")
.join(app_subdir)
.join("mcp.json");
remove_servers_key(&path)
}