use std::path::PathBuf;
use devsql::{engine::detect_tables, UnifiedEngine};
use incurs::cli::Cli;
use incurs::command::{CommandContext, CommandDef, CommandHandler, Example};
use incurs::output::{CommandResult, Format};
use serde_json::Value;
#[derive(incurs::Args, serde::Deserialize)]
#[allow(dead_code)]
struct QueryArgs {
query: String,
}
#[derive(incurs::Options, serde::Deserialize)]
#[allow(dead_code)]
struct QueryOptions {
#[incur(alias = "r", default = ".")]
repo: String,
#[incur(alias = "d")]
data_dir: Option<String>,
#[incur(alias = "H")]
no_header: bool,
}
struct SqlHandler;
#[async_trait::async_trait]
impl CommandHandler for SqlHandler {
async fn run(&self, ctx: CommandContext) -> CommandResult {
let query = match ctx.args.get("query").and_then(|v| v.as_str()) {
Some(q) if !q.is_empty() => q.to_string(),
_ => {
return CommandResult::Error {
code: "MISSING_QUERY".to_string(),
message: "No SQL query provided. Run `devsql --help` for usage examples."
.to_string(),
retryable: false,
exit_code: Some(1),
cta: None,
};
}
};
let repo_str = ctx
.options
.get("repo")
.and_then(|v| v.as_str())
.unwrap_or(".");
let repo_path = if repo_str == "." {
match std::env::current_dir() {
Ok(p) => p,
Err(e) => {
return CommandResult::Error {
code: "PATH_ERROR".to_string(),
message: format!("Cannot determine current directory: {e}"),
retryable: false,
exit_code: Some(1),
cta: None,
};
}
}
} else {
PathBuf::from(repo_str)
};
let claude_dir = match ctx.options.get("data_dir").and_then(|v| v.as_str()) {
Some(d) => PathBuf::from(d),
None => dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".claude"),
};
let mut engine = match UnifiedEngine::new(claude_dir, repo_path) {
Ok(e) => e,
Err(e) => {
return CommandResult::Error {
code: "ENGINE_ERROR".to_string(),
message: format!("Failed to create engine: {e}"),
retryable: false,
exit_code: Some(1),
cta: None,
};
}
};
let (claude_tables, git_tables, code_tables) = detect_tables(&query);
let claude_refs: Vec<&str> = claude_tables.iter().map(|s| s.as_str()).collect();
let git_refs: Vec<&str> = git_tables.iter().map(|s| s.as_str()).collect();
let code_refs: Vec<&str> = code_tables.iter().map(|s| s.as_str()).collect();
if let Err(e) = engine.load_claude_tables(&claude_refs) {
return CommandResult::Error {
code: "LOAD_ERROR".to_string(),
message: format!("Failed to load Claude tables: {e}"),
retryable: false,
exit_code: Some(1),
cta: None,
};
}
if let Err(e) = engine.load_git_tables(&git_refs) {
return CommandResult::Error {
code: "LOAD_ERROR".to_string(),
message: format!("Failed to load Git tables: {e}"),
retryable: false,
exit_code: Some(1),
cta: None,
};
}
if let Err(e) = engine.load_code_tables(&code_refs) {
return CommandResult::Error {
code: "LOAD_ERROR".to_string(),
message: format!("Failed to load code tables: {e}"),
retryable: false,
exit_code: Some(1),
cta: None,
};
}
match engine.query(&query) {
Ok(results) => CommandResult::Ok {
data: Value::Array(results),
cta: None,
},
Err(e) => CommandResult::Error {
code: "QUERY_ERROR".to_string(),
message: format!("Query failed: {e}"),
retryable: false,
exit_code: Some(1),
cta: None,
},
}
}
}
fn build_cli() -> Cli {
Cli::create("devsql")
.description(
"Query your AI coding history to become a better prompter.\n\n\
Join Claude/Codex conversations with Git commits to find your most productive\n\
prompts, identify struggle sessions, and learn what actually works for you.",
)
.version(env!("CARGO_PKG_VERSION"))
.format(Format::Table)
.root(
CommandDef::build("devsql", SqlHandler)
.description("Execute a SQL query against your Claude/Codex + Git data")
.args::<QueryArgs>()
.options::<QueryOptions>()
.examples(vec![
Example {
command: r#""SELECT * FROM commits LIMIT 5""#.to_string(),
description: Some("List recent commits".to_string()),
},
Example {
command: r#""SELECT h.message, COUNT(c.id) as commits FROM history h LEFT JOIN commits c ON DATE(h.timestamp) = DATE(c.authored_at) GROUP BY h.message HAVING commits > 0 ORDER BY commits DESC LIMIT 10""#.to_string(),
description: Some("Most productive prompts".to_string()),
},
Example {
command: r#""SELECT DATE(h.timestamp) as day, COUNT(*) as prompts, COUNT(DISTINCT c.id) as commits FROM history h LEFT JOIN commits c ON DATE(h.timestamp) = DATE(c.authored_at) GROUP BY day ORDER BY prompts DESC LIMIT 10""#.to_string(),
description: Some("Struggle days".to_string()),
},
Example {
command: r#""SELECT datetime(timestamp/1000, 'unixepoch') as time, display FROM jhistory ORDER BY timestamp DESC LIMIT 10""#.to_string(),
description: Some("Recent Codex prompts".to_string()),
},
])
.hint(
"TABLES:\n \
Claude Code: history (prompts), transcripts (conversations), todos\n \
Codex CLI: jhistory / codex_history (session_id, ts, text, display, timestamp)\n \
Git: commits, diffs, diff_files, branches\n\n\
TELL YOUR AI AGENT:\n \
\"Use devsql to find my most effective prompts from the past month\"\n \
\"Query my history to find when I struggled most\"\n \
\"Analyze what my productive days have in common using devsql\"\n\n\
Learn more: https://github.com/douglance/devsql",
)
.done(),
)
.command("diff", devsql::tools::diff::build())
.command("search", devsql::tools::search::build())
.command("context", devsql::tools::context::build())
.command("history", devsql::tools::history::build())
.command("impact", devsql::tools::impact::build())
}
#[tokio::main]
async fn main() {
let cli = build_cli();
if let Err(e) = cli.serve().await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}