mod cli;
mod hook;
mod setup;
use anyhow::{Context, Result};
use ccboard_core::DataStore;
use clap::{Parser, Subcommand};
use indicatif::{ProgressBar, ProgressStyle};
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Parser)]
#[command(
name = "ccboard",
version,
about = "Unified Claude Code Management Dashboard",
long_about = "A comprehensive TUI and web dashboard for managing Claude Code data.\n\
\n\
Visualizes sessions, statistics, configuration, hooks, agents, costs, and history\n\
from ~/.claude directories with real-time updates and file editing capabilities.\n\
\n\
Features:\n\
• 7 interactive tabs (Dashboard, Sessions, Config, Hooks, Agents, Costs, History)\n\
• File editing with $EDITOR integration (press 'e')\n\
• MCP server management and visualization\n\
• Real-time cost tracking and analytics\n\
• Session search and exploration\n\
\n\
Examples:\n\
ccboard # Run TUI (default)\n\
ccboard web # Run web (API + frontend if built)\n\
ccboard web --port 8080 # Custom port\n\
ccboard both # Run both TUI and web server\n\
ccboard stats # Print stats summary\n\
ccboard search \"query\" # Search sessions\n\
ccboard recent 10 # Show 10 most recent sessions\n\
\n\
Web Frontend Workflow:\n\
# Option 1: Production (single command)\n\
trunk build --release # Compile frontend once\n\
ccboard web # Serves API + static frontend\n\
\n\
# Option 2: Development (hot reload)\n\
ccboard web --port 8080 # Terminal 1: API server\n\
trunk serve --port 3333 # Terminal 2: Frontend dev server\n\
\n\
Environment Variables:\n\
CCBOARD_CLAUDE_HOME # Override Claude home directory\n\
CCBOARD_NON_INTERACTIVE # Disable interactive prompts (CI/CD)\n\
CCBOARD_FORMAT # Force output format: json|table\n\
CCBOARD_NO_COLOR # Disable ANSI colors (log-friendly)"
)]
struct Cli {
#[command(subcommand)]
mode: Option<Mode>,
#[arg(long, env = "CCBOARD_CLAUDE_HOME")]
claude_home: Option<PathBuf>,
#[arg(long)]
project: Option<PathBuf>,
#[arg(long, env = "CCBOARD_NON_INTERACTIVE")]
non_interactive: bool,
#[arg(long, env = "CCBOARD_FORMAT", value_parser = ["json", "table"])]
format: Option<String>,
#[arg(long, env = "CCBOARD_NO_COLOR")]
no_color: bool,
}
#[derive(Subcommand)]
enum Mode {
Tui,
Web {
#[arg(long, default_value = "3333")]
port: u16,
},
Both {
#[arg(long, default_value = "3333")]
port: u16,
},
Stats,
ClearCache,
Search {
query: String,
#[arg(long, short = 'd')]
since: Option<String>,
#[arg(long, short = 'n', default_value = "20")]
limit: usize,
#[arg(long)]
json: bool,
},
Recent {
#[arg(default_value = "10")]
count: usize,
#[arg(long, short = 'd')]
since: Option<String>,
#[arg(long)]
json: bool,
},
Info {
session_id: String,
#[arg(long)]
json: bool,
},
Resume {
session_id: String,
},
Summarize {
session_id: String,
#[arg(long, default_value = "")]
model: String,
#[arg(long)]
force: bool,
},
Export {
#[command(subcommand)]
command: ExportCommand,
},
Pricing {
#[command(subcommand)]
command: PricingCommand,
},
Hook {
event: String,
},
Setup {
#[arg(long)]
dry_run: bool,
},
Report {
#[arg(long, short = 'f', default_value = "markdown", value_parser = ["json", "markdown", "html"])]
format: String,
#[arg(long, short = 'd', default_value = "7d")]
since: String,
#[arg(long)]
budget: Option<u64>,
#[arg(long)]
error_threshold: Option<f64>,
#[arg(long, short = 'o')]
output: Option<PathBuf>,
},
Discover {
#[arg(long, default_value = "90d")]
since: String,
#[arg(long, default_value = "3")]
min_count: usize,
#[arg(long, default_value = "20")]
top: usize,
#[arg(long)]
llm: bool,
#[arg(long, default_value = "")]
model: String,
#[arg(long)]
all: bool,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum ExportCommand {
Conversation {
session_id: String,
#[arg(short = 'o', long)]
output: PathBuf,
#[arg(short = 'f', long, default_value = "markdown", value_parser = ["markdown", "json", "html"])]
format: String,
},
Sessions {
#[arg(short = 'o', long)]
output: PathBuf,
#[arg(short = 'f', long, default_value = "csv", value_parser = ["csv", "json", "md"])]
format: String,
#[arg(long, short = 'd')]
since: Option<String>,
},
Stats {
#[arg(short = 'o', long)]
output: PathBuf,
#[arg(short = 'f', long, default_value = "csv", value_parser = ["csv", "json", "md"])]
format: String,
},
Billing {
#[arg(short = 'o', long)]
output: PathBuf,
#[arg(short = 'f', long, default_value = "csv", value_parser = ["csv", "json", "md"])]
format: String,
},
}
#[derive(Subcommand)]
enum PricingCommand {
Update,
Clear,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let claude_home = cli
.claude_home
.or_else(|| dirs::home_dir().map(|h: PathBuf| h.join(".claude")))
.context("Could not determine Claude home directory")?;
let project = cli.project.or_else(|| {
let current_dir = std::env::current_dir().ok()?;
if current_dir.join(".claude").exists() {
Some(current_dir)
} else {
None
}
});
let no_color = cli.no_color;
match cli.mode.unwrap_or(Mode::Tui) {
Mode::Tui => {
run_tui(claude_home, project).await?;
}
Mode::Web { port } => {
run_web(claude_home, project, port).await?;
}
Mode::Both { port } => {
run_both(claude_home, project, port).await?;
}
Mode::Stats => {
run_stats(claude_home, project).await?;
}
Mode::ClearCache => {
run_clear_cache(claude_home).await?;
}
Mode::Search {
query,
since,
limit,
json,
} => {
run_search(claude_home, project, query, since, limit, json, no_color).await?;
}
Mode::Recent { count, since, json } => {
run_recent(claude_home, project, count, since, json, no_color).await?;
}
Mode::Info { session_id, json } => {
run_info(claude_home, project, session_id, json, no_color).await?;
}
Mode::Resume { session_id } => {
run_resume(claude_home, project, session_id).await?;
}
Mode::Summarize {
session_id,
model,
force,
} => {
run_summarize(claude_home, project, session_id, model, force, no_color).await?;
}
Mode::Export { command } => match command {
ExportCommand::Conversation {
session_id,
output,
format,
} => {
run_export_conversation(claude_home, project, session_id, output, format, no_color)
.await?;
}
ExportCommand::Sessions {
output,
format,
since,
} => {
run_export_sessions(claude_home, project, output, format, since, no_color).await?;
}
ExportCommand::Stats { output, format } => {
run_export_stats(claude_home, project, output, format, no_color).await?;
}
ExportCommand::Billing { output, format } => {
run_export_billing(claude_home, project, output, format, no_color).await?;
}
},
Mode::Pricing { command } => match command {
PricingCommand::Update => {
run_pricing_update(no_color).await?;
}
PricingCommand::Clear => {
run_pricing_clear(no_color).await?;
}
},
Mode::Hook { event } => {
tokio::task::block_in_place(|| hook::run_hook(event))?;
}
Mode::Setup { dry_run } => {
setup::run_setup(dry_run, claude_home).await?;
}
Mode::Report {
format,
since,
budget,
error_threshold,
output,
} => {
run_report(
claude_home,
project,
format,
since,
budget,
error_threshold,
output,
no_color,
)
.await?;
}
Mode::Discover {
since,
min_count,
top,
llm,
model,
all,
json,
} => {
run_discover(
claude_home,
project,
since,
min_count,
top,
llm,
model,
all,
json,
)
.await?;
}
}
Ok(())
}
async fn run_tui(claude_home: PathBuf, project: Option<PathBuf>) -> Result<()> {
let store = Arc::new(DataStore::with_defaults(
claude_home.clone(),
project.clone(),
));
let _watcher = ccboard_core::FileWatcher::start(
claude_home.clone(),
project.clone(),
Arc::clone(&store),
Default::default(),
)
.await
.context("Failed to start file watcher")?;
ccboard_tui::run(store, claude_home, project).await
}
fn create_spinner() -> ProgressBar {
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
);
spinner.enable_steady_tick(std::time::Duration::from_millis(80));
spinner
}
fn parse_date_filter(since: Option<&str>) -> Result<Option<cli::DateFilter>> {
since
.map(|s| cli::DateFilter::parse(s).context("Invalid date filter"))
.transpose()
}
fn report_fatal_errors(spinner: &ProgressBar, report: &ccboard_core::LoadReport) -> bool {
if report.has_fatal_errors() {
spinner.finish_and_clear();
eprintln!("Fatal errors during data load:");
for error in &report.errors {
eprintln!(" - {}: {}", error.source, error.message);
}
true
} else {
false
}
}
async fn run_web(claude_home: PathBuf, project: Option<PathBuf>, port: u16) -> Result<()> {
use std::time::Instant;
let start = Instant::now();
let spinner = create_spinner();
spinner.set_message("Initializing data store...");
let store = Arc::new(DataStore::with_defaults(
claude_home.clone(),
project.clone(),
));
spinner.set_message("Loading sessions and statistics...");
let report = store.initial_load().await;
if report_fatal_errors(&spinner, &report) {
return Ok(());
}
spinner.set_message("Starting background analytics computation...");
let store_clone = Arc::clone(&store);
tokio::spawn(async move {
store_clone.compute_invocations().await;
store_clone.compute_billing_blocks().await;
});
spinner.set_message("Starting file watcher...");
let _watcher = ccboard_core::FileWatcher::start(
claude_home.clone(),
project.clone(),
Arc::clone(&store),
Default::default(),
)
.await
.context("Failed to start file watcher")?;
let elapsed = start.elapsed();
spinner.finish_with_message(format!(
"✓ Ready in {:.2}s ({} sessions loaded)",
elapsed.as_secs_f64(),
report.sessions_scanned
));
if ccboard_web::has_real_frontend() {
println!("\n🌐 Backend API + Frontend: http://localhost:{}", port);
println!(" API endpoints: http://localhost:{}/api/*", port);
} else {
println!("\n🌐 http://localhost:{}", port);
println!(" API: http://localhost:{}/api/*", port);
println!(" ⚠️ Frontend not embedded — use a pre-built binary from GitHub Releases");
println!(" or run `trunk build` in crates/ccboard-web/ then rebuild.");
}
ccboard_web::run(store, port).await
}
async fn run_both(claude_home: PathBuf, project: Option<PathBuf>, port: u16) -> Result<()> {
use std::time::Instant;
let start = Instant::now();
let spinner = create_spinner();
spinner.set_message("Initializing data store...");
let store = Arc::new(DataStore::with_defaults(
claude_home.clone(),
project.clone(),
));
spinner.set_message("Loading sessions and statistics...");
let report = store.initial_load().await;
if report_fatal_errors(&spinner, &report) {
return Ok(());
}
spinner.set_message("Computing invocation statistics...");
store.compute_invocations().await;
spinner.set_message("Computing billing blocks...");
store.compute_billing_blocks().await;
spinner.set_message("Starting file watcher...");
let _watcher = ccboard_core::FileWatcher::start(
claude_home.clone(),
project.clone(),
Arc::clone(&store),
Default::default(),
)
.await
.context("Failed to start file watcher")?;
let elapsed = start.elapsed();
spinner.finish_with_message(format!(
"✓ Ready in {:.2}s ({} sessions loaded)",
elapsed.as_secs_f64(),
report.sessions_scanned
));
if ccboard_web::has_real_frontend() {
println!("🌐 Backend API + Frontend: http://localhost:{}", port);
} else {
println!(
"🌐 http://localhost:{} (API only — frontend not embedded)",
port
);
}
let web_store = Arc::clone(&store);
let web_handle = tokio::spawn(async move {
if let Err(e) = ccboard_web::run(web_store, port).await {
eprintln!("Web server error: {}", e);
}
});
let tui_result = ccboard_tui::run(store, claude_home, project).await;
web_handle.abort();
tui_result
}
async fn run_stats(claude_home: PathBuf, project: Option<PathBuf>) -> Result<()> {
let store = DataStore::with_defaults(claude_home, project);
let report = store.initial_load().await;
println!("ccboard - Claude Code Statistics");
println!("================================");
println!();
if let Some(stats) = store.stats() {
println!("Total Tokens: {}", format_number(stats.total_tokens()));
println!(
" Input: {}",
format_number(stats.total_input_tokens())
);
println!(
" Output: {}",
format_number(stats.total_output_tokens())
);
println!(
" Cache Read: {}",
format_number(stats.total_cache_read_tokens())
);
println!(
" Cache Write: {}",
format_number(stats.total_cache_write_tokens())
);
println!();
println!("Sessions: {}", stats.session_count());
println!("Messages: {}", stats.message_count());
println!("Cache Hit Ratio: {:.1}%", stats.cache_ratio() * 100.0);
println!();
if !stats.model_usage.is_empty() {
println!("Models:");
for (name, usage) in stats.top_models(5) {
println!(
" {}: {} tokens (in: {}, out: {})",
name,
format_number(usage.total_tokens()),
format_number(usage.input_tokens),
format_number(usage.output_tokens)
);
}
}
} else {
println!("No stats available");
}
println!();
println!("Sessions indexed: {}", store.session_count());
if report.has_errors() {
println!();
println!("Warnings:");
for error in report.warnings() {
println!(" - {}: {}", error.source, error.message);
}
}
Ok(())
}
async fn run_clear_cache(claude_home: PathBuf) -> Result<()> {
let cache_dir = claude_home.join("cache");
let cache_path = cache_dir.join("session-metadata.db");
if !cache_path.exists() {
println!("❌ Cache not found at: {}", cache_path.display());
println!(" Nothing to clear.");
return Ok(());
}
let size_bytes = std::fs::metadata(&cache_path)
.with_context(|| format!("Failed to read cache metadata: {}", cache_path.display()))?
.len();
std::fs::remove_file(&cache_path)
.with_context(|| format!("Failed to delete cache: {}", cache_path.display()))?;
let wal_path = cache_dir.join("session-metadata.db-wal");
let shm_path = cache_dir.join("session-metadata.db-shm");
if wal_path.exists() {
let _ = std::fs::remove_file(&wal_path);
}
if shm_path.exists() {
let _ = std::fs::remove_file(&shm_path);
}
println!("✅ Cache cleared successfully");
println!(" Location: {}", cache_path.display());
println!(" Freed: {}", format_size(size_bytes));
println!();
println!("💡 Next run will rebuild cache with fresh metadata.");
Ok(())
}
fn format_size(bytes: u64) -> String {
if bytes >= 1_048_576 {
format!("{:.1}MB", bytes as f64 / 1_048_576.0)
} else if bytes >= 1_024 {
format!("{:.1}KB", bytes as f64 / 1_024.0)
} else {
format!("{}B", bytes)
}
}
fn format_number(n: u64) -> String {
if n >= 1_000_000_000 {
format!("{:.2}B", n as f64 / 1_000_000_000.0)
} else if n >= 1_000_000 {
format!("{:.2}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.2}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
async fn run_search(
claude_home: PathBuf,
project: Option<PathBuf>,
query: String,
since: Option<String>,
limit: usize,
json: bool,
no_color: bool,
) -> Result<()> {
let store = DataStore::with_defaults(claude_home, project);
if !json {
eprint!("Scanning sessions... ");
}
let report = store.initial_load().await;
if !json && report.sessions_scanned > 0 {
eprintln!("✓ {} sessions", report.sessions_scanned);
}
let date_filter = parse_date_filter(since.as_deref())?;
let all = store.recent_sessions(usize::MAX);
let results = cli::search_sessions(&all, &query, date_filter.as_ref(), limit);
if results.is_empty() {
return Err(cli::CliError::NoResults {
query,
scanned: all.len(),
}
.into());
}
println!("{}", cli::format_session_table(&results, json, no_color));
if !json {
eprintln!("\n{} results from {} sessions", results.len(), all.len());
}
Ok(())
}
async fn run_recent(
claude_home: PathBuf,
project: Option<PathBuf>,
count: usize,
since: Option<String>,
json: bool,
no_color: bool,
) -> Result<()> {
let store = DataStore::with_defaults(claude_home, project);
if !json {
eprint!("Loading sessions... ");
}
let report = store.initial_load().await;
if !json && report.sessions_scanned > 0 {
eprintln!("✓ {} sessions", report.sessions_scanned);
}
let date_filter = parse_date_filter(since.as_deref())?;
let mut all = store.recent_sessions(usize::MAX);
if let Some(filter) = date_filter {
all.retain(|s| {
s.first_timestamp
.map(|ts| filter.matches(&ts))
.unwrap_or(false)
});
}
let results: Vec<_> = all.into_iter().take(count).collect();
if results.is_empty() {
if !json {
println!("No sessions found.");
}
return Ok(());
}
println!("{}", cli::format_session_table(&results, json, no_color));
if !json {
eprintln!(
"\nShowing {} of {} sessions",
results.len(),
report.sessions_scanned
);
}
Ok(())
}
async fn run_info(
claude_home: PathBuf,
project: Option<PathBuf>,
session_id: String,
json: bool,
_no_color: bool,
) -> Result<()> {
let store = DataStore::with_defaults(claude_home, project);
if !json {
eprint!("Loading sessions... ");
}
store.initial_load().await;
if !json {
eprintln!("✓");
}
let all = store.recent_sessions(usize::MAX);
let session = cli::find_by_id_or_prefix(&all, &session_id)?;
println!("{}", cli::format_session_info(&session, json));
Ok(())
}
async fn run_resume(
claude_home: PathBuf,
project: Option<PathBuf>,
session_id: String,
) -> Result<()> {
let store = DataStore::with_defaults(claude_home, project);
eprint!("Loading sessions... ");
store.initial_load().await;
eprintln!("✓");
let all = store.recent_sessions(usize::MAX);
let session = cli::find_by_id_or_prefix(&all, &session_id)?;
eprintln!(
"Resuming session {} in {}",
&session.id[..8],
session.project_path
);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = std::process::Command::new("claude")
.args(["--resume", &session.id])
.exec();
anyhow::bail!("Failed to exec claude: {}", err);
}
#[cfg(not(unix))]
{
let status = std::process::Command::new("claude")
.args(["--resume", &session.id])
.status()
.context("Failed to spawn claude (is 'claude' in PATH?)")?;
std::process::exit(status.code().unwrap_or(1));
}
}
async fn run_summarize(
claude_home: PathBuf,
project: Option<PathBuf>,
session_id: String,
model: String,
force: bool,
no_color: bool,
) -> Result<()> {
let ccboard_dir = dirs::home_dir()
.context("Cannot determine home directory")?
.join(".ccboard");
let store = DataStore::with_defaults(claude_home, project);
eprint!("Loading sessions... ");
store.initial_load().await;
eprintln!("done");
let all = store.recent_sessions(usize::MAX);
let session = cli::find_by_id_or_prefix(&all, &session_id)?;
let summary_store = ccboard_core::summaries::SummaryStore::new(&ccboard_dir);
if !force && summary_store.has_summary(&session.id) {
let summary = summary_store.load(&session.id).unwrap_or_default();
let meta = summary_store.load_meta(&session.id);
eprintln!(
"Cached summary for {} ({})",
&session.id[..8.min(session.id.len())],
meta.map(|m| m.generated_at.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "unknown date".to_string())
);
println!("{}", summary);
return Ok(());
}
eprint!("Loading session content... ");
let lines =
ccboard_core::parsers::SessionContentParser::parse_session_lines(&session.file_path)
.await
.with_context(|| format!("Failed to read session {}", session.id))?;
let mut transcript = String::new();
let mut msg_count = 0usize;
for line in &lines {
if let Some(ref msg) = line.message {
let role = msg.role.as_deref().unwrap_or("unknown");
let text = extract_text_from_content(msg.content.as_ref());
if !text.is_empty() {
transcript.push_str(role);
transcript.push_str(": ");
transcript.push_str(&text);
transcript.push_str("\n\n");
msg_count += 1;
}
}
}
eprintln!("done ({} messages, {} chars)", msg_count, transcript.len());
eprintln!(
"Calling claude --print{}...",
if model.is_empty() {
String::new()
} else {
format!(" ({})", model)
}
);
let summary = ccboard_core::summaries::call_claude_summarize(&transcript, &model)
.context("claude --print failed")?;
summary_store
.save(&session.id, &summary, &model)
.context("Failed to cache summary")?;
let _ = no_color; println!("{}", summary);
eprintln!(
"Summary cached to ~/.ccboard/summaries/{}.md",
&session.id[..8.min(session.id.len())]
);
Ok(())
}
fn extract_text_from_content(content: Option<&serde_json::Value>) -> String {
match content {
None => String::new(),
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(blocks)) => blocks
.iter()
.filter_map(|b| {
if b.get("type").and_then(|t| t.as_str()) == Some("text") {
b.get("text")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
} else {
None
}
})
.collect::<Vec<_>>()
.join(" "),
_ => String::new(),
}
}
async fn run_export_conversation(
claude_home: PathBuf,
project: Option<PathBuf>,
session_id: String,
output: PathBuf,
format: String,
no_color: bool,
) -> Result<()> {
use ccboard_core::export::{
export_conversation_to_html, export_conversation_to_json, export_conversation_to_markdown,
};
let store = Arc::new(DataStore::with_defaults(claude_home, project));
if !no_color {
eprint!("Loading sessions... ");
}
store.initial_load().await;
if !no_color {
eprintln!("✓");
}
let all = store.recent_sessions(usize::MAX);
let session = cli::find_by_id_or_prefix(&all, &session_id)?;
if !no_color {
eprint!("Loading conversation... ");
}
let messages = store
.load_session_content(&session.id)
.await
.context("Failed to load session content")?;
if !no_color {
eprintln!("✓ {} messages", messages.len());
}
if !no_color {
eprint!("Exporting to {}... ", output.display());
}
match format.as_str() {
"markdown" | "md" => {
export_conversation_to_markdown(&messages, &session, &output)
.context("Failed to export to Markdown")?;
}
"json" => {
export_conversation_to_json(&messages, &session, &output)
.context("Failed to export to JSON")?;
}
"html" => {
export_conversation_to_html(&messages, &session, &output)
.context("Failed to export to HTML")?;
}
_ => {
anyhow::bail!("Invalid format: {}. Use markdown, json, or html", format);
}
}
if !no_color {
eprintln!("✓");
println!("✅ Exported to {}", output.display());
println!(" Session: {}", session.id);
println!(" Messages: {}", messages.len());
println!(" Format: {}", format);
} else {
println!("{}", output.display());
}
Ok(())
}
async fn run_export_sessions(
claude_home: PathBuf,
project: Option<PathBuf>,
output: PathBuf,
format: String,
since: Option<String>,
no_color: bool,
) -> Result<()> {
use ccboard_core::{
export_sessions_to_csv, export_sessions_to_json, export_sessions_to_markdown,
};
let store = DataStore::with_defaults(claude_home, project);
if !no_color {
eprint!("Loading sessions... ");
}
let report = store.initial_load().await;
if !no_color {
eprintln!("✓ {} sessions", report.sessions_scanned);
}
let date_filter = parse_date_filter(since.as_deref())?;
let mut sessions = store.recent_sessions(usize::MAX);
if let Some(filter) = date_filter {
sessions.retain(|s| {
s.first_timestamp
.map(|ts| filter.matches(&ts))
.unwrap_or(false)
});
}
if !no_color {
eprint!(
"Exporting {} sessions to {}... ",
sessions.len(),
output.display()
);
}
match format.as_str() {
"csv" => {
export_sessions_to_csv(&sessions, &output)
.context("Failed to export sessions to CSV")?;
}
"json" => {
export_sessions_to_json(&sessions, &output)
.context("Failed to export sessions to JSON")?;
}
"md" | "markdown" => {
export_sessions_to_markdown(&sessions, &output)
.context("Failed to export sessions to Markdown")?;
}
_ => {
anyhow::bail!("Invalid format: {}. Use csv, json, or md", format);
}
}
if !no_color {
eprintln!("✓");
println!("✅ Exported to {}", output.display());
println!(" Sessions: {}", sessions.len());
println!(" Format: {}", format);
} else {
println!("{}", output.display());
}
Ok(())
}
async fn run_export_stats(
claude_home: PathBuf,
project: Option<PathBuf>,
output: PathBuf,
format: String,
no_color: bool,
) -> Result<()> {
use ccboard_core::{export_stats_to_csv, export_stats_to_json, export_stats_to_markdown};
let store = DataStore::with_defaults(claude_home, project);
if !no_color {
eprint!("Loading statistics... ");
}
store.initial_load().await;
if !no_color {
eprintln!("✓");
}
let stats = store
.stats()
.ok_or_else(|| anyhow::anyhow!("No stats available (stats-cache.json not found)"))?;
if !no_color {
eprint!("Exporting stats to {}... ", output.display());
}
match format.as_str() {
"csv" => {
export_stats_to_csv(&stats, &output).context("Failed to export stats to CSV")?;
}
"json" => {
export_stats_to_json(&stats, &output).context("Failed to export stats to JSON")?;
}
"md" | "markdown" => {
export_stats_to_markdown(&stats, &output)
.context("Failed to export stats to Markdown")?;
}
_ => {
anyhow::bail!("Invalid format: {}. Use csv, json, or md", format);
}
}
if !no_color {
eprintln!("✓");
println!("✅ Exported to {}", output.display());
println!(" Sessions: {}", stats.total_sessions);
println!(" Messages: {}", stats.total_messages);
println!(" Format: {}", format);
} else {
println!("{}", output.display());
}
Ok(())
}
async fn run_export_billing(
claude_home: PathBuf,
project: Option<PathBuf>,
output: PathBuf,
format: String,
no_color: bool,
) -> Result<()> {
use ccboard_core::{
export_billing_blocks_to_csv, export_billing_blocks_to_json,
export_billing_blocks_to_markdown,
};
let store = DataStore::with_defaults(claude_home, project);
if !no_color {
eprint!("Loading billing data... ");
}
store.initial_load().await;
store.compute_billing_blocks().await;
if !no_color {
eprintln!("✓");
}
let manager = store.billing_blocks();
let block_count = manager.get_all_blocks().len();
if !no_color {
eprint!(
"Exporting {} blocks to {}... ",
block_count,
output.display()
);
}
match format.as_str() {
"csv" => {
export_billing_blocks_to_csv(&manager, &output)
.context("Failed to export billing to CSV")?;
}
"json" => {
export_billing_blocks_to_json(&manager, &output)
.context("Failed to export billing to JSON")?;
}
"md" | "markdown" => {
export_billing_blocks_to_markdown(&manager, &output)
.context("Failed to export billing to Markdown")?;
}
_ => {
anyhow::bail!("Invalid format: {}. Use csv, json, or md", format);
}
}
if !no_color {
eprintln!("✓");
println!("✅ Exported to {}", output.display());
println!(" Blocks: {}", block_count);
println!(" Format: {}", format);
} else {
println!("{}", output.display());
}
Ok(())
}
async fn run_pricing_update(_no_color: bool) -> Result<()> {
let spinner = create_spinner();
spinner.set_message("Fetching pricing from LiteLLM...");
match ccboard_core::pricing::update_pricing_from_litellm().await {
Ok(count) => {
spinner.finish_and_clear();
println!("✓ Updated {} model prices from LiteLLM", count);
println!(" Cache: ~/.cache/ccboard/pricing.json (TTL: 7 days)");
Ok(())
}
Err(e) => {
spinner.finish_and_clear();
eprintln!("✗ Failed to update pricing: {}", e);
eprintln!(" Using embedded pricing as fallback");
Ok(())
}
}
}
async fn run_pricing_clear(_no_color: bool) -> Result<()> {
match ccboard_core::pricing::clear_cache() {
Ok(()) => {
println!("✓ Cleared pricing cache");
println!(" File: ~/.cache/ccboard/pricing.json");
Ok(())
}
Err(e) => {
eprintln!("✗ Failed to clear cache: {}", e);
Err(e)
}
}
}
#[allow(clippy::too_many_arguments)]
async fn run_discover(
claude_home: std::path::PathBuf,
project: Option<std::path::PathBuf>,
since: String,
min_count: usize,
top: usize,
llm: bool,
model: String,
all: bool,
json: bool,
) -> Result<()> {
use ccboard_core::{DiscoverConfig, SuggestionCategory};
let since_days = parse_since_to_days(&since)?;
let filter_project: Option<&str> = if all {
None
} else {
project
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
};
let config = DiscoverConfig {
since_days,
min_count,
top,
all_projects: all,
};
if llm {
let projects_dir = claude_home.join("projects");
let sessions_data =
ccboard_core::discover_collect_sessions(&projects_dir, since_days, filter_project)
.await;
if sessions_data.is_empty() {
eprintln!("No sessions found in the given time range.");
return Ok(());
}
let total_sessions = sessions_data.len();
let total_projects: std::collections::HashSet<&str> =
sessions_data.iter().map(|s| s.project.as_str()).collect();
let total_projects_count = total_projects.len();
let suggestions = ccboard_core::discover_call_llm(&sessions_data, &model)
.context("LLM discovery failed")?;
let suggestions = &suggestions[..suggestions.len().min(top)];
if json {
println!("{}", serde_json::to_string_pretty(suggestions)?);
return Ok(());
}
println!();
println!(
" ccboard discover --llm — {} sessions · {} project(s) · {}",
total_sessions,
total_projects_count,
if model.is_empty() { "claude" } else { &model }
);
println!();
use std::collections::HashMap;
let mut by_category: HashMap<String, Vec<&ccboard_core::LlmSuggestion>> = HashMap::new();
for s in suggestions {
by_category.entry(s.category.clone()).or_default().push(s);
}
let category_order = ["CLAUDE.md rule", "skill", "command"];
let icons = [("CLAUDE.md rule", "📋"), ("skill", "🧩"), ("command", "⚡")];
for (cat, icon) in &icons {
let items = match by_category.get(*cat) {
Some(v) if !v.is_empty() => v,
_ => continue,
};
println!("{} {}", icon, cat.to_uppercase());
println!(" {}", "─".repeat(60));
for item in items.iter() {
println!(" {}", item.pattern);
if let Some(ref rationale) = item.rationale {
println!(" {}", rationale);
}
if let Some(ref name) = item.suggested_name {
println!(" name: {}", name);
}
println!();
}
println!();
}
let known: std::collections::HashSet<&str> = category_order.iter().copied().collect();
for (cat, items) in &by_category {
if known.contains(cat.as_str()) {
continue;
}
println!(" {}", cat.to_uppercase());
println!(" {}", "─".repeat(60));
for item in items {
println!(" {}", item.pattern);
println!();
}
}
println!(" Run with --json to pipe to jq for further processing.");
println!();
return Ok(());
}
let (suggestions, total_sessions, total_projects) =
ccboard_core::run_discover(&claude_home, &config, filter_project)
.await
.context("Discover failed")?;
if suggestions.is_empty() {
eprintln!("No recurring patterns found (try --min-count 2 or --since 180d).");
return Ok(());
}
if json {
println!("{}", serde_json::to_string_pretty(&suggestions)?);
return Ok(());
}
println!();
println!(
" ccboard discover — {} sessions · {} project(s) · since {}",
total_sessions, total_projects, since
);
println!();
let category_order = [
SuggestionCategory::ClaudeMdRule,
SuggestionCategory::Skill,
SuggestionCategory::Command,
];
for cat in &category_order {
let items: Vec<_> = suggestions.iter().filter(|s| &s.category == cat).collect();
if items.is_empty() {
continue;
}
println!("{} {}", cat.icon(), cat.as_str());
println!(" {}", "─".repeat(60));
for item in items {
let tag = if item.cross_project {
" [cross-project]"
} else {
""
};
let pct = item.session_count as f64 / total_sessions as f64 * 100.0;
println!(" {}{}", item.pattern, tag);
println!(
" {} sessions ({:.0}%) · {} occurrences · score {:.3}",
item.session_count, pct, item.count, item.score
);
for ex in &item.example_sessions {
println!(" → {}", &ex[..ex.len().min(36)]);
}
println!();
}
println!();
}
println!(" Run with --json to pipe to jq for further processing.");
println!();
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn run_report(
claude_home: PathBuf,
project: Option<PathBuf>,
format: String,
since: String,
budget: Option<u64>,
error_threshold: Option<f64>,
output: Option<PathBuf>,
_no_color: bool,
) -> Result<()> {
use ccboard_core::analytics::{AnalyticsData, Period};
let spinner = create_spinner();
spinner.set_message("Loading data...");
let store = DataStore::with_defaults(claude_home, project);
store.initial_load().await;
let days = parse_since_to_days(&since)?;
let period = Period::Days(days as usize);
let sessions = store.all_sessions();
spinner.set_message("Computing analytics...");
let analytics = AnalyticsData::compute(&sessions, period);
let total_sessions = sessions.len();
let errored_sessions = sessions
.iter()
.filter(|s| s.message_count == 0 || s.tool_usage.contains_key("error"))
.count();
let error_rate = if total_sessions > 0 {
errored_sessions as f64 / total_sessions as f64 * 100.0
} else {
0.0
};
let total_tokens: u64 = sessions.iter().map(|s| s.total_tokens).sum();
let total_cost: f64 = analytics.trends.daily_cost.iter().sum();
spinner.finish_and_clear();
let content = match format.as_str() {
"json" => {
let top_models: Vec<serde_json::Value> = store
.stats()
.map(|s| {
s.top_models(5)
.into_iter()
.map(|(name, usage)| {
serde_json::json!({
"model": name,
"total_tokens": usage.total_tokens(),
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
})
})
.collect()
})
.unwrap_or_default();
let top_tools: Vec<serde_json::Value> = analytics
.tool_token_stats
.iter()
.take(10)
.map(|t| {
serde_json::json!({
"tool": t.tool_name,
"calls": t.call_count,
"tokens": t.tokens,
"pct_of_total": format!("{:.1}", t.pct_of_total * 100.0),
"est_cost_usd": format!("{:.4}", t.est_cost_usd),
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({
"period": format!("{}d", days),
"generated_at": chrono::Utc::now().to_rfc3339(),
"summary": {
"total_tokens": total_tokens,
"total_sessions": total_sessions,
"sessions_in_period": analytics.sessions_in_period,
"total_cost_usd": format!("{:.4}", total_cost),
"error_rate_pct": format!("{:.2}", error_rate),
"cache_hit_ratio": store.stats().map(|s| format!("{:.1}", s.cache_ratio() * 100.0)).unwrap_or_else(|| "N/A".to_string()),
},
"models": top_models,
"top_tools": top_tools,
"insights": analytics.insights,
"gates": {
"budget_token_limit": budget,
"budget_exceeded": budget.map(|b| total_tokens > b),
"error_threshold_pct": error_threshold,
"error_threshold_exceeded": error_threshold.map(|t| error_rate > t),
},
}))?
}
"html" => {
let rows: String = analytics
.tool_token_stats
.iter()
.take(10)
.map(|t| {
format!(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{:.1}%</td><td>${:.4}</td></tr>",
t.tool_name,
t.call_count,
format_number(t.tokens),
t.pct_of_total * 100.0,
t.est_cost_usd
)
})
.collect::<Vec<_>>()
.join("\n");
let insights_html: String = analytics
.insights
.iter()
.map(|i| format!("<li>{}</li>", i))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ccboard Report — last {days}d</title>
<style>
body {{ font-family: system-ui, sans-serif; background: #0d1117; color: #e6edf3; padding: 2rem; }}
h1 {{ color: #58a6ff; }} h2 {{ color: #79c0ff; border-bottom: 1px solid #30363d; padding-bottom: 0.4rem; }}
table {{ border-collapse: collapse; width: 100%; margin-bottom: 2rem; }}
th, td {{ padding: 0.5rem 1rem; text-align: left; border: 1px solid #30363d; }}
th {{ background: #161b22; color: #58a6ff; }}
.stat {{ display: inline-block; background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem 1.5rem; margin: 0.5rem; }}
.stat-label {{ font-size: 0.85rem; color: #8b949e; }} .stat-value {{ font-size: 1.4rem; font-weight: bold; color: #58a6ff; }}
.gate-ok {{ color: #56d364; }} .gate-fail {{ color: #f85149; }}
ul {{ padding-left: 1.2rem; }} li {{ margin: 0.3rem 0; }}
</style>
</head>
<body>
<h1>ccboard Report</h1>
<p>Period: last {days} days | Generated: {generated}</p>
<h2>Summary</h2>
<div>
<div class="stat"><div class="stat-label">Total Tokens</div><div class="stat-value">{tokens}</div></div>
<div class="stat"><div class="stat-label">Sessions (period)</div><div class="stat-value">{sessions_period}</div></div>
<div class="stat"><div class="stat-label">Total Sessions</div><div class="stat-value">{sessions_total}</div></div>
<div class="stat"><div class="stat-label">Est. Cost</div><div class="stat-value">${cost:.4}</div></div>
<div class="stat"><div class="stat-label">Error Rate</div><div class="stat-value">{error_rate:.1}%</div></div>
</div>
<h2>Top Tools by Token Usage</h2>
<table>
<tr><th>Tool</th><th>Calls</th><th>Tokens</th><th>% of Total</th><th>Est. Cost</th></tr>
{rows}
</table>
<h2>Insights</h2>
<ul>
{insights_html}
</ul>
<h2>CI Gates</h2>
<ul>
{gates_html}
</ul>
</body>
</html>"#,
days = days,
generated = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC"),
tokens = format_number(total_tokens),
sessions_period = analytics.sessions_in_period,
sessions_total = total_sessions,
cost = total_cost,
error_rate = error_rate,
rows = rows,
insights_html = insights_html,
gates_html = {
let mut gates = Vec::new();
if let Some(b) = budget {
let ok = total_tokens <= b;
gates.push(format!(
r#"<li class="{}">Budget: {} tokens / {} limit ({})</li>"#,
if ok { "gate-ok" } else { "gate-fail" },
format_number(total_tokens),
format_number(b),
if ok { "OK" } else { "EXCEEDED" }
));
}
if let Some(t) = error_threshold {
let ok = error_rate <= t;
gates.push(format!(
r#"<li class="{}">Error rate: {:.1}% / {:.1}% threshold ({})</li>"#,
if ok { "gate-ok" } else { "gate-fail" },
error_rate,
t,
if ok { "OK" } else { "EXCEEDED" }
));
}
if gates.is_empty() {
gates.push("<li>No CI gates configured</li>".to_string());
}
gates.join("\n")
}
)
}
_ => {
let mut md = format!(
"# ccboard Report\n\n**Period:** last {days}d \n**Generated:** {generated}\n\n",
days = days,
generated = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC")
);
md.push_str("## Summary\n\n");
md.push_str(&format!("| Metric | Value |\n|--------|-------|\n"));
md.push_str(&format!(
"| Total Tokens | {} |\n",
format_number(total_tokens)
));
md.push_str(&format!(
"| Sessions (period) | {} |\n",
analytics.sessions_in_period
));
md.push_str(&format!("| Total Sessions | {} |\n", total_sessions));
md.push_str(&format!("| Est. Cost | ${:.4} |\n", total_cost));
md.push_str(&format!("| Error Rate | {:.1}% |\n", error_rate));
if let Some(stats) = store.stats() {
md.push_str(&format!(
"| Cache Hit Ratio | {:.1}% |\n",
stats.cache_ratio() * 100.0
));
}
if !analytics.tool_token_stats.is_empty() {
md.push_str("\n## Top Tools by Token Usage\n\n");
md.push_str("| Tool | Calls | Tokens | % of Total | Est. Cost |\n");
md.push_str("|------|-------|--------|------------|-----------|\n");
for t in analytics.tool_token_stats.iter().take(10) {
md.push_str(&format!(
"| {} | {} | {} | {:.1}% | ${:.4} |\n",
t.tool_name,
t.call_count,
format_number(t.tokens),
t.pct_of_total * 100.0,
t.est_cost_usd
));
}
}
if !analytics.insights.is_empty() {
md.push_str("\n## Insights\n\n");
for insight in &analytics.insights {
md.push_str(&format!("- {}\n", insight));
}
}
if budget.is_some() || error_threshold.is_some() {
md.push_str("\n## CI Gates\n\n");
if let Some(b) = budget {
let ok = total_tokens <= b;
md.push_str(&format!(
"- Budget: {} / {} tokens — **{}**\n",
format_number(total_tokens),
format_number(b),
if ok { "OK" } else { "EXCEEDED" }
));
}
if let Some(t) = error_threshold {
let ok = error_rate <= t;
md.push_str(&format!(
"- Error rate: {:.1}% / {:.1}% threshold — **{}**\n",
error_rate,
t,
if ok { "OK" } else { "EXCEEDED" }
));
}
}
md
}
};
match output {
Some(path) => {
std::fs::write(&path, &content)
.with_context(|| format!("Failed to write report to {}", path.display()))?;
println!("Report written to {}", path.display());
}
None => print!("{}", content),
}
let budget_exceeded = budget.map(|b| total_tokens > b).unwrap_or(false);
let error_exceeded = error_threshold.map(|t| error_rate > t).unwrap_or(false);
if budget_exceeded || error_exceeded {
if budget_exceeded {
eprintln!(
"CI gate FAILED: token budget exceeded ({} > {})",
format_number(total_tokens),
format_number(budget.unwrap())
);
}
if error_exceeded {
eprintln!(
"CI gate FAILED: error rate exceeded ({:.1}% > {:.1}%)",
error_rate,
error_threshold.unwrap()
);
}
std::process::exit(1);
}
Ok(())
}
fn parse_since_to_days(since: &str) -> Result<u32> {
if let Some(n_str) = since.strip_suffix('d') {
return n_str.parse::<u32>().map_err(|_| {
anyhow::anyhow!(
"Invalid --since value '{}'. Use 7d, 30d, 90d, or YYYY-MM-DD",
since
)
});
}
if let Ok(date) = chrono::NaiveDate::parse_from_str(since, "%Y-%m-%d") {
let today = chrono::Utc::now().date_naive();
let diff = today.signed_duration_since(date).num_days();
if diff < 0 {
anyhow::bail!("--since date '{}' is in the future", since);
}
return Ok(diff as u32);
}
anyhow::bail!(
"Invalid --since value '{}'. Use formats like 7d, 30d, 90d, or YYYY-MM-DD",
since
)
}