use crate::cli::output::{create_table, truncate_string, OutputFormat, OutputWriter};
use crate::config::Config;
use crate::datasources::{
CodexHistoryDataSource, HistoryDataSource, StatsDataSource, TodoDataSource,
TranscriptDataSource,
};
use crate::error::Result;
use crate::models::TodoStatus;
use crate::query::QueryEngine;
use crate::search::SearchEngine;
use crate::sql::{SqlEngine, SqlOptions};
use comfy_table::Cell;
pub async fn prompts(
config: &Config,
session: Option<String>,
project: Option<String>,
since: Option<String>,
until: Option<String>,
limit: Option<usize>,
format: OutputFormat,
) -> Result<()> {
let history = HistoryDataSource::new(config.clone());
let mut entries = history.filter_prompts().await?;
if let Some(ref proj) = project {
entries.retain(|e| {
e.project
.as_ref()
.map(|p| p.contains(proj))
.unwrap_or(false)
});
}
if let Some(ref sess) = session {
entries.retain(|e| {
e.session_id
.as_ref()
.map(|s| s.contains(sess))
.unwrap_or(false)
});
}
if let Some(ref since_str) = since {
if let Ok(since_date) = chrono::NaiveDate::parse_from_str(since_str, "%Y-%m-%d") {
let since_ts = since_date
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp_millis();
entries.retain(|e| e.timestamp >= since_ts);
}
}
if let Some(ref until_str) = until {
if let Ok(until_date) = chrono::NaiveDate::parse_from_str(until_str, "%Y-%m-%d") {
let until_ts = until_date
.and_hms_opt(23, 59, 59)
.unwrap()
.and_utc()
.timestamp_millis();
entries.retain(|e| e.timestamp <= until_ts);
}
}
entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
if let Some(limit) = limit {
entries.truncate(limit);
}
let mut writer = OutputWriter::new(std::io::stdout(), format);
match format {
OutputFormat::Json => {
writer.write_json(&entries)?;
}
OutputFormat::Raw | OutputFormat::Jsonl => {
for entry in &entries {
writer.write_json(entry)?;
}
}
OutputFormat::Table => {
let mut table = create_table();
table.set_header(vec!["Time", "Project", "Prompt"]);
for entry in &entries {
table.add_row(vec![
Cell::new(entry.formatted_time()),
Cell::new(entry.project_name().unwrap_or("-")),
Cell::new(truncate_string(&entry.display, 80)),
]);
}
writer.write_table(table)?;
writer.writeln(&format!("\nTotal: {} prompts", entries.len()))?;
}
}
Ok(())
}
pub async fn query(
config: &Config,
query_str: &str,
source: &str,
file_pattern: Option<String>,
format: OutputFormat,
) -> Result<()> {
let engine = QueryEngine::new();
let data = match source {
"history" => {
let ds = HistoryDataSource::new(config.clone());
ds.load_raw().await?
}
"jhistory" => {
let ds = CodexHistoryDataSource::new(config.clone());
ds.load_raw().await?
}
"codex_history" => {
let ds = CodexHistoryDataSource::new(config.clone());
ds.load_raw().await?
}
"transcripts" => {
let ds = TranscriptDataSource::new(config.clone());
let sessions = ds.load_all_sessions().await?;
let mut all = Vec::new();
for (session_id, entries) in sessions {
if let Some(ref pattern) = file_pattern {
if !session_id.contains(pattern) {
continue;
}
}
all.extend(entries);
}
all
}
"stats" => {
let ds = StatsDataSource::new(config.clone());
vec![ds.load_raw().await?]
}
"todos" => {
let ds = TodoDataSource::new(config.clone());
let files = ds.load_all().await?;
files
.into_iter()
.map(|f| {
serde_json::json!({
"workspace_id": f.workspace_id,
"agent_id": f.agent_id,
"todos": f.todos
})
})
.collect()
}
_ => {
return Err(crate::error::Error::DataSource(format!(
"Unknown source: {}. Use: history, jhistory, codex_history, transcripts, stats, todos",
source
)));
}
};
let results = engine.execute_on_array(query_str, data)?;
let mut writer = OutputWriter::new(std::io::stdout(), format);
match format {
OutputFormat::Json => {
writer.write_json(&results)?;
}
OutputFormat::Raw | OutputFormat::Jsonl => {
for result in &results {
writer.write_json(result)?;
}
}
OutputFormat::Table => {
for result in &results {
let json = serde_json::to_string_pretty(result)?;
writer.writeln(&json)?;
}
}
}
Ok(())
}
pub async fn sessions(
config: &Config,
_detailed: bool,
_project: Option<String>,
sort_by: &str,
format: OutputFormat,
) -> Result<()> {
let ds = TranscriptDataSource::new(config.clone());
let mut sessions = ds.list_sessions()?;
match sort_by {
"time" => sessions.sort_by(|a, b| b.modified.cmp(&a.modified)),
"size" => sessions.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes)),
_ => {}
}
let mut writer = OutputWriter::new(std::io::stdout(), format);
match format {
OutputFormat::Json => {
let data: Vec<_> = sessions
.iter()
.map(|s| {
serde_json::json!({
"session_id": s.session_id,
"size": s.size_bytes,
"modified": s.formatted_time()
})
})
.collect();
writer.write_json(&data)?;
}
OutputFormat::Raw | OutputFormat::Jsonl => {
for session in &sessions {
writer.write_json(&serde_json::json!({
"session_id": session.session_id,
"size": session.size_bytes,
"modified": session.formatted_time()
}))?;
}
}
OutputFormat::Table => {
let mut table = create_table();
table.set_header(vec!["Session ID", "Size", "Modified"]);
for session in &sessions {
table.add_row(vec![
Cell::new(&session.session_id),
Cell::new(session.size_human()),
Cell::new(session.formatted_time()),
]);
}
writer.write_table(table)?;
writer.writeln(&format!("\nTotal: {} sessions", sessions.len()))?;
}
}
Ok(())
}
pub async fn stats(
config: &Config,
group_by: &str,
_since: Option<String>,
_until: Option<String>,
format: OutputFormat,
) -> Result<()> {
let ds = StatsDataSource::new(config.clone());
let stats = ds.load().await?;
let mut writer = OutputWriter::new(std::io::stdout(), format);
match format {
OutputFormat::Json => {
writer.write_json(&stats)?;
}
OutputFormat::Raw | OutputFormat::Jsonl => {
writer.write_json(&stats)?;
}
OutputFormat::Table => {
writer.writeln("=== Claude Code Usage Statistics ===\n")?;
writer.writeln(&format!("Total Messages: {}", stats.total_messages))?;
writer.writeln(&format!("Total Sessions: {}", stats.total_sessions))?;
writer.writeln(&format!("Total Tokens: {}", stats.total_tokens()))?;
writer.writeln(&format!("First Session: {}", stats.first_session_date))?;
writer.writeln(&format!("Last Computed: {}", stats.last_computed_date))?;
if let Some(ref longest) = stats.longest_session {
writer.writeln(&format!(
"\nLongest Session: {} ({} messages)",
longest.session_id, longest.message_count
))?;
}
writer.writeln("\n--- Model Usage ---")?;
let mut model_table = create_table();
model_table.set_header(vec!["Model", "Input Tokens", "Output Tokens"]);
for (model, usage) in &stats.model_usage {
model_table.add_row(vec![
Cell::new(model),
Cell::new(usage.input_tokens),
Cell::new(usage.output_tokens),
]);
}
writer.write_table(model_table)?;
if group_by == "date" {
writer.writeln("\n--- Daily Activity (last 10 days) ---")?;
let mut daily_table = create_table();
daily_table.set_header(vec!["Date", "Messages", "Sessions", "Tool Calls"]);
for activity in stats.daily_activity.iter().rev().take(10) {
daily_table.add_row(vec![
Cell::new(&activity.date),
Cell::new(activity.message_count),
Cell::new(activity.session_count),
Cell::new(activity.tool_call_count),
]);
}
writer.write_table(daily_table)?;
}
}
}
Ok(())
}
pub async fn search(
config: &Config,
term: &str,
scope: &str,
case_sensitive: bool,
is_regex: bool,
_before_context: usize,
_after_context: usize,
format: OutputFormat,
) -> Result<()> {
let engine = SearchEngine::new(term, case_sensitive, is_regex)?;
let mut results = Vec::new();
if scope == "all" || scope == "prompts" {
let history = HistoryDataSource::new(config.clone());
let entries = history.load_all().await?;
for entry in entries {
if engine.matches(&entry.display) {
results.push(serde_json::json!({
"source": "history",
"timestamp": entry.timestamp,
"project": entry.project,
"content": entry.display
}));
}
}
}
if scope == "all" || scope == "transcripts" {
let transcripts = TranscriptDataSource::new(config.clone());
let sessions = transcripts.load_all_sessions().await?;
for (session_id, entries) in sessions {
for (idx, entry) in entries.iter().enumerate() {
if engine.find_in_json(entry) {
results.push(serde_json::json!({
"source": "transcript",
"session_id": session_id,
"entry_index": idx,
"content": entry
}));
}
}
}
}
let mut writer = OutputWriter::new(std::io::stdout(), format);
match format {
OutputFormat::Json => {
writer.write_json(&results)?;
}
OutputFormat::Raw | OutputFormat::Jsonl => {
for result in &results {
writer.write_json(result)?;
}
}
OutputFormat::Table => {
let mut table = create_table();
table.set_header(vec!["Source", "Location", "Match"]);
for result in &results {
let source = result["source"].as_str().unwrap_or("-");
let location = if source == "history" {
result["project"].as_str().unwrap_or("-").to_string()
} else {
format!(
"{}:{}",
result["session_id"].as_str().unwrap_or("-"),
result["entry_index"].as_u64().unwrap_or(0)
)
};
let content = if source == "history" {
result["content"].as_str().unwrap_or("").to_string()
} else {
truncate_string(&result["content"].to_string(), 60)
};
table.add_row(vec![
Cell::new(source),
Cell::new(&location),
Cell::new(truncate_string(&content, 60)),
]);
}
writer.write_table(table)?;
writer.writeln(&format!("\nFound: {} matches", results.len()))?;
}
}
Ok(())
}
pub async fn todos(
config: &Config,
status: Option<TodoStatus>,
agent: Option<String>,
format: OutputFormat,
) -> Result<()> {
let ds = TodoDataSource::new(config.clone());
let mut files = ds.load_all().await?;
if let Some(ref agent_filter) = agent {
files.retain(|f| f.agent_id.contains(agent_filter));
}
let mut writer = OutputWriter::new(std::io::stdout(), format);
match format {
OutputFormat::Json => {
let data: Vec<_> = files
.iter()
.map(|f| {
serde_json::json!({
"workspace_id": f.workspace_id,
"agent_id": f.agent_id,
"todos": f.todos
})
})
.collect();
writer.write_json(&data)?;
}
OutputFormat::Raw | OutputFormat::Jsonl => {
for file in &files {
writer.write_json(&serde_json::json!({
"workspace_id": file.workspace_id,
"agent_id": file.agent_id,
"todos": file.todos
}))?;
}
}
OutputFormat::Table => {
let mut table = create_table();
table.set_header(vec!["Agent", "Status", "Task"]);
let mut total = 0;
for file in &files {
for todo in &file.todos {
if let Some(ref status_filter) = status {
if &todo.status != status_filter {
continue;
}
}
total += 1;
table.add_row(vec![
Cell::new(truncate_string(&file.agent_id, 12)),
Cell::new(todo.status.to_string()),
Cell::new(truncate_string(&todo.content, 60)),
]);
}
}
writer.write_table(table)?;
writer.writeln(&format!("\nTotal: {} todos", total))?;
}
}
Ok(())
}
pub async fn duplicates(
config: &Config,
threshold: f64,
min_count: usize,
limit: usize,
show_variants: bool,
sort: &str,
min_length: usize,
format: OutputFormat,
) -> Result<()> {
use crate::dedup::FuzzyDeduper;
let history = HistoryDataSource::new(config.clone());
let entries = history.filter_prompts().await?;
let prompts: Vec<(String, i64)> = entries
.iter()
.map(|e| (e.display.clone(), e.timestamp))
.collect();
let deduper = FuzzyDeduper::new(threshold, min_length);
let mut clusters = deduper.cluster(prompts);
match sort {
"latest" => FuzzyDeduper::sort_by_latest(&mut clusters),
_ => FuzzyDeduper::sort_by_count(&mut clusters),
}
let filtered: Vec<_> = clusters
.into_iter()
.filter(|c| c.count >= min_count)
.take(limit)
.collect();
let mut writer = OutputWriter::new(std::io::stdout(), format);
match format {
OutputFormat::Json => {
let data: Vec<_> = filtered
.iter()
.map(|c| {
serde_json::json!({
"prompt": c.canonical,
"count": c.count,
"latest": c.latest_timestamp,
"variants": c.variants
})
})
.collect();
writer.write_json(&data)?;
}
OutputFormat::Raw | OutputFormat::Jsonl => {
for cluster in &filtered {
writer.write_json(&serde_json::json!({
"prompt": cluster.canonical,
"count": cluster.count,
"latest": cluster.latest_timestamp,
"variants": cluster.variants
}))?;
}
}
OutputFormat::Table => {
let mut table = create_table();
if sort == "latest" {
if show_variants {
table.set_header(vec!["Last Used", "Count", "Prompt", "Variants"]);
} else {
table.set_header(vec!["Last Used", "Count", "Prompt"]);
}
} else if show_variants {
table.set_header(vec!["Count", "Prompt", "Variants"]);
} else {
table.set_header(vec!["Count", "Prompt"]);
}
for cluster in &filtered {
let time_str = chrono::DateTime::from_timestamp_millis(cluster.latest_timestamp)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "-".to_string());
let variants_str = if cluster.variants.len() > 1 {
cluster
.variants
.iter()
.filter(|v| *v != &cluster.canonical)
.take(3)
.cloned()
.collect::<Vec<_>>()
.join(", ")
} else {
"-".to_string()
};
if sort == "latest" {
if show_variants {
table.add_row(vec![
Cell::new(&time_str),
Cell::new(cluster.count),
Cell::new(truncate_string(&cluster.canonical, 40)),
Cell::new(truncate_string(&variants_str, 30)),
]);
} else {
table.add_row(vec![
Cell::new(&time_str),
Cell::new(cluster.count),
Cell::new(truncate_string(&cluster.canonical, 60)),
]);
}
} else if show_variants {
table.add_row(vec![
Cell::new(cluster.count),
Cell::new(truncate_string(&cluster.canonical, 50)),
Cell::new(truncate_string(&variants_str, 40)),
]);
} else {
table.add_row(vec![
Cell::new(cluster.count),
Cell::new(truncate_string(&cluster.canonical, 70)),
]);
}
}
writer.write_table(table)?;
writer.writeln(&format!(
"\nShowing {} clusters (min count: {}, min length: {} chars, threshold: {:.0}%, sort: {})",
filtered.len(),
min_count,
min_length,
threshold * 100.0,
sort
))?;
}
}
Ok(())
}
pub async fn sql(
config: &Config,
query_str: &str,
write_enabled: bool,
dry_run: bool,
format: OutputFormat,
) -> Result<()> {
let options = SqlOptions {
write_enabled,
dry_run,
};
let mut engine = SqlEngine::new(config.clone(), options)?;
if dry_run && crate::sql::is_write_operation_public(query_str) {
let mut writer = OutputWriter::new(std::io::stdout(), format);
writer.writeln("[DRY RUN] Would execute:")?;
writer.writeln(query_str)?;
writer.writeln("\nNo changes made. Remove --dry-run to execute.")?;
return Ok(());
}
let results = engine.execute(query_str).await?;
let mut writer = OutputWriter::new(std::io::stdout(), format);
match format {
OutputFormat::Json => {
writer.write_json(&results)?;
}
OutputFormat::Raw | OutputFormat::Jsonl => {
for result in &results {
writer.write_json(result)?;
}
}
OutputFormat::Table => {
if results.is_empty() {
writer.writeln("No results.")?;
} else {
if let Some(first) = results.first() {
if let Some(obj) = first.as_object() {
let headers: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
let mut table = create_table();
table.set_header(headers.clone());
for result in &results {
if let Some(obj) = result.as_object() {
let row: Vec<Cell> = headers
.iter()
.map(|h| {
let val = obj.get(*h).unwrap_or(&serde_json::Value::Null);
let display = match val {
serde_json::Value::String(s) => truncate_string(s, 50),
serde_json::Value::Null => "-".to_string(),
_ => truncate_string(&val.to_string(), 50),
};
Cell::new(display)
})
.collect();
table.add_row(row);
}
}
writer.write_table(table)?;
writer.writeln(&format!("\n{} row(s)", results.len()))?;
} else {
for result in &results {
let json = serde_json::to_string_pretty(result)?;
writer.writeln(&json)?;
}
}
}
}
}
}
Ok(())
}