use anyhow::{Context, Result};
use std::path::PathBuf;
pub fn handle(agent: Option<String>) -> Result<()> {
match agent.as_deref() {
Some(name) => {
install_agent(name)?;
if !matches!(name, "zsh" | "bash" | "fish") {
let shell = detect_shell();
if !shell_hook_present(&shell) {
println!();
println!("Detected shell: {shell}");
install_agent(&shell)?;
}
}
}
None => {
let mut installed = 0u32;
for name in SUPPORTED_AGENTS {
if is_installed(name) {
println!("Detected: {name}");
install_agent(name)?;
installed += 1;
println!();
}
}
if installed == 0 {
println!("No supported agents detected.");
println!("Run with --agent to install manually:");
for name in SUPPORTED_AGENTS {
println!(" bctx init --agent {name}");
}
}
}
}
Ok(())
}
fn detect_shell() -> String {
let home = std::env::var("HOME").unwrap_or_default();
if std::path::Path::new(&format!("{home}/.zshrc")).exists() {
return "zsh".to_string();
}
if std::path::Path::new(&format!("{home}/.bashrc")).exists()
|| std::path::Path::new(&format!("{home}/.bash_profile")).exists()
{
return "bash".to_string();
}
if let Ok(out) = std::process::Command::new("sh")
.args(["-c", "echo $SHELL"])
.output()
{
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.ends_with("zsh") {
return "zsh".to_string();
}
if s.ends_with("bash") {
return "bash".to_string();
}
if s.ends_with("fish") {
return "fish".to_string();
}
}
"zsh".to_string()
}
fn shell_hook_present(shell: &str) -> bool {
let home = std::env::var("HOME").unwrap_or_default();
let rc = match shell {
"bash" => {
let p = std::path::PathBuf::from(&home).join(".bashrc");
if p.exists() {
p
} else {
std::path::PathBuf::from(&home).join(".bash_profile")
}
}
"fish" => std::path::PathBuf::from(&home).join(".config/fish/conf.d/bctx.fish"),
_ => std::path::PathBuf::from(&home).join(".zshrc"),
};
std::fs::read_to_string(rc)
.unwrap_or_default()
.contains("# bctx —")
}
const SUPPORTED_AGENTS: &[&str] = &[
"claude", "cursor", "windsurf", "gemini", "zed", "continue", "cline", "roo", "copilot", "antigravity", "amazonq", "kiro", "codex", "trae", "aider", "goose", "jetbrains", "opencode", "pear", "void", "neovim", "amp", "fish", "zsh", "bash", ];
fn is_installed(agent: &str) -> bool {
let home = std::env::var("HOME").unwrap_or_default();
let h = |rel: &str| PathBuf::from(&home).join(rel);
let app = |name: &str| PathBuf::from(format!("{home}/Library/Application Support/{name}"));
match agent {
"claude" => {
h(".claude.json").exists()
|| PathBuf::from("/Applications/Claude.app").exists()
|| app("Code/User").exists()
|| cmd_exists("claude")
}
"cursor" => {
h(".cursor").exists()
|| PathBuf::from("/Applications/Cursor.app").exists()
|| app("Cursor/User").exists()
}
"windsurf" => {
h(".codeium").exists()
|| PathBuf::from("/Applications/Windsurf.app").exists()
|| app("Windsurf/User").exists()
}
"gemini" => h(".gemini").exists() || cmd_exists("gemini"),
"zed" => {
PathBuf::from("/Applications/Zed.app").exists()
|| h(".config/zed").exists()
|| cmd_exists("zed")
}
"continue" => h(".continue").exists() || app("Code/User").exists(),
"cline" | "roo" | "copilot" => app("Code/User").exists(),
"antigravity" => h(".gemini/antigravity").exists() || app("Antigravity/User").exists(),
"amazonq" => h(".aws/amazonq").exists() || cmd_exists("q"),
"kiro" => h(".kiro").exists() || cmd_exists("kiro"),
"codex" => cmd_exists("codex") || h(".codex").exists(),
"trae" => PathBuf::from("/Applications/Trae.app").exists() || app("Trae/User").exists(),
"aider" => cmd_exists("aider") || h(".aider.conf.yml").exists(),
"goose" => cmd_exists("goose") || h(".config/goose").exists(),
"jetbrains" => {
#[cfg(target_os = "macos")]
{
let jb = PathBuf::from(&home).join("Library/Application Support/JetBrains");
jb.exists()
}
#[cfg(not(target_os = "macos"))]
{
h(".config/JetBrains").exists()
}
}
"opencode" => {
cmd_exists("opencode") || h(".opencode").exists() || h(".config/opencode").exists()
}
"pear" => PathBuf::from("/Applications/PearAI.app").exists() || app("PearAI/User").exists(),
"void" => PathBuf::from("/Applications/Void.app").exists() || app("Void/User").exists(),
"neovim" => cmd_exists("nvim") || h(".config/nvim").exists(),
"amp" => cmd_exists("amp") || h(".config/amp").exists(),
"fish" => cmd_exists("fish") || h(".config/fish").exists(),
"zsh" => h(".zshrc").exists(),
"bash" => h(".bashrc").exists() || h(".bash_profile").exists(),
_ => false,
}
}
fn cmd_exists(cmd: &str) -> bool {
std::process::Command::new("which")
.arg(cmd)
.output()
.is_ok_and(|o| o.status.success())
}
fn install_agent(agent: &str) -> Result<()> {
match agent {
"claude" => install_claude(),
"cursor" => install_cursor(),
"windsurf" => install_windsurf(),
"gemini" => install_gemini(),
"zed" => install_zed(),
"continue" => install_continue(),
"cline" | "roo" => install_vscode_extension(agent),
"copilot" => install_vscode_extension("copilot"),
"antigravity" => install_antigravity(),
"amazonq" => install_amazonq(),
"kiro" => install_kiro(),
"codex" => install_codex(),
"trae" => install_trae(),
"aider" => install_aider(),
"goose" => install_goose(),
"jetbrains" => install_jetbrains(),
"opencode" => install_opencode(),
"pear" => install_pear(),
"void" => install_void(),
"amp" => install_amp(),
"neovim" => install_neovim(),
"fish" => install_shell("fish"),
"zsh" => install_shell("zsh"),
"bash" => install_shell("bash"),
other => {
eprintln!("bctx init: unknown agent '{other}'");
eprintln!("Supported agents:");
for name in SUPPORTED_AGENTS {
eprintln!(" {name}");
}
std::process::exit(1);
}
}
}
fn install_claude() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
let claude_dir = PathBuf::from(&home).join(".claude");
std::fs::create_dir_all(&claude_dir)?;
let claude_json = PathBuf::from(&home).join(".claude.json");
if claude_json.exists() {
let raw = std::fs::read_to_string(&claude_json)?;
let mut cfg: serde_json::Value =
serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));
cfg["mcpServers"]["bctx"] = serde_json::json!({
"type": "stdio",
"command": bctx_bin,
"args": ["mcp"],
"env": {}
});
std::fs::write(&claude_json, serde_json::to_string(&cfg)?)?;
println!(" ✓ ~/.claude.json (Claude CLI)");
}
#[cfg(target_os = "macos")]
write_vscode_mcp(&home, "Code/User", &bctx_bin, "Claude Code VSCode")?;
let hook_path = claude_dir.join("bctx-hook.sh");
std::fs::write(&hook_path, HOOK_SCRIPT)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
}
println!(" ✓ hook script → {}", hook_path.display());
let settings_path = claude_dir.join("settings.json");
let raw = if settings_path.exists() {
std::fs::read_to_string(&settings_path)?
} else {
"{}".to_string()
};
let mut settings: serde_json::Value =
serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));
let hook_entry = serde_json::json!({
"matcher": "Bash",
"hooks": [{ "type": "command", "command": hook_path.to_string_lossy() }]
});
let already = settings["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"))
})
});
if !already {
if let Some(arr) = settings["hooks"]["PreToolUse"].as_array_mut() {
arr.push(hook_entry);
} else {
settings["hooks"]["PreToolUse"] = serde_json::json!([hook_entry]);
}
std::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
println!(" ✓ settings.json hook registered");
}
install_claude_rules(&claude_dir)?;
println!(" → Restart Claude Code to activate.");
println!(" → To bypass in a project: touch .bctx-bypass");
Ok(())
}
fn install_claude_rules(claude_dir: &std::path::Path) -> Result<()> {
let claude_md = claude_dir.join("CLAUDE.md");
if !claude_md.exists() {
std::fs::write(
&claude_md,
"## bctx — Context Runtime\n\nbctx is our tool. \
Always use bctx MCP skills through the `bctx` MCP server.\n\n\
Full rules: @rules/bctx.md\n",
)?;
println!(" ✓ CLAUDE.md written");
}
let rules_dir = claude_dir.join("rules");
std::fs::create_dir_all(&rules_dir)?;
let rules_file = rules_dir.join("bctx.md");
let needs_update = if rules_file.exists() {
!std::fs::read_to_string(&rules_file)
.unwrap_or_default()
.contains("parallax")
} else {
true
};
if needs_update {
std::fs::write(&rules_file, BCTX_RULES_MD)?;
println!(" ✓ rules/bctx.md written (41 skills)");
}
Ok(())
}
fn install_cursor() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
write_mcp_servers(&home, ".cursor/mcp.json", &bctx_bin, "Cursor (global)")?;
#[cfg(target_os = "macos")]
write_vscode_mcp(&home, "Cursor/User", &bctx_bin, "Cursor VSCode")?;
println!(" → Restart Cursor to activate.");
Ok(())
}
fn install_windsurf() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
write_mcp_servers(
&home,
".codeium/windsurf/mcp_config.json",
&bctx_bin,
"Windsurf (global)",
)?;
#[cfg(target_os = "macos")]
write_vscode_mcp(&home, "Windsurf/User", &bctx_bin, "Windsurf VSCode")?;
println!(" → Restart Windsurf to activate.");
Ok(())
}
fn install_gemini() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
let gemini_dir = PathBuf::from(&home).join(".gemini");
std::fs::create_dir_all(&gemini_dir)?;
let settings_path = gemini_dir.join("settings.json");
let raw = if settings_path.exists() {
std::fs::read_to_string(&settings_path)?
} else {
"{}".to_string()
};
let mut cfg: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));
cfg["mcpServers"]["bctx"] = serde_json::json!({
"command": bctx_bin,
"args": ["mcp"],
"trust": true
});
std::fs::write(&settings_path, serde_json::to_string_pretty(&cfg)?)?;
println!(" ✓ ~/.gemini/settings.json");
println!(" → Restart Gemini CLI to activate.");
Ok(())
}
fn install_zed() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
let zed_dir = PathBuf::from(&home).join(".config/zed");
std::fs::create_dir_all(&zed_dir)?;
let settings_path = zed_dir.join("settings.json");
let raw = if settings_path.exists() {
std::fs::read_to_string(&settings_path)?
} else {
"{}".to_string()
};
let mut cfg: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));
cfg["context_servers"]["bctx"] = serde_json::json!({
"command": { "path": bctx_bin, "args": ["mcp"] }
});
std::fs::write(&settings_path, serde_json::to_string_pretty(&cfg)?)?;
println!(" ✓ ~/.config/zed/settings.json");
println!(" → Restart Zed to activate.");
Ok(())
}
fn install_continue() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
let continue_dir = PathBuf::from(&home).join(".continue");
std::fs::create_dir_all(&continue_dir)?;
let config_path = continue_dir.join("config.json");
let raw = if config_path.exists() {
std::fs::read_to_string(&config_path)?
} else {
"{}".to_string()
};
let mut cfg: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));
let entry = serde_json::json!({
"name": "bctx",
"command": bctx_bin,
"args": ["mcp"]
});
match cfg["mcpServers"].as_array_mut() {
Some(arr) => {
arr.retain(|e| e.get("name").and_then(|v| v.as_str()) != Some("bctx"));
arr.push(entry);
}
None => {
cfg["mcpServers"] = serde_json::json!([entry]);
}
}
std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?)?;
println!(" ✓ ~/.continue/config.json");
println!(" → Reload Continue extension to activate.");
Ok(())
}
fn install_vscode_extension(label: &str) -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
#[cfg(target_os = "macos")]
write_vscode_mcp(&home, "Code/User", &bctx_bin, label)?;
#[cfg(not(target_os = "macos"))]
{
let path = PathBuf::from(&home).join(".config/Code/User/mcp.json");
if let Some(parent) = path.parent() {
if parent.exists() {
write_vscode_mcp_path(&path, &bctx_bin, label)?;
}
}
}
println!(" → Reload VSCode window (Cmd+Shift+P → Developer: Reload Window) to activate.");
Ok(())
}
fn install_antigravity() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
write_mcp_servers(
&home,
".gemini/antigravity/mcp_config.json",
&bctx_bin,
"Antigravity (global)",
)?;
#[cfg(target_os = "macos")]
write_vscode_mcp(&home, "Antigravity/User", &bctx_bin, "Antigravity VSCode")?;
println!(" → Restart Antigravity to activate.");
Ok(())
}
fn install_amazonq() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
write_mcp_servers(&home, ".aws/amazonq/mcp.json", &bctx_bin, "Amazon Q")?;
println!(" → Restart Amazon Q to activate.");
Ok(())
}
fn install_kiro() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
write_mcp_servers(&home, ".kiro/settings/mcp.json", &bctx_bin, "AWS Kiro")?;
println!(" → Restart Kiro to activate.");
Ok(())
}
fn install_codex() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
write_mcp_servers(&home, ".codex/mcp.json", &bctx_bin, "OpenAI Codex")?;
println!(" → Restart Codex CLI to activate.");
Ok(())
}
fn install_trae() -> Result<()> {
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
write_vscode_mcp(&home, "Trae/User", &bctx_bin, "Trae VSCode")?;
}
println!(" → Restart Trae to activate.");
Ok(())
}
fn install_aider() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
let conf_path = PathBuf::from(&home).join(".aider.conf.yml");
let existing = if conf_path.exists() {
std::fs::read_to_string(&conf_path)?
} else {
String::new()
};
if existing.contains("# bctx") {
println!(" (bctx aider hook already present — skipped)");
} else {
let snippet = format!(
"\n# bctx — route git/bash through the context-aware lens pipeline\n\
map-tokens: true\n\
verify-ssl: true\n\
# Run: bctx init --agent aider to refresh this snippet\n\
# bctx binary: {bctx_bin}\n"
);
let mut content = existing;
content.push_str(&snippet);
std::fs::write(&conf_path, content)?;
println!(" ✓ ~/.aider.conf.yml (Aider CLI)");
}
let wrapper_dir = PathBuf::from(&home).join(".bctx");
std::fs::create_dir_all(&wrapper_dir)?;
let wrapper_path = wrapper_dir.join("aider-hook.sh");
std::fs::write(
&wrapper_path,
format!(
"#!/usr/bin/env bash\n\
# bctx wrapper for Aider shell commands\n\
exec {bctx_bin} \"$@\"\n"
),
)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&wrapper_path, std::fs::Permissions::from_mode(0o755))?;
}
println!(" ✓ ~/.bctx/aider-hook.sh");
println!(" → In Aider: /run bctx git log --oneline -20");
Ok(())
}
fn install_goose() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
let goose_dir = PathBuf::from(&home).join(".config/goose");
std::fs::create_dir_all(&goose_dir)?;
let config_path = goose_dir.join("config.yaml");
let existing = if config_path.exists() {
std::fs::read_to_string(&config_path)?
} else {
String::new()
};
if existing.contains("bctx") {
println!(" (bctx goose config already present — skipped)");
} else {
let snippet = format!(
"{existing}\
\n# bctx MCP server — added by `bctx init --agent goose`\n\
extensions:\n \
mcp:\n \
type: stdio\n \
servers:\n \
bctx:\n \
cmd: {bctx_bin}\n \
args:\n \
- mcp\n"
);
std::fs::write(&config_path, snippet)?;
println!(" ✓ ~/.config/goose/config.yaml");
}
println!(" → Restart Goose to activate.");
Ok(())
}
fn install_jetbrains() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
#[cfg(target_os = "macos")]
let jb_root = PathBuf::from(&home).join("Library/Application Support/JetBrains");
#[cfg(not(target_os = "macos"))]
let jb_root = PathBuf::from(&home).join(".config/JetBrains");
let Ok(rd) = std::fs::read_dir(&jb_root) else {
println!(" (JetBrains settings directory not found — run from an IDE first)");
return Ok(());
};
let mut count = 0u32;
for entry in rd.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let mcp_path = path.join("mcp.json");
let raw = if mcp_path.exists() {
std::fs::read_to_string(&mcp_path)?
} else {
"{}".to_string()
};
let mut cfg: serde_json::Value =
serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));
cfg["mcpServers"]["bctx"] = serde_json::json!({
"command": bctx_bin,
"args": ["mcp"]
});
std::fs::write(&mcp_path, serde_json::to_string_pretty(&cfg)?)?;
println!(
" ✓ {} mcp.json",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("JetBrains IDE")
);
count += 1;
}
if count == 0 {
println!(" (no JetBrains IDEs found in {}) ", jb_root.display());
}
println!(" → Restart your JetBrains IDE to activate.");
Ok(())
}
fn install_opencode() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
write_mcp_servers(&home, ".opencode/config.json", &bctx_bin, "OpenCode")?;
println!(" → Restart OpenCode to activate.");
Ok(())
}
fn install_amp() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
let settings_path = PathBuf::from(&home).join(".config/amp/settings.json");
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent)?;
}
let raw = if settings_path.exists() {
std::fs::read_to_string(&settings_path)?
} else {
"{}".to_string()
};
let mut cfg: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));
cfg["amp.mcpServers"]["bctx"] = serde_json::json!({
"command": bctx_bin,
"args": ["mcp"]
});
std::fs::write(&settings_path, serde_json::to_string_pretty(&cfg)?)?;
println!(" ✓ ~/.config/amp/settings.json");
println!(" → Restart Amp to activate.");
Ok(())
}
fn install_pear() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
#[cfg(target_os = "macos")]
write_vscode_mcp(&home, "PearAI/User", &bctx_bin, "PearAI")?;
#[cfg(not(target_os = "macos"))]
{
let path = PathBuf::from(&home).join(".config/PearAI/User/mcp.json");
if let Some(parent) = path.parent() {
if parent.exists() {
write_vscode_mcp_path(&path, &bctx_bin, "PearAI")?;
}
}
}
println!(" → Restart PearAI to activate.");
Ok(())
}
fn install_void() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
#[cfg(target_os = "macos")]
write_vscode_mcp(&home, "Void/User", &bctx_bin, "Void")?;
#[cfg(not(target_os = "macos"))]
{
let path = PathBuf::from(&home).join(".config/Void/User/mcp.json");
if let Some(parent) = path.parent() {
if parent.exists() {
write_vscode_mcp_path(&path, &bctx_bin, "Void")?;
}
}
}
println!(" → Restart Void to activate.");
Ok(())
}
fn install_neovim() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let bctx_bin = which_bctx();
let nvim_dir = PathBuf::from(&home).join(".config/nvim/lua");
std::fs::create_dir_all(&nvim_dir)?;
let snippet_path = nvim_dir.join("bctx_mcp.lua");
if snippet_path.exists() {
println!(" (~/.config/nvim/lua/bctx_mcp.lua already exists — skipped)");
} else {
std::fs::write(
&snippet_path,
format!(
"-- bctx MCP server — generated by `bctx init --agent neovim`\n\
-- Add to your avante.nvim or mcphub.nvim setup:\n\
-- require('avante').setup({{ mcp = require('bctx_mcp') }})\n\
-- OR: require('mcphub').add_servers(require('bctx_mcp').servers)\n\
return {{\n \
servers = {{\n \
bctx = {{\n \
cmd = {{ '{bctx_bin}', 'mcp' }},\n \
name = 'bctx',\n \
}},\n \
}},\n\
}}\n"
),
)?;
println!(" ✓ ~/.config/nvim/lua/bctx_mcp.lua");
}
println!(" → Add to your Neovim config:");
println!(" -- avante.nvim:");
println!(" require('avante').setup({{ mcp = require('bctx_mcp') }})");
println!(" -- mcphub.nvim:");
println!(" require('mcphub').add_servers(require('bctx_mcp').servers)");
Ok(())
}
fn install_shell(shell: &str) -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let rc_file = match shell {
"zsh" => PathBuf::from(&home).join(".zshrc"),
_ => {
let p = PathBuf::from(&home).join(".bashrc");
if p.exists() {
p
} else {
PathBuf::from(&home).join(".bash_profile")
}
}
};
if shell == "fish" {
return install_fish_shell();
}
let snippet = concat!(
"\n# bctx — intercept git/cargo/etc. through the context-aware lens pipeline\n",
"git() { bctx git \"$@\"; }\n",
"cargo() { bctx cargo \"$@\"; }\n",
"npm() { bctx npm \"$@\"; }\n",
"pnpm() { bctx pnpm \"$@\"; }\n",
"python3() { bctx python3 \"$@\"; }\n",
"kubectl() { bctx kubectl \"$@\"; }\n",
"docker() { bctx docker \"$@\"; }\n",
"terraform() { bctx terraform \"$@\"; }\n",
"aws() { bctx aws \"$@\"; }\n",
"# bctx end\n",
);
let current = std::fs::read_to_string(&rc_file).unwrap_or_default();
if current.contains("# bctx —") {
if current.contains("eval \"$_bctx_cmd()") || current.contains("for _bctx_cmd in") {
let updated = replace_shell_block(¤t, snippet);
std::fs::write(&rc_file, updated)?;
println!(" ✓ {} hook migrated to current format", rc_file.display());
println!(" → Run: source {}", rc_file.display());
} else {
println!(" (bctx shell hook already up to date — skipped)");
}
return Ok(());
}
let mut content = current;
content.push_str(snippet);
std::fs::write(&rc_file, content)?;
println!(" ✓ {} hook appended", rc_file.display());
println!(" → Run: source {}", rc_file.display());
Ok(())
}
fn replace_shell_block(content: &str, new_snippet: &str) -> String {
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');
}
output.push_str(new_snippet);
output
}
fn install_fish_shell() -> Result<()> {
let home = std::env::var("HOME").context("HOME not set")?;
let conf_dir = PathBuf::from(&home).join(".config/fish/conf.d");
std::fs::create_dir_all(&conf_dir)?;
let fish_path = conf_dir.join("bctx.fish");
if fish_path.exists() {
println!(" (bctx fish config already present — skipped)");
return Ok(());
}
std::fs::write(
&fish_path,
"# bctx — intercept git/cargo/etc. through the context-aware lens pipeline\n\
for _bctx_cmd in git cargo npm pnpm python3 kubectl docker terraform aws\n \
function $_bctx_cmd --wraps $_bctx_cmd --description \"bctx $_bctx_cmd\"\n \
command bctx $_bctx_cmd $argv\n \
end\n\
end\n",
)?;
println!(" ✓ ~/.config/fish/conf.d/bctx.fish");
println!(" → Open a new fish shell to activate.");
Ok(())
}
fn write_mcp_servers(home: &str, rel: &str, bctx_bin: &str, label: &str) -> Result<()> {
let path = PathBuf::from(home).join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let raw = if path.exists() {
std::fs::read_to_string(&path)?
} else {
"{}".to_string()
};
let mut cfg: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));
cfg["mcpServers"]["bctx"] = serde_json::json!({
"command": bctx_bin,
"args": ["mcp"]
});
std::fs::write(&path, serde_json::to_string_pretty(&cfg)?)?;
println!(" ✓ {} → {}", label, path.display());
Ok(())
}
#[cfg(target_os = "macos")]
fn write_vscode_mcp(home: &str, app_subdir: &str, bctx_bin: &str, label: &str) -> Result<()> {
let path = PathBuf::from(home)
.join("Library/Application Support")
.join(app_subdir)
.join("mcp.json");
if let Some(parent) = path.parent() {
if !parent.exists() {
return Ok(()); }
}
write_vscode_mcp_path(&path, bctx_bin, label)
}
fn write_vscode_mcp_path(path: &std::path::Path, bctx_bin: &str, label: &str) -> Result<()> {
let raw = if path.exists() {
std::fs::read_to_string(path)?
} else {
"{}".to_string()
};
let mut cfg: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));
cfg["servers"]["bctx"] = serde_json::json!({
"type": "stdio",
"command": bctx_bin,
"args": ["mcp"]
});
std::fs::write(path, serde_json::to_string_pretty(&cfg)?)?;
println!(" ✓ {} → {}", label, path.display());
Ok(())
}
fn which_bctx() -> String {
if let Ok(exe) = std::env::current_exe() {
let s = exe.to_string_lossy().to_string();
if !s.contains("/deps/")
&& !s.contains("target/debug")
&& !s.contains("target/release/build")
{
return s;
}
}
if let Ok(out) = std::process::Command::new("which").arg("bctx").output() {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !s.is_empty() && std::path::Path::new(&s).exists() {
return s;
}
}
let home = std::env::var("HOME").unwrap_or_default();
for candidate in [
format!("{home}/.cargo/bin/bctx"),
format!("{home}/.local/bin/bctx"),
"/usr/local/bin/bctx".to_string(),
"/opt/homebrew/bin/bctx".to_string(),
] {
if std::path::Path::new(&candidate).exists() {
return candidate;
}
}
"bctx".to_string()
}
const HOOK_SCRIPT: &str = r#"#!/usr/bin/env bash
# bctx Claude Code pre-tool-use hook
# Rewrites interceptable commands to go through the bctx lens pipeline.
# Bypass: BCTX_BYPASS=1 env var, or .bctx-bypass file in CWD or any parent.
set -euo pipefail
INPUT=$(cat)
[ "${BCTX_BYPASS:-0}" = "1" ] && { printf '%s' "$INPUT"; exit 0; }
dir="$PWD"
while [ "$dir" != "/" ]; do
[ -f "$dir/.bctx-bypass" ] && { printf '%s' "$INPUT"; exit 0; }
dir=$(dirname "$dir")
done
COMMAND=$(python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('tool_input', {}).get('command', ''))
" <<< "$INPUT" 2>/dev/null || true)
[ -z "$COMMAND" ] && { printf '%s' "$INPUT"; exit 0; }
case "$COMMAND" in
bctx\ *) printf '%s' "$INPUT"; exit 0 ;;
esac
PROGRAMS="git cargo npm pnpm npx python3 python pytest jest vitest kubectl docker terraform aws"
for prog in $PROGRAMS; do
case "$COMMAND" in
"$prog "*|"$prog")
python3 -c "
import sys, json
d = json.load(sys.stdin)
d['tool_input']['command'] = 'bctx ' + d['tool_input']['command']
print(json.dumps(d))
" <<< "$INPUT" 2>/dev/null || printf '%s' "$INPUT"
exit 0
;;
esac
done
printf '%s' "$INPUT"
exit 0
"#;
const BCTX_RULES_MD: &str = r#"# bctx — Context Engineering Layer
bctx is our context runtime for LLM coding agents. Use bctx MCP skills for intelligent
context operations. The bctx hook in settings.json automatically rewrites interceptable
shell commands (git, cargo, npm, kubectl, docker, terraform, aws, etc.) to route through
the bctx lens pipeline for token savings.
## Available MCP Skills (via `bctx mcp`)
### Core skills (original 15)
| Skill | Purpose |
|-------|---------|
| `sieve` | Filter raw file/shell output to task-relevant lines |
| `cartograph` | Scan directory → structured project map |
| `chisel` | Extract AST symbols from files in compact signature format |
| `sediment` | Persist key-value facts into Vault |
| `prism` | Full incremental AST parse + index of project directory |
| `compass` | Fuse BM25 + graph traversal + Vault into ranked code locations |
| `condenser` | Compress file/string via specified or auto-selected Lens stack |
| `archivist` | Query Vault across tiers for facts matching a query (read-only) |
| `scout` | Execute shell command with domain-aware output compression |
| `chronicler` | Generate structured narrative of session/PassageRun |
| `alchemist` | Transform noisy content → structured JSON |
| `sentinel` | Static security risk assessment of command/path/tool_call |
| `cartridge` | Package current context state into a portable bundle |
| `resonator` | Dense-vector semantic search over ProjectIndex |
| `surveyor` | Compute dependency topology: callers/callees, cycles |
### Extended skills (Wave 2 — 21 more)
| Skill | Purpose |
|-------|---------|
| `parallax` | Read multiple files in one call with per-file lens |
| `blueprint` | Emit compact structural outline of a file (headings + defs) |
| `pinpoint` | Extract a single named symbol from a file by name |
| `crossroads` | Extract API route definitions from files |
| `drift` | Return lines changed since a git ref, token-budgeted |
| `panorama` | High-level project overview: languages, entry points, key dirs |
| `ripple` | Impact analysis: files that depend on a given file |
| `meridian` | Snapshot current agent context: executions, vault facts |
| `forecast` | Predict which files the agent will need next |
| `unfold` | Restore a condensed block to its original content |
| `thermal` | Token heatmap: identify which file sections consume the most tokens |
| `echo` | Record compression feedback (good/bad) for adaptive tuning |
| `ledger` | Real-time cost estimate: tokens × model pricing → USD |
| `steward` | Session role management: coder/reviewer/debugger/ops/admin + budget |
| `dispatch` | Meta-tool: invoke any bctx skill by name via one stable schema |
| `arbiter` | Code review: structured findings from diff or file content |
| `diviner` | Infer agent intent from file access patterns and commands |
| `crucible` | Run compression benchmarks across all lenses, return savings table |
| `scanner` | Find files/symbols matching a query (fuzzy + content) |
| `relay_ctx` | Package and hand off agent context to another session |
| `witness` | Record and replay tool-call sequences for testing |
## Hook behavior
The bctx pre-tool-use hook rewrites commands like `git log` → `bctx git log` automatically.
Bypass with: `BCTX_BYPASS=1 <cmd>` or place a `.bctx-bypass` file in the repo root.
## Read modes (`bctx read --mode <mode>`)
`auto` · `full` · `map` · `signatures` · `diff` · `aggressive` · `entropy` · `task` · `reference` · `lines:N-M`
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_25_agents_in_supported_list() {
assert_eq!(SUPPORTED_AGENTS.len(), 25);
}
#[test]
fn all_agents_have_unique_names() {
let mut seen = std::collections::HashSet::new();
for name in SUPPORTED_AGENTS {
assert!(seen.insert(*name), "duplicate agent: {name}");
}
}
#[test]
fn new_wave2_agents_present() {
for name in &[
"aider",
"goose",
"jetbrains",
"opencode",
"pear",
"void",
"neovim",
"fish",
"amp",
] {
assert!(SUPPORTED_AGENTS.contains(name), "missing agent: {name}");
}
}
#[test]
fn original_agents_still_present() {
for name in &[
"claude",
"cursor",
"windsurf",
"gemini",
"zed",
"continue",
"cline",
"roo",
"copilot",
"antigravity",
"amazonq",
"kiro",
"codex",
"trae",
"zsh",
"bash",
] {
assert!(
SUPPORTED_AGENTS.contains(name),
"missing original agent: {name}"
);
}
}
#[test]
fn amp_install_writes_amp_mcp_servers_key() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let home = dir.path().to_str().unwrap().to_string();
let amp_dir = dir.path().join(".config/amp");
std::fs::create_dir_all(&_dir).unwrap();
let settings_path = amp_dir.join("settings.json");
let bctx_bin = "bctx";
let raw = "{}".to_string();
let mut cfg: serde_json::Value =
serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));
cfg["amp.mcpServers"]["bctx"] = serde_json::json!({
"command": bctx_bin,
"args": ["mcp"]
});
std::fs::write(&settings_path, serde_json::to_string_pretty(&cfg).unwrap()).unwrap();
let written = std::fs::read_to_string(&settings_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
assert!(
parsed["amp.mcpServers"]["bctx"]["command"]
.as_str()
.is_some(),
"amp.mcpServers.bctx.command missing in: {written}"
);
assert_eq!(parsed["amp.mcpServers"]["bctx"]["args"][0], "mcp");
assert!(
parsed["mcpServers"].is_null(),
"must use amp.mcpServers, not mcpServers"
);
drop(home); }
#[test]
fn amp_install_merges_with_existing_config() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let amp_dir = dir.path().join(".config/amp");
std::fs::create_dir_all(&_dir).unwrap();
let settings_path = amp_dir.join("settings.json");
std::fs::write(
&settings_path,
r#"{"some.other.setting": true, "amp.mcpServers": {"existing": {"command": "other"}}}"#,
)
.unwrap();
let raw = std::fs::read_to_string(&settings_path).unwrap();
let mut cfg: serde_json::Value =
serde_json::from_str(&raw).unwrap_or(serde_json::json!({}));
cfg["amp.mcpServers"]["bctx"] = serde_json::json!({
"command": "bctx",
"args": ["mcp"]
});
std::fs::write(&settings_path, serde_json::to_string_pretty(&cfg).unwrap()).unwrap();
let written = std::fs::read_to_string(&settings_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(parsed["some.other.setting"], true);
assert_eq!(
parsed["amp.mcpServers"]["existing"]["command"].as_str(),
Some("other")
);
assert_eq!(
parsed["amp.mcpServers"]["bctx"]["command"].as_str(),
Some("bctx")
);
}
#[test]
fn bctx_rules_md_contains_all_36_skills() {
let skills = [
"sieve",
"cartograph",
"chisel",
"sediment",
"prism",
"compass",
"condenser",
"archivist",
"scout",
"chronicler",
"alchemist",
"sentinel",
"cartridge",
"resonator",
"surveyor",
"parallax",
"blueprint",
"pinpoint",
"crossroads",
"drift",
"panorama",
"ripple",
"meridian",
"forecast",
"unfold",
"thermal",
"echo",
"ledger",
"steward",
"dispatch",
"arbiter",
"diviner",
"crucible",
"scanner",
"relay_ctx",
"witness",
];
for skill in &skills {
assert!(
BCTX_RULES_MD.contains(skill),
"BCTX_RULES_MD missing skill: {skill}"
);
}
}
#[test]
fn bctx_rules_md_contains_read_modes() {
assert!(BCTX_RULES_MD.contains("Read modes"));
assert!(BCTX_RULES_MD.contains("signatures"));
assert!(BCTX_RULES_MD.contains("aggressive"));
}
#[test]
fn hook_script_contains_intercepted_programs() {
for prog in &["git", "cargo", "npm", "kubectl", "docker", "terraform"] {
assert!(HOOK_SCRIPT.contains(prog), "hook missing: {prog}");
}
}
}