use super::agent_config;
use anyhow::Result;
use std::path::PathBuf;
pub fn handle(agent: Option<String>) -> Result<()> {
agent_config::reset_manual_steps();
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)?;
}
}
add_owner(SHELL_HOOK_OWNERS, name)?;
}
None => {
let mut installed = 0u32;
let mut failed = Vec::new();
for name in SUPPORTED_AGENTS {
if is_installed(name) {
println!("Detected: {name}");
match install_agent(name) {
Ok(()) => {
if matches!(*name, "zsh" | "bash" | "fish") {
add_owner(SHELL_HOOK_OWNERS, name)?;
}
installed += 1;
}
Err(e) => {
eprintln!(" ✗ {name}: {e:#}");
failed.push(*name);
}
}
println!();
}
}
if !failed.is_empty() {
anyhow::bail!("bctx init could not configure: {}", failed.join(", "));
}
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}");
}
}
}
}
let manual = agent_config::manual_steps();
if manual > 0 {
anyhow::bail!(
"{manual} config file(s) need a manual edit; everything else is set up (see the steps above)"
);
}
Ok(())
}
pub(crate) const SHELL_HOOK_OWNERS: &str = "shell-hook-agents";
pub(crate) const VSCODE_MCP_OWNERS: &str = "vscode-mcp-agents";
pub(crate) fn owners_path(list: &str) -> PathBuf {
PathBuf::from(super::home_dir()).join(".bctx").join(list)
}
pub(crate) fn read_owners(list: &str) -> Option<Vec<String>> {
let raw = std::fs::read_to_string(owners_path(list)).ok()?;
Some(
raw.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.collect(),
)
}
pub(crate) fn write_owners(list: &str, owners: &[String]) -> Result<()> {
let path = owners_path(list);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(
path,
owners.iter().map(|o| format!("{o}\n")).collect::<String>(),
)?;
Ok(())
}
fn add_owner(list: &str, name: &str) -> Result<()> {
let mut owners = read_owners(list).unwrap_or_default();
if !owners.iter().any(|o| o == name) {
owners.push(name.to_string());
write_owners(list, &owners)?;
}
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 —")
}
pub(crate) 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 entry = serde_json::json!({
"type": "stdio",
"command": bctx_bin,
"args": ["mcp"],
"env": {}
});
let add = serde_json::json!({ "mcpServers": { "bctx": entry.clone() } });
if agent_config::merge(
&claude_json,
&add,
|c| agent_config::has_bctx(c, "mcpServers"),
|c| agent_config::set_bctx(c, "mcpServers", entry),
agent_config::Style::Compact,
)? {
println!(" ✓ ~/.claude.json (Claude CLI)");
}
}
merge_bctx_entry(
&claude_dir.join("mcp.json"),
"mcpServers",
serde_json::json!({ "command": bctx_bin, "args": ["mcp"] }),
" ✓ ~/.claude/mcp.json",
)?;
#[cfg(target_os = "macos")]
{
write_vscode_mcp(&home, "Code/User", &bctx_bin, "Claude Code VSCode")?;
add_owner(VSCODE_MCP_OWNERS, "claude")?;
}
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 hook_entry = serde_json::json!({
"matcher": "Bash",
"hooks": [{ "type": "command", "command": hook_path.to_string_lossy() }]
});
let allow: Vec<serde_json::Value> = CLAUDE_SKILLS
.iter()
.map(|skill| serde_json::Value::String(format!("mcp__bctx__{skill}")))
.collect();
let add = serde_json::json!({
"hooks": { "PreToolUse": [hook_entry.clone()] },
"permissions": { "allow": allow.clone() }
});
let apply = |cfg: &mut serde_json::Value| -> bool {
let Some(pre) = agent_config::object_at(cfg, "hooks")
.and_then(|hooks| agent_config::array_at(hooks, "PreToolUse"))
else {
return false;
};
if !pre.iter().any(is_bctx_hook) {
pre.push(hook_entry);
}
let Some(existing) = agent_config::object_at(cfg, "permissions")
.and_then(|perms| agent_config::array_at(perms, "allow"))
else {
return false;
};
for entry in allow {
if !existing.contains(&entry) {
existing.push(entry);
}
}
true
};
let present = |cfg: &serde_json::Value| {
cfg["hooks"]["PreToolUse"]
.as_array()
.is_some_and(|arr| arr.iter().any(is_bctx_hook))
};
if agent_config::merge(
&settings_path,
&add,
present,
apply,
agent_config::Style::Pretty,
)? {
println!(" ✓ settings.json hook + permissions (41 skills)");
}
install_claude_rules(&claude_dir)?;
println!(" → Restart Claude Code to activate.");
println!(" → To bypass in a project: touch .bctx-bypass");
Ok(())
}
fn is_bctx_hook(entry: &serde_json::Value) -> bool {
entry["hooks"]
.as_array()
.and_then(|hs| hs.first())
.and_then(|h| h["command"].as_str())
.is_some_and(|c| c.contains("bctx-hook"))
}
const CLAUDE_SKILLS: [&str; 41] = [
"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",
];
pub(crate) const CLAUDE_MD_STUB: &str =
"## bctx — Context Runtime\n\nbctx is our tool. Follow the MANDATORY tool \
mapping in the Golden Workflow: prefer `blueprint`/`chisel`/`parallax` over \
native Read, `compass`/`scanner` over grep, and run the memory ritual \
(`archivist` at task start, `sediment` on discovery).\n\n\
Full rules: @rules/bctx.md\n";
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, CLAUDE_MD_STUB)?;
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("Golden Workflow")
} else {
true
};
if needs_update {
std::fs::write(&rules_file, BCTX_RULES_MD)?;
println!(" ✓ rules/bctx.md written (Golden Workflow + 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 entry = serde_json::json!({ "command": bctx_bin, "args": ["mcp"], "trust": true });
merge_bctx_entry(
&settings_path,
"mcpServers",
entry,
" ✓ ~/.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 entry = serde_json::json!({ "command": { "path": bctx_bin, "args": ["mcp"] } });
merge_bctx_entry(
&settings_path,
"context_servers",
entry,
" ✓ ~/.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 entry = serde_json::json!({
"name": "bctx",
"command": bctx_bin,
"args": ["mcp"]
});
let add = serde_json::json!({ "mcpServers": [entry.clone()] });
let is_bctx = |e: &serde_json::Value| e.get("name").and_then(|v| v.as_str()) == Some("bctx");
let present = |cfg: &serde_json::Value| {
cfg["mcpServers"]
.as_array()
.is_some_and(|arr| arr.iter().any(is_bctx))
};
let apply = |cfg: &mut serde_json::Value| match agent_config::array_at(cfg, "mcpServers") {
Some(arr) => {
arr.retain(|e| !is_bctx(e));
arr.push(entry);
true
}
None => false,
};
if agent_config::merge(
&config_path,
&add,
present,
apply,
agent_config::Style::Pretty,
)? {
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)?;
add_owner(VSCODE_MCP_OWNERS, 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)?;
add_owner(VSCODE_MCP_OWNERS, 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(())
}
pub(crate) const GOOSE_START: &str = "# bctx MCP server — added by `bctx init --agent goose`";
pub(crate) const GOOSE_END: &str = "# end of bctx MCP server";
fn goose_entry(bctx_bin: &str, indent: &str) -> Vec<String> {
[
GOOSE_START.to_string(),
"bctx:".to_string(),
" name: bctx".to_string(),
" type: stdio".to_string(),
format!(" cmd: {bctx_bin}"),
" args:".to_string(),
" - mcp".to_string(),
" enabled: true".to_string(),
" timeout: 300".to_string(),
GOOSE_END.to_string(),
]
.into_iter()
.map(|l| format!("{indent}{l}"))
.collect()
}
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)");
println!(" → Restart Goose to activate.");
return Ok(());
}
let lines: Vec<&str> = existing.lines().collect();
let updated = match lines.iter().position(|l| l.starts_with("extensions:")) {
None => {
let mut block = vec![GOOSE_START.to_string(), "extensions:".to_string()];
let entry = goose_entry(&bctx_bin, " ");
block.extend(entry[1..entry.len() - 1].iter().cloned());
block.push(GOOSE_END.to_string());
let mut out = existing.trim_end().to_string();
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(&block.join("\n"));
out.push('\n');
out
}
Some(i) if lines[i].trim_end() == "extensions:" => {
let indent: String = lines[i + 1..]
.iter()
.find(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
.map(|l| l.chars().take_while(|c| *c == ' ').collect())
.filter(|s: &String| !s.is_empty())
.unwrap_or_else(|| " ".to_string());
let mut out: Vec<String> = lines[..=i].iter().map(|l| l.to_string()).collect();
out.extend(goose_entry(&bctx_bin, &indent));
out.extend(lines[i + 1..].iter().map(|l| l.to_string()));
out.join("\n") + if existing.ends_with('\n') { "\n" } else { "" }
}
Some(_) => {
let mut snippet = vec!["extensions:".to_string()];
snippet.extend(goose_entry(&bctx_bin, " "));
agent_config::manual_text(
&config_path,
"has an `extensions:` key that is not a plain block map",
&snippet,
);
return Ok(());
}
};
std::fs::write(&config_path, updated)?;
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 entry = serde_json::json!({ "command": bctx_bin, "args": ["mcp"] });
let ide = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("JetBrains IDE");
merge_bctx_entry(
&mcp_path,
"mcpServers",
entry,
&format!(" ✓ {ide} mcp.json"),
)?;
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 entry = serde_json::json!({ "command": bctx_bin, "args": ["mcp"] });
merge_bctx_entry(
&settings_path,
"amp.mcpServers",
entry,
" ✓ ~/.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 merge_bctx_entry(
path: &std::path::Path,
key: &str,
entry: serde_json::Value,
done: &str,
) -> Result<()> {
let mut add = serde_json::Map::new();
add.insert(
key.to_string(),
serde_json::json!({ "bctx": entry.clone() }),
);
if agent_config::merge(
path,
&serde_json::Value::Object(add),
|c| agent_config::has_bctx(c, key),
|c| agent_config::set_bctx(c, key, entry),
agent_config::Style::Pretty,
)? {
println!("{done}");
}
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 entry = serde_json::json!({ "command": bctx_bin, "args": ["mcp"] });
merge_bctx_entry(
&path,
"mcpServers",
entry,
&format!(" ✓ {} → {}", 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 entry = serde_json::json!({ "type": "stdio", "command": bctx_bin, "args": ["mcp"] });
merge_bctx_entry(
path,
"servers",
entry,
&format!(" ✓ {} → {}", 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\ *|\
uvicorn\ *|gunicorn\ *|hypercorn\ *|daphne\ *|granian\ *|\
flask\ run*|fastapi\ dev*|fastapi\ run*|\
python\ -m\ uvicorn*|python3\ -m\ uvicorn*|\
python\ -m\ gunicorn*|python3\ -m\ gunicorn*|\
python\ -m\ hypercorn*|python3\ -m\ hypercorn*|\
python\ -m\ http.server*|python3\ -m\ http.server*|\
python\ *manage.py\ runserver*|python3\ *manage.py\ runserver*|\
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. It saves tokens two ways: (1) the
pre-tool-use hook automatically compresses shell command output, and (2) the MCP skills
below compress source files and persist memory. **The shell hook is automatic; the skills
are not — you must call them.** Most of the savings on a source codebase (Python, TS, Go,
etc.) come from the skills. Follow the Golden Workflow.
## Golden Workflow — MANDATORY tool mapping
Before reaching for a native tool, use the bctx equivalent. Emitting the actual `bctx` MCP
tool call is required — describing it is not calling it.
| When you would… | Instead call | Why |
|---|---|---|
| Read a source file to understand it | `blueprint` (structure) or `chisel` (signatures) | 60–90% fewer tokens than full Read |
| Read one function/class | `pinpoint` (symbol) | just the symbol, not the file |
| Read several files at once | `parallax` (multi-file, per-file lens) | one budgeted call |
| Compress a large blob before reasoning on it | `condenser` | auto-selected lens stack |
| `grep`/search the codebase | `compass` or `scanner` | ranked hits, not raw dumps |
| Map an unfamiliar project | `panorama` / `cartograph` | languages, entry points, key dirs |
**Only** fall back to native Read when you need the exact, complete contents of a file you
are about to edit.
### Memory ritual (do this every task)
1. **At task start:** call `archivist` (or `recall`) with the task topic to load durable
facts learned in prior sessions (schemas, auth flow, conventions, gotchas).
2. **When you learn something durable:** call `sediment` to persist it (category + key +
value). This is what makes the *next* session cheap.
### Proving savings
- `bctx benchmark <dir>` — structural compression ceiling across a codebase (headline %).
- `bctx gain` — real, all-time token savings recorded from commands run through the hook.
## 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 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 bctx_rules_md_leads_with_mandatory_steering() {
assert!(BCTX_RULES_MD.contains("Golden Workflow"));
assert!(BCTX_RULES_MD.contains("MANDATORY tool mapping"));
assert!(BCTX_RULES_MD.contains("Memory ritual"));
for hook in &["blueprint", "chisel", "parallax", "archivist", "sediment"] {
assert!(
BCTX_RULES_MD.contains(hook),
"Golden Workflow missing steering for: {hook}"
);
}
let workflow = BCTX_RULES_MD.find("Golden Workflow").unwrap();
let tables = BCTX_RULES_MD.find("Available MCP Skills").unwrap();
assert!(workflow < tables, "steering must lead, tables are appendix");
}
#[test]
fn hook_script_contains_intercepted_programs() {
for prog in &["git", "cargo", "npm", "kubectl", "docker", "terraform"] {
assert!(HOOK_SCRIPT.contains(prog), "hook missing: {prog}");
}
}
}