use anyhow::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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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)");
}
let mcp_json_path = claude_dir.join("mcp.json");
{
let raw = if mcp_json_path.exists() {
std::fs::read_to_string(&mcp_json_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_json_path, serde_json::to_string_pretty(&cfg)?)?;
println!(" ✓ ~/.claude/mcp.json");
}
#[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]);
}
println!(" ✓ settings.json hook registered");
}
let all_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",
"flux",
"harvest",
"pathfinder",
"render",
"scribe",
];
let existing_allow = settings["permissions"]["allow"]
.as_array()
.cloned()
.unwrap_or_default();
let mut allow: Vec<serde_json::Value> = existing_allow;
for skill in &all_skills {
let entry = serde_json::Value::String(format!("mcp__bctx__{skill}"));
if !allow.contains(&entry) {
allow.push(entry);
}
}
settings["permissions"]["allow"] = serde_json::Value::Array(allow);
std::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
println!(" ✓ settings.json permissions updated (41 skills)");
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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 = super::home_dir();
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",
"npm() {\n",
" case \"${1:-}\" in\n",
" start) command npm \"$@\" ;;\n",
" run) case \"${2:-}\" in\n",
" dev|dev:*|*:dev|watch|watch:*|*:watch|*-watch|\\\n",
" serve|serve:*|start|start:*|preview|storybook)\n",
" command npm \"$@\" ;;\n",
" *) bctx npm \"$@\" ;;\n",
" esac ;;\n",
" *) bctx npm \"$@\" ;;\n",
" esac\n",
"}\n",
"pnpm() {\n",
" case \"${1:-}\" in\n",
" start) command pnpm \"$@\" ;;\n",
" run) case \"${2:-}\" in\n",
" dev|dev:*|*:dev|watch|watch:*|*:watch|*-watch|\\\n",
" serve|serve:*|start|start:*|preview|storybook)\n",
" command pnpm \"$@\" ;;\n",
" *) bctx pnpm \"$@\" ;;\n",
" esac ;;\n",
" *) bctx pnpm \"$@\" ;;\n",
" esac\n",
"}\n",
"cargo() {\n",
" case \"${1:-}\" in\n",
" watch) command cargo \"$@\" ;;\n",
" *) bctx cargo \"$@\" ;;\n",
" esac\n",
"}\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 —") {
let needs_migration = current.contains("eval \"$_bctx_cmd()\"")
|| current.contains("for _bctx_cmd in")
|| current.contains("npm() { bctx npm");
if needs_migration {
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 = super::home_dir();
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");
let fish_hook = "\
# bctx — intercept git/cargo/etc. through the context-aware lens pipeline\n\
\n\
function git --wraps git --description \"bctx git\"\n\
command bctx git $argv\n\
end\n\
\n\
function npm --wraps npm --description \"bctx npm\"\n\
switch \"$argv[1]\"\n\
case start\n\
command npm $argv\n\
case run\n\
switch \"$argv[2]\"\n\
case dev 'dev:*' '*:dev' watch 'watch:*' '*:watch' '*-watch' \\\n\
serve 'serve:*' start 'start:*' preview storybook\n\
command npm $argv\n\
case '*'\n\
command bctx npm $argv\n\
end\n\
case '*'\n\
command bctx npm $argv\n\
end\n\
end\n\
\n\
function pnpm --wraps pnpm --description \"bctx pnpm\"\n\
switch \"$argv[1]\"\n\
case start\n\
command pnpm $argv\n\
case run\n\
switch \"$argv[2]\"\n\
case dev 'dev:*' '*:dev' watch 'watch:*' '*:watch' '*-watch' \\\n\
serve 'serve:*' start 'start:*' preview storybook\n\
command pnpm $argv\n\
case '*'\n\
command bctx pnpm $argv\n\
end\n\
case '*'\n\
command bctx pnpm $argv\n\
end\n\
end\n\
\n\
function cargo --wraps cargo --description \"bctx cargo\"\n\
switch \"$argv[1]\"\n\
case watch\n\
command cargo $argv\n\
case '*'\n\
command bctx cargo $argv\n\
end\n\
end\n\
\n\
function docker --wraps docker --description \"bctx docker\"\n\
switch \"$argv[1]\"\n\
case attach exec stats\n\
command docker $argv\n\
case logs\n\
if contains -- -f $argv\n\
command docker $argv\n\
else\n\
command bctx docker $argv\n\
end\n\
case '*'\n\
command bctx docker $argv\n\
end\n\
end\n\
\n\
function kubectl --wraps kubectl --description \"bctx kubectl\"\n\
switch \"$argv[1]\"\n\
case exec attach port-forward proxy\n\
command kubectl $argv\n\
case logs\n\
if contains -- -f $argv\n\
command kubectl $argv\n\
else\n\
command bctx kubectl $argv\n\
end\n\
case '*'\n\
command bctx kubectl $argv\n\
end\n\
end\n\
\n\
function python3 --wraps python3 --description \"bctx python3\"\n\
command bctx python3 $argv\n\
end\n\
function terraform --wraps terraform --description \"bctx terraform\"\n\
command bctx terraform $argv\n\
end\n\
function aws --wraps aws --description \"bctx aws\"\n\
command bctx aws $argv\n\
end\n\
# bctx end\n";
std::fs::write(&fish_path, fish_hook)?;
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")
&& !is_npm_wrapper(&s)
{
return s;
}
}
let home = super::home_dir();
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;
}
}
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() && !is_npm_wrapper(&s) {
return s;
}
}
"bctx".to_string()
}
fn is_npm_wrapper(path: &str) -> bool {
path.contains("node_modules") || path.ends_with(".js") || path.ends_with("bctx-native")
}
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
# Streaming / interactive commands must never be routed through bctx — they produce
# continuous output that would be invisible until process exit (buffered by .output()).
case "$COMMAND" in
npm\ start|npm\ start\ *|\
npm\ run\ dev|npm\ run\ dev:*|npm\ run\ *:dev|\
npm\ run\ watch|npm\ run\ watch:*|npm\ run\ *:watch|npm\ run\ *-watch|\
npm\ run\ serve|npm\ run\ serve:*|npm\ run\ start|npm\ run\ start:*|\
npm\ run\ preview|npm\ run\ storybook|\
pnpm\ start|pnpm\ start\ *|\
pnpm\ run\ dev|pnpm\ run\ dev:*|pnpm\ run\ *:dev|\
pnpm\ run\ watch|pnpm\ run\ watch:*|pnpm\ run\ *:watch|pnpm\ run\ *-watch|\
pnpm\ run\ serve|pnpm\ run\ serve:*|pnpm\ run\ start|pnpm\ run\ start:*|\
pnpm\ run\ preview|pnpm\ run\ storybook|\
cargo\ watch|cargo\ watch\ *|\
docker\ attach\ *|docker\ exec\ *|docker\ logs\ -f*|docker\ stats*|\
kubectl\ exec\ *|kubectl\ attach\ *|kubectl\ port-forward\ *|kubectl\ proxy*|\
kubectl\ logs\ -f*)
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}");
}
}
}