claudex-cli 0.10.0

Query, search, and analyze agent coding sessions from the command line
Documentation
use std::collections::HashMap;
use std::path::PathBuf;

use anyhow::Result;
use chrono::DateTime;

use crate::cli::ResolvedFilter;
use crate::ui;
use claudex::index::IndexStore;
use claudex::parser::parse_session;
use claudex::providers::enabled_default;
use claudex::store::{SessionStore, decode_project_name, display_project_name, short_name};

pub fn run(
    project: Option<&str>,
    per_session: bool,
    limit: usize,
    json: bool,
    no_index: bool,
    filter: &ResolvedFilter,
) -> Result<()> {
    if !no_index && let Ok(()) = run_indexed(project, per_session, limit, json, filter) {
        return Ok(());
    }
    run_from_files(project, per_session, limit, json, filter)
}

fn run_indexed(
    project: Option<&str>,
    per_session: bool,
    limit: usize,
    json: bool,
    filter: &ResolvedFilter,
) -> Result<()> {
    let providers = enabled_default()?;
    let mut idx = IndexStore::open()?;
    idx.ensure_fresh(&providers)?;

    if per_session {
        let rows = idx.query_tools_per_session(project, filter, limit)?;

        if json {
            let output: Vec<_> = rows
                .iter()
                .map(|r| {
                    let date = r
                        .first_timestamp_ms
                        .and_then(DateTime::from_timestamp_millis)
                        .map(|d| d.to_rfc3339());
                    serde_json::json!({
                        "project": r.project,
                        "session_id": r.session_id,
                        "date": date,
                        "tools": r.tools,
                    })
                })
                .collect();
            println!("{}", serde_json::to_string_pretty(&output)?);
            return Ok(());
        }

        let mut table = ui::table();
        table.set_header(ui::header([
            "Project",
            "Session",
            "Date",
            "Top Tools",
            "Total Calls",
        ]));
        ui::right_align(&mut table, &[4]);

        for r in &rows {
            let sid: String = r
                .session_id
                .as_deref()
                .unwrap_or("-")
                .chars()
                .take(8)
                .collect();
            let total: i64 = r.tools.values().sum();
            let mut sorted: Vec<_> = r.tools.iter().collect();
            sorted.sort_by(|a, b| b.1.cmp(a.1));
            let date = r
                .first_timestamp_ms
                .and_then(DateTime::from_timestamp_millis)
                .map(|d| d.format("%Y-%m-%d").to_string())
                .unwrap_or_else(|| "-".to_string());
            let top: Vec<_> = sorted
                .iter()
                .take(3)
                .map(|(k, v)| format!("{}({})", k, ui::fmt_count(**v as u64)))
                .collect();
            table.add_row([
                ui::cell_project(&short_name(&r.project)),
                ui::cell_dim(&sid),
                ui::cell_dim(&date),
                ui::cell_plain(top.join(", ")),
                ui::cell_count(total as u64),
            ]);
        }
        println!("{table}");
        return Ok(());
    }

    let rows = idx.query_tools_aggregate(project, filter, limit)?;

    if json {
        let output: Vec<_> = rows
            .iter()
            .map(|r| serde_json::json!({"tool": r.tool_name, "count": r.count}))
            .collect();
        println!("{}", serde_json::to_string_pretty(&output)?);
        return Ok(());
    }

    let mut table = ui::table();
    table.set_header(ui::header(["Tool", "Calls"]));
    ui::right_align(&mut table, &[1]);
    for r in &rows {
        table.add_row([ui::cell_tool(&r.tool_name), ui::cell_count(r.count as u64)]);
    }
    println!("{table}");
    Ok(())
}

fn run_from_files(
    project: Option<&str>,
    per_session: bool,
    limit: usize,
    json: bool,
    filter: &ResolvedFilter,
) -> Result<()> {
    filter.ensure_no_index_supported()?;

    let store = SessionStore::new()?;
    let files = store.all_session_files(project)?;
    if per_session {
        run_per_session(files, limit, json, filter)
    } else {
        run_aggregate(files, limit, json, filter)
    }
}

fn run_aggregate(
    files: Vec<(String, PathBuf)>,
    limit: usize,
    json: bool,
    filter: &ResolvedFilter,
) -> Result<()> {
    let mut counts: HashMap<String, u64> = HashMap::new();

    for (_, path) in &files {
        let stats = match parse_session(path) {
            Ok(s) => s,
            Err(_) => continue,
        };
        if !filter.matches("claude", &stats, false) {
            continue;
        }
        for name in stats.tool_names {
            *counts.entry(name).or_insert(0) += 1;
        }
    }

    let mut rows: Vec<(String, u64)> = counts.into_iter().collect();
    rows.sort_by_key(|r| std::cmp::Reverse(r.1));
    rows.truncate(limit);

    if json {
        let output: Vec<_> = rows
            .iter()
            .map(|(name, count)| serde_json::json!({"tool": name, "count": count}))
            .collect();
        println!("{}", serde_json::to_string_pretty(&output)?);
        return Ok(());
    }

    let mut table = ui::table();
    table.set_header(ui::header(["Tool", "Calls"]));
    ui::right_align(&mut table, &[1]);
    for (name, count) in &rows {
        table.add_row([ui::cell_tool(name), ui::cell_count(*count)]);
    }
    println!("{table}");
    Ok(())
}

fn run_per_session(
    files: Vec<(String, PathBuf)>,
    limit: usize,
    json: bool,
    filter: &ResolvedFilter,
) -> Result<()> {
    let mut rows = Vec::new();
    for (project_raw, path) in &files {
        let stats = match parse_session(path) {
            Ok(s) => s,
            Err(_) => continue,
        };
        if stats.tool_names.is_empty() {
            continue;
        }
        if !filter.matches("claude", &stats, false) {
            continue;
        }
        let mut counts: HashMap<String, u64> = HashMap::new();
        for name in &stats.tool_names {
            *counts.entry(name.clone()).or_insert(0) += 1;
        }
        rows.push((
            display_project_name(&decode_project_name(project_raw)),
            stats.session_id,
            stats.first_timestamp,
            counts,
        ));
    }
    rows.sort_by(|a, b| {
        b.2.cmp(&a.2)
            .then_with(|| a.0.cmp(&b.0))
            .then_with(|| a.1.cmp(&b.1))
    });
    rows.truncate(limit);

    if json {
        let output: Vec<_> = rows
            .iter()
            .map(|(project, session_id, date, counts)| {
                serde_json::json!({
                    "project": project,
                    "session_id": session_id,
                    "date": date.map(|d| d.to_rfc3339()),
                    "tools": counts,
                })
            })
            .collect();
        println!("{}", serde_json::to_string_pretty(&output)?);
        return Ok(());
    }

    let mut table = ui::table();
    table.set_header(ui::header([
        "Project",
        "Session",
        "Date",
        "Top Tools",
        "Total Calls",
    ]));
    ui::right_align(&mut table, &[4]);

    for (project, session_id, date, counts) in &rows {
        let sid: String = session_id
            .as_deref()
            .unwrap_or("-")
            .chars()
            .take(8)
            .collect();
        let date = date
            .map(|d| d.format("%Y-%m-%d").to_string())
            .unwrap_or_else(|| "-".to_string());
        let total: u64 = counts.values().sum();
        let mut sorted: Vec<_> = counts.iter().collect();
        sorted.sort_by(|a, b| b.1.cmp(a.1));
        let top: Vec<_> = sorted
            .iter()
            .take(3)
            .map(|(k, v)| format!("{}({})", k, ui::fmt_count(**v)))
            .collect();
        table.add_row([
            ui::cell_project(&short_name(project)),
            ui::cell_dim(&sid),
            ui::cell_dim(&date),
            ui::cell_plain(top.join(", ")),
            ui::cell_count(total),
        ]);
    }
    println!("{table}");
    Ok(())
}