bctx 0.1.26

bctx CLI — intercept CLI commands and compress output for LLM coding agents
use anyhow::Result;
use std::io::IsTerminal;

/// Skill node selected by the planning pass.
struct SkillSelection {
    skill: &'static str,
    rationale: &'static str,
    token_budget: usize,
}

pub fn handle(task: String, budget: usize, json: bool) -> Result<()> {
    let selections = decide(&task, budget);

    if json {
        let arr: Vec<serde_json::Value> = selections
            .iter()
            .map(|s| {
                serde_json::json!({
                    "skill": s.skill,
                    "rationale": s.rationale,
                    "token_budget": s.token_budget
                })
            })
            .collect();
        println!("{}", serde_json::to_string_pretty(&arr)?);
        return Ok(());
    }

    let is_tty = std::io::stdout().is_terminal();
    let sep = "  ─────────────────────────────────────────────────────────";

    println!();
    println!("  bctx plan  — {task}");
    println!("{sep}");

    if selections.is_empty() {
        println!("  No skills selected. Describe the task in more detail.");
        println!("{sep}");
        println!();
        return Ok(());
    }

    println!(
        "  SpiralCycle DECIDE phase · budget: {} tokens total",
        budget
    );
    println!();

    let total_budget: usize = selections.iter().map(|s| s.token_budget).sum();
    let max_budget = selections.iter().map(|s| s.token_budget).max().unwrap_or(1);

    for (i, s) in selections.iter().enumerate() {
        let step = i + 1;
        let bar_width = 14usize;
        let filled = s
            .token_budget
            .checked_mul(bar_width)
            .and_then(|v| v.checked_div(max_budget))
            .unwrap_or(0)
            .min(bar_width);
        let empty = bar_width - filled;
        let bar = if is_tty {
            format!(
                "\x1b[36m{}\x1b[2m{}\x1b[0m",
                "".repeat(filled),
                "".repeat(empty)
            )
        } else {
            format!("{}{}", "".repeat(filled), "".repeat(empty))
        };

        let skill_label = if is_tty {
            format!("\x1b[1m{:<12}\x1b[0m", s.skill)
        } else {
            format!("{:<12}", s.skill)
        };

        println!(
            "  {step}. {skill_label}  {bar}  {:>5} tok  {}",
            s.token_budget, s.rationale
        );
    }

    println!();
    println!("{sep}");
    println!(
        "  {} skills selected · {} tokens total",
        selections.len(),
        total_budget
    );
    println!();
    println!("  Run `bctx mcp` to execute via MCP, or use individual `bctx` subcommands");
    println!();
    Ok(())
}

/// Lightweight DECIDE-phase heuristic: map task keywords → skill selections.
fn decide(task: &str, budget: usize) -> Vec<SkillSelection> {
    let task_lower = task.to_lowercase();
    let mut selections: Vec<SkillSelection> = Vec::new();
    let per = budget / 6; // rough equal split across up to 6 skills

    // Code search / navigation
    if task_lower.contains("find")
        || task_lower.contains("search")
        || task_lower.contains("where")
        || task_lower.contains("which file")
        || task_lower.contains("location")
    {
        selections.push(SkillSelection {
            skill: "compass",
            rationale: "BM25 + graph search to locate relevant code",
            token_budget: per,
        });
    }

    // Code understanding / AST
    if task_lower.contains("understand")
        || task_lower.contains("explain")
        || task_lower.contains("symbol")
        || task_lower.contains("function")
        || task_lower.contains("class")
        || task_lower.contains("struct")
        || task_lower.contains("interface")
        || task_lower.contains("api")
    {
        selections.push(SkillSelection {
            skill: "chisel",
            rationale: "AST symbol extraction for code structure",
            token_budget: per,
        });
    }

    // Project overview / directory structure
    if task_lower.contains("project")
        || task_lower.contains("overview")
        || task_lower.contains("structure")
        || task_lower.contains("directory")
        || task_lower.contains("layout")
        || task_lower.contains("codebase")
    {
        selections.push(SkillSelection {
            skill: "cartograph",
            rationale: "directory map + language breakdown",
            token_budget: per,
        });
        selections.push(SkillSelection {
            skill: "panorama",
            rationale: "high-level project summary with entry points",
            token_budget: per / 2,
        });
    }

    // Dependency / impact analysis
    if task_lower.contains("depend")
        || task_lower.contains("impact")
        || task_lower.contains("call")
        || task_lower.contains("uses")
        || task_lower.contains("caller")
    {
        selections.push(SkillSelection {
            skill: "surveyor",
            rationale: "dependency topology + caller/callee graph",
            token_budget: per,
        });
    }

    // Running commands / output
    if task_lower.contains("run")
        || task_lower.contains("execute")
        || task_lower.contains("build")
        || task_lower.contains("test")
        || task_lower.contains("compile")
        || task_lower.contains("output")
    {
        selections.push(SkillSelection {
            skill: "scout",
            rationale: "execute command with domain-aware compression",
            token_budget: per,
        });
        selections.push(SkillSelection {
            skill: "sieve",
            rationale: "filter command output to task-relevant lines",
            token_budget: per / 2,
        });
    }

    // Security review
    if task_lower.contains("security")
        || task_lower.contains("vulnerab")
        || task_lower.contains("safe")
        || task_lower.contains("risk")
        || task_lower.contains("audit")
    {
        selections.push(SkillSelection {
            skill: "sentinel",
            rationale: "static security risk assessment",
            token_budget: per / 2,
        });
        selections.push(SkillSelection {
            skill: "arbiter",
            rationale: "structured code review for smells",
            token_budget: per,
        });
    }

    // Memory / vault retrieval
    if task_lower.contains("remember")
        || task_lower.contains("previous")
        || task_lower.contains("last time")
        || task_lower.contains("history")
        || task_lower.contains("recall")
        || task_lower.contains("fact")
    {
        selections.push(SkillSelection {
            skill: "archivist",
            rationale: "retrieve relevant Vault facts",
            token_budget: per / 2,
        });
    }

    // Compression / token budget
    if task_lower.contains("compress")
        || task_lower.contains("summar")
        || task_lower.contains("token")
        || task_lower.contains("shrink")
        || task_lower.contains("condense")
    {
        selections.push(SkillSelection {
            skill: "condenser",
            rationale: "compress content to token budget via Lens pipeline",
            token_budget: per,
        });
        selections.push(SkillSelection {
            skill: "thermal",
            rationale: "identify hottest token-consuming sections",
            token_budget: per / 2,
        });
    }

    // Always add sieve if we have shell tasks and it's not already there
    if selections.is_empty() {
        // Generic fallback: suggest a Prism index + compass search
        selections.push(SkillSelection {
            skill: "prism",
            rationale: "build/update project code index",
            token_budget: per,
        });
        selections.push(SkillSelection {
            skill: "compass",
            rationale: "BM25 + graph search for relevant context",
            token_budget: per,
        });
        selections.push(SkillSelection {
            skill: "condenser",
            rationale: "compress retrieved context to token budget",
            token_budget: per,
        });
    }

    // Cap at 6 skills to keep the plan readable
    selections.truncate(6);
    selections
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn search_task_selects_compass() {
        let plan = decide("find where the auth token is validated", 6000);
        assert!(
            plan.iter().any(|s| s.skill == "compass"),
            "{:?}",
            plan.iter().map(|s| s.skill).collect::<Vec<_>>()
        );
    }

    #[test]
    fn security_task_selects_sentinel() {
        let plan = decide("security audit of the login flow", 6000);
        assert!(plan.iter().any(|s| s.skill == "sentinel"));
    }

    #[test]
    fn build_task_selects_scout() {
        let plan = decide("run the test suite and check output", 6000);
        assert!(plan.iter().any(|s| s.skill == "scout"));
    }

    #[test]
    fn unknown_task_gets_fallback() {
        let plan = decide("frobnicate the widget", 6000);
        assert!(!plan.is_empty());
    }

    #[test]
    fn plan_capped_at_six_skills() {
        let plan = decide(
            "find security issues in test output and summarize the project structure dependencies",
            12000,
        );
        assert!(plan.len() <= 6);
    }

    #[test]
    fn project_overview_task_selects_cartograph() {
        let plan = decide("give me an overview of the project structure", 6000);
        assert!(
            plan.iter().any(|s| s.skill == "cartograph"),
            "expected cartograph for project overview task"
        );
    }

    #[test]
    fn dependency_task_selects_surveyor() {
        let plan = decide(
            "show me what depends on this function and its callers",
            6000,
        );
        assert!(
            plan.iter().any(|s| s.skill == "surveyor"),
            "expected surveyor for dependency task"
        );
    }

    #[test]
    fn recall_task_selects_archivist() {
        let plan = decide("recall what I know about the previous auth decisions", 6000);
        assert!(
            plan.iter().any(|s| s.skill == "archivist"),
            "expected archivist for recall/memory task"
        );
    }

    #[test]
    fn all_skills_have_non_zero_budget() {
        let plan = decide("find and explain the database connection setup", 6000);
        for s in &plan {
            assert!(
                s.token_budget > 0,
                "skill '{}' has zero token budget",
                s.skill
            );
        }
    }

    #[test]
    fn json_output_is_valid() {
        // Verify handle() with json=true produces parseable JSON
        // We test this by calling decide() directly and serialising — the same
        // path handle() takes internally.
        let plan = decide("find where the config is loaded", 6000);
        let arr: Vec<serde_json::Value> = plan
            .iter()
            .map(|s| {
                serde_json::json!({
                    "skill": s.skill,
                    "rationale": s.rationale,
                    "token_budget": s.token_budget
                })
            })
            .collect();
        let text = serde_json::to_string_pretty(&arr).expect("should serialise");
        let parsed: Vec<serde_json::Value> =
            serde_json::from_str(&text).expect("should parse back");
        assert_eq!(parsed.len(), plan.len());
        for item in &parsed {
            assert!(item["skill"].is_string());
            assert!(item["rationale"].is_string());
            assert!(item["token_budget"].is_number());
        }
    }
}