codebase-graph 1.8.0

Native codebaseGraph CLI and MCP server for local code knowledge graphs.
use crate::agent_hooks::{
    reconcile_agent_hooks, remove_agent_hooks, resolve_agent_hook_clients, run_agent_hook_stdin,
    verify_agent_hooks, AgentHookClient, MANAGED_ID,
};
use std::env;
use std::io::{self, Write};
use std::path::PathBuf;

#[derive(Debug, Clone)]
struct AgentHooksOptions {
    client: String,
    repo_root: Option<PathBuf>,
    config: Option<PathBuf>,
    dry_run: bool,
    verify: bool,
    managed_id: String,
    help: bool,
}

impl AgentHooksOptions {
    fn parse(args: &[String], command: &str) -> Result<Self, String> {
        let mut options = Self {
            client: "all".to_string(),
            repo_root: None,
            config: None,
            dry_run: false,
            verify: false,
            managed_id: MANAGED_ID.to_string(),
            help: false,
        };
        let mut index = 0;
        while index < args.len() {
            match args[index].as_str() {
                "-h" | "--help" => {
                    options.help = true;
                    index += 1;
                }
                "--client" => {
                    options.client = required(args, index, "--client")?.to_string();
                    index += 2;
                }
                "--repo-root" => {
                    options.repo_root = Some(PathBuf::from(required(args, index, "--repo-root")?));
                    index += 2;
                }
                "--config" | "--config-path" => {
                    options.config = Some(PathBuf::from(required(args, index, "--config")?));
                    index += 2;
                }
                "--dry-run" => {
                    options.dry_run = true;
                    index += 1;
                }
                "--verify" => {
                    options.verify = true;
                    index += 1;
                }
                "--managed-id" => {
                    options.managed_id = required(args, index, "--managed-id")?.to_string();
                    index += 2;
                }
                "--json" => index += 1,
                other => {
                    return Err(format!(
                        "unknown agent-hooks {command} option: {other}\n\n{}",
                        agent_hooks_help()
                    ));
                }
            }
        }
        Ok(options)
    }

    fn paths(&self) -> Result<(PathBuf, PathBuf), String> {
        let config = self
            .config
            .clone()
            .or_else(|| {
                self.repo_root
                    .as_ref()
                    .map(|root| root.join(".codebaseGraph/config.json"))
            })
            .unwrap_or_else(|| PathBuf::from(".codebaseGraph/config.json"));
        let config = absolutize(config);
        let root = self
            .repo_root
            .clone()
            .or_else(|| {
                config
                    .parent()
                    .and_then(|parent| parent.parent())
                    .map(PathBuf::from)
            })
            .unwrap_or(env::current_dir().map_err(|error| error.to_string())?);
        Ok((absolutize(root), config))
    }
}

fn required<'a>(args: &'a [String], index: usize, name: &str) -> Result<&'a str, String> {
    args.get(index + 1)
        .map(String::as_str)
        .ok_or_else(|| format!("{name} requires a value"))
}

fn absolutize(path: PathBuf) -> PathBuf {
    if path.is_absolute() {
        path
    } else {
        env::current_dir()
            .map(|cwd| cwd.join(&path))
            .unwrap_or_else(|_| path)
    }
}

fn clients(selection: &str) -> Result<Vec<AgentHookClient>, String> {
    resolve_agent_hook_clients(selection, selection)
}

pub(in crate::adapters::cli) fn run_agent_hooks_command<W: Write>(
    args: &[String],
    stdout: &mut W,
) -> Result<(), String> {
    let action = args.first().map(String::as_str).unwrap_or("help");
    if matches!(action, "-h" | "--help" | "help") {
        writeln!(stdout, "{}", agent_hooks_help()).map_err(|error| error.to_string())?;
        return Ok(());
    }
    match action {
        "install" | "remove" | "verify" => {
            let options = AgentHooksOptions::parse(&args[1..], action)?;
            if options.help {
                writeln!(stdout, "{}", agent_hooks_help()).map_err(|error| error.to_string())?;
                return Ok(());
            }
            if options.managed_id != MANAGED_ID {
                return Err(format!("--managed-id must be {MANAGED_ID}"));
            }
            let (root, config) = options.paths()?;
            let selected = clients(&options.client)?;
            let mut payload = match action {
                "install" => reconcile_agent_hooks(&root, &config, &selected, options.dry_run)?,
                "remove" => remove_agent_hooks(&root, &config, &selected, options.dry_run)?,
                "verify" => verify_agent_hooks(&root, &config, &selected)?,
                _ => unreachable!(),
            };
            if action == "install" && options.verify {
                payload["verification"] = verify_agent_hooks(&root, &config, &selected)?;
            }
            writeln!(
                stdout,
                "{}",
                serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())?
            )
            .map_err(|error| error.to_string())
        }
        "run" => {
            let options = AgentHooksOptions::parse(&args[1..], action)?;
            if options.help {
                writeln!(stdout, "{}", agent_hooks_help()).map_err(|error| error.to_string())?;
                return Ok(());
            }
            if options.managed_id != MANAGED_ID {
                return Ok(());
            }
            let client = AgentHookClient::parse_for_cli(&options.client)?;
            let (_, config) = options.paths()?;
            let mut stdin = io::stdin().lock();
            run_agent_hook_stdin(client, &config, &mut stdin, stdout)
        }
        other => Err(format!(
            "unknown agent-hooks command: {other}\n\n{}",
            agent_hooks_help()
        )),
    }
}

pub(in crate::adapters::cli) fn agent_hooks_help() -> &'static str {
    "codebase-graph agent-hooks\n\nUSAGE:\n  codebase-graph agent-hooks install --client <codex|claude|github-copilot|all> [--repo-root <path>] [--config <path>] [--dry-run]\n  codebase-graph agent-hooks remove --client <codex|claude|github-copilot|all> [--repo-root <path>] [--config <path>] [--dry-run]\n  codebase-graph agent-hooks verify --client <codex|claude|github-copilot|all> [--repo-root <path>] [--config <path>]\n  codebase-graph agent-hooks run --client <codex|claude|github-copilot> --config <path> --managed-id codebase-graph-v1\n\nHooks are advisory, fail open within a bounded deadline, and use the existing managed loopback MCP daemon."
}

trait CliClient {
    fn parse_for_cli(value: &str) -> Result<Self, String>
    where
        Self: Sized;
}

impl CliClient for AgentHookClient {
    fn parse_for_cli(value: &str) -> Result<Self, String> {
        match value {
            "codex" => Ok(Self::Codex),
            "claude" | "claude-project" => Ok(Self::Claude),
            "github-copilot" | "copilot" => Ok(Self::GithubCopilot),
            other => Err(format!("unsupported agent hook client: {other}")),
        }
    }
}

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

    #[test]
    fn options_resolve_repo_local_config_by_default() {
        let expected_root = env::temp_dir().join("codebase-graph-agent-hooks-options-repo");
        let options = AgentHooksOptions::parse(
            &[
                "--repo-root".to_string(),
                expected_root.to_string_lossy().into_owned(),
            ],
            "install",
        )
        .unwrap();
        let (root, config) = options.paths().unwrap();
        assert!(root.is_absolute());
        assert_eq!(root, expected_root);
        assert_eq!(config, expected_root.join(".codebaseGraph/config.json"));
    }
}