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,
},
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::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(())
}
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
)
}