dci-tool 0.1.0

Direct Corpus Interaction: a sandboxed, ripgrep-backed corpus-search toolset and agent for cyber-focused LLM agents, built on rig.
Documentation
//! `dci` — a command-line Direct Corpus Interaction agent.
//!
//! Point it at a corpus directory and ask a question; the agent searches the
//! raw files directly (no vector database) and answers with `path:line`
//! citations. Bring your own model via the relevant provider environment
//! variable (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`).

use std::path::PathBuf;
use std::process::ExitCode;

use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use dci_tool::{CorpusRoot, DciAgent, Limits};
use rig_core::client::{CompletionClient, ProviderClient};
use rig_core::completion::CompletionModel;
use rig_core::providers::{anthropic, ollama, openai};

/// Supported model providers (selected at runtime; bring your own key).
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Provider {
    /// OpenAI (`OPENAI_API_KEY`).
    Openai,
    /// Anthropic (`ANTHROPIC_API_KEY`).
    Anthropic,
    /// Local Ollama (`OLLAMA_*`, no key required).
    Ollama,
}

/// Search a corpus by direct interaction instead of a vector database.
#[derive(Debug, Parser)]
#[command(name = "dci", version, about)]
struct Cli {
    /// Path to the corpus directory to investigate.
    #[arg(short, long, default_value = ".")]
    corpus: PathBuf,

    /// The question to answer over the corpus.
    question: String,

    /// Model provider to use.
    #[arg(short, long, value_enum, default_value_t = Provider::Openai)]
    provider: Provider,

    /// Model name (defaults to a sensible model per provider).
    #[arg(short, long)]
    model: Option<String>,

    /// Maximum tool-calling turns per investigation.
    #[arg(long, default_value_t = dci_tool::DEFAULT_MAX_TURNS)]
    max_turns: usize,

    /// Do not honor `.gitignore` rules while searching (useful for log corpora).
    #[arg(long)]
    no_gitignore: bool,
}

#[tokio::main]
async fn main() -> ExitCode {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
        )
        .with_writer(std::io::stderr)
        .init();

    match run(Cli::parse()).await {
        Ok(answer) => {
            println!("{answer}");
            ExitCode::SUCCESS
        }
        Err(err) => {
            eprintln!("error: {err:#}");
            ExitCode::FAILURE
        }
    }
}

async fn run(cli: Cli) -> Result<String> {
    let limits = Limits {
        respect_gitignore: !cli.no_gitignore,
        ..Limits::default()
    };
    let corpus = CorpusRoot::with_limits(&cli.corpus, limits)
        .with_context(|| format!("opening corpus at {}", cli.corpus.display()))?;

    match cli.provider {
        Provider::Openai => {
            let client = openai::Client::from_env().context("initializing OpenAI client")?;
            let model_id = cli.model.as_deref().unwrap_or(openai::GPT_4O).to_string();
            let model = client.completion_model(&model_id);
            investigate(model, corpus, &cli.question, cli.max_turns, None, &model_id).await
        }
        Provider::Anthropic => {
            let client = anthropic::Client::from_env().context("initializing Anthropic client")?;
            let model_id = cli
                .model
                .as_deref()
                .unwrap_or(anthropic::completion::CLAUDE_SONNET_4_6)
                .to_string();
            let model = client.completion_model(&model_id);
            // Anthropic requires an explicit output token budget.
            investigate(
                model,
                corpus,
                &cli.question,
                cli.max_turns,
                Some(4096),
                &model_id,
            )
            .await
        }
        Provider::Ollama => {
            let client = ollama::Client::from_env().context("initializing Ollama client")?;
            let model_id = cli.model.as_deref().unwrap_or("llama3.1").to_string();
            let model = client.completion_model(&model_id);
            investigate(model, corpus, &cli.question, cli.max_turns, None, &model_id).await
        }
    }
}

async fn investigate<M: CompletionModel + 'static>(
    model: M,
    corpus: CorpusRoot,
    question: &str,
    max_turns: usize,
    max_tokens: Option<u64>,
    model_label: &str,
) -> Result<String> {
    let mut builder = DciAgent::builder(model, corpus)
        .max_turns(max_turns)
        .model_label(model_label);
    if let Some(max_tokens) = max_tokens {
        builder = builder.max_tokens(max_tokens);
    }
    let agent = builder.build();
    // Scope telemetry events emitted during the run to a CLI session.
    dci_tool::telemetry::with_session("cli".to_string(), || async {
        agent
            .investigate(question)
            .await
            .context("running investigation")
    })
    .await
}