devsql 0.7.0

Code Mode across AI coding history, shell history, Git, and source code
//! DevSQL CLI - Unified SQL queries across developer-local data
//!
//! Built on the incurs framework, giving devsql all built-in CLI features:
//! --help, --version, --llms, --llms-full, --mcp, --json, --format,
//! --filter-output, --verbose, shell completions, and skills.

use std::sync::Arc;

use devsql::query_help::schema_request;
use incurs::cli::Cli;
use incurs::command::{CommandDef, Example, TypedContext, TypedResult};
use incurs::output::{CtaBlock, CtaEntry};
use incurs_codemode::{
    CodeMode, CodeModeRunOptions, CodeModeService, ExecutionState, IncurConnector, MemoryStore,
    SearchOutput,
};
use incurs_codemode_local::{LocalCodeModeService, LocalExecutor};
use incurs_extras::{CliExtras, ExtraFormat};
use serde_json::Value;

#[derive(Clone)]
struct DurableCodeModeService(LocalCodeModeService);

fn durable_options(options: CodeModeRunOptions) -> CodeModeRunOptions {
    CodeModeRunOptions {
        request: options.request,
        ..CodeModeRunOptions::default()
    }
}

#[async_trait::async_trait]
impl CodeModeService for DurableCodeModeService {
    async fn search(&self, query: String) -> Result<SearchOutput, String> {
        self.0.search(query).await
    }

    async fn execute(
        &self,
        code: String,
        options: CodeModeRunOptions,
    ) -> Result<ExecutionState, String> {
        self.0.execute(code, durable_options(options)).await
    }

    async fn execution(&self, execution_id: String) -> Result<ExecutionState, String> {
        self.0.execution(execution_id).await
    }

    async fn artifact(&self, execution_id: String, artifact_id: String) -> Result<Value, String> {
        self.0.artifact(execution_id, artifact_id).await
    }

    async fn approve(
        &self,
        execution_id: String,
        seq: u64,
        options: CodeModeRunOptions,
    ) -> Result<ExecutionState, String> {
        self.0
            .approve(execution_id, seq, durable_options(options))
            .await
    }

    async fn reject(&self, execution_id: String, seq: u64) -> Result<ExecutionState, String> {
        self.0.reject(execution_id, seq).await
    }

    async fn cancel(&self, execution_id: String) -> Result<ExecutionState, String> {
        self.0.cancel(execution_id).await
    }
}

// ---------------------------------------------------------------------------
// Schemas (derive macros replace manual FieldMeta construction)
// ---------------------------------------------------------------------------

#[derive(incurs::Args, serde::Deserialize)]
#[allow(dead_code)]
struct QueryArgs {
    /// SQL query to execute
    query: String,
}

#[derive(incurs::Options, serde::Deserialize)]
#[allow(dead_code)]
struct QueryOptions {
    /// Git repository path
    #[incurs(alias = "r", default = ".")]
    repo: String,
    /// Claude data directory (defaults to ~/.claude)
    #[incurs(alias = "d")]
    data_dir: Option<String>,
    /// Relative macOS Unified Log window (defaults to 15m)
    log_last: Option<String>,
    /// Absolute macOS Unified Log start time (requires --log-end)
    log_start: Option<String>,
    /// Absolute macOS Unified Log end time (requires --log-start)
    log_end: Option<String>,
    /// Additional macOS Unified Log NSPredicate
    log_predicate: Option<String>,
    /// Read a .logarchive instead of the live macOS log datastore
    log_archive: Option<String>,
    /// macOS Unified Log level: standard, info, or debug
    #[incurs(default = "standard")]
    log_level: String,
    /// Maximum macOS Unified Log records to scan
    #[incurs(default = 50000)]
    log_max_rows: usize,
    /// Maximum macOS Unified Log scan duration in seconds
    #[incurs(default = 30)]
    log_timeout: u64,
}

// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------

type QueryOutput = Vec<Value>;

async fn run_query(ctx: TypedContext<QueryArgs, QueryOptions, ()>) -> TypedResult<QueryOutput> {
    match tokio::task::spawn_blocking(move || run_query_blocking(ctx)).await {
        Ok(result) => result,
        Err(error) => {
            TypedResult::error("QUERY_TASK_ERROR", format!("Query worker failed: {error}"))
        }
    }
}

fn run_query_blocking(ctx: TypedContext<QueryArgs, QueryOptions, ()>) -> TypedResult<QueryOutput> {
    let query = ctx.args.query;
    if let Some(request) = schema_request(&query) {
        return devsql::tools::schema::answer(
            request,
            &ctx.options.repo,
            ctx.options.data_dir.as_deref(),
        );
    }
    let (mut engine, _) = match devsql::tools::engine_from_paths(
        &ctx.options.repo,
        ctx.options.data_dir.as_deref(),
    ) {
        Ok(value) => value,
        Err(error) => return error.into_typed(),
    };

    let macos_logs = devsql::providers::macos_logs::MacosLogConfig {
        last: ctx.options.log_last,
        start: ctx.options.log_start,
        end: ctx.options.log_end,
        predicate: ctx.options.log_predicate,
        archive: ctx.options.log_archive,
        level: ctx.options.log_level,
        max_rows: ctx.options.log_max_rows,
        timeout: std::time::Duration::from_secs(ctx.options.log_timeout),
    };
    if let Err(error) = devsql::tools::load_query_tables(&mut engine, &query, Some(macos_logs)) {
        return error.into_typed();
    }

    match engine.query(&query) {
        Ok(results) => {
            if let Some((rows, reason)) = engine.macos_log_truncation() {
                TypedResult::Ok {
                    data: results,
                    exit_code: None,
                    cta: Some(CtaBlock {
                        commands: vec![CtaEntry::Detailed {
                            command: "query <SQL> --log-last 5m".to_string(),
                            description: Some(
                                "Rerun with a narrower window or predicate".to_string(),
                            ),
                        }],
                        description: Some(format!(
                            "macos_logs returned a partial scan after {rows} rows: {reason}. Narrow the time window or predicate, or raise the relevant log bound."
                        )),
                    }),
                }
            } else {
                TypedResult::ok(results)
            }
        }
        Err(e) => {
            let message = e.to_string();
            match engine.explain_error(&query, &message) {
                Some(help) => TypedResult::Error {
                    code: "QUERY_ERROR".into(),
                    message: format!("Query failed: {help}"),
                    retryable: false,
                    exit_code: Some(1),
                    cta: Some(CtaBlock {
                        commands: vec![CtaEntry::Detailed {
                            command: "schema <table>".to_string(),
                            description: Some("Show a table's columns".to_string()),
                        }],
                        description: None,
                    }),
                },
                None => TypedResult::error("QUERY_ERROR", format!("Query failed: {message}")),
            }
        }
    }
}

fn query_command(name: &str, description: &str) -> CommandDef {
    CommandDef::typed::<QueryArgs, QueryOptions, (), QueryOutput, _, _>(name, run_query)
        .description(description)
        .examples(query_examples())
        .hint(query_hint())
        .mcp(devsql::tools::read_only_mcp())
        .done()
}

fn query_examples() -> Vec<Example> {
    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()),
        },
        Example {
            command: r#""SELECT thread_id, cwd, last_event_at FROM codex_threads ORDER BY last_event_at DESC LIMIT 10""#.to_string(),
            description: Some("Recent Codex conversations".to_string()),
        },
        Example {
            command: r#""SELECT source, timestamp, command FROM shell_history ORDER BY timestamp DESC LIMIT 10""#.to_string(),
            description: Some("Recent Atuin, zsh, and bash commands".to_string()),
        },
        Example {
            command: r#""SELECT timestamp, process, subsystem, category, message FROM macos_logs WHERE process = 'ExampleApp' ORDER BY timestamp DESC LIMIT 100" --log-last 10m --log-level info"#.to_string(),
            description: Some("Recent macOS Unified Log events".to_string()),
        },
    ]
}

fn query_hint() -> &'static str {
    "PRIMARY AGENT INTERFACE:\n  devsql --mcp                 # five-tool Code Mode server\n  codemode_search              # discover devsql.* methods\n  codemode_execute             # run JavaScript across one or more methods\n  codemode_execution           # inspect a durable execution\n  codemode_decide / cancel     # execution decisions or cancellation\n\n  The direct CLI below is the human and scripting fallback.\n\nTABLES:\n  Claude Code:  history (prompts), transcripts (conversations), sessions (per-session stats), todos\n  Codex CLI:    jhistory / codex_history, codex_threads, codex_messages, codex_events,\n                codex_tool_executions / codex_tool_calls, codex_compactions, codex_ingest_errors\n  Grok Bots:    grok_bots, grok_entries, grok_messages, grok_ingest_errors (global; see devsql grok)\n  Git:          commits, diffs, diff_files, branches\n  Shell:        shell_history (Atuin, zsh, bash), command_events (shell + agent commands)\n  macOS:        macos_logs (bounded live or .logarchive stream with provenance)\n\n  devsql schema [table]        # every table, or one table's real columns\n\nGROK BOTS (explicit search target, never in recall/gather):\n  devsql grok status | search <terms> | sync\n\nTELL YOUR AI AGENT:\n  \"Use DevSQL Code Mode to find my most effective prompts from the past month\"\n  \"Use DevSQL Code Mode to inspect my recent commands and commits\"\n\nLearn more: https://github.com/douglance/devsql"
}

// ---------------------------------------------------------------------------
// CLI construction
// ---------------------------------------------------------------------------

fn build_cli() -> Cli {
    Cli::create("devsql")
        .description(
            "Code Mode is the primary agent interface for querying AI coding, Git, source code, \
             shell history and macOS Unified Logs.\n\n\
             Run `devsql --mcp` for the Code Mode server. Use the direct CLI for human queries \
             and scripts.",
        )
        .version(env!("CARGO_PKG_VERSION"))
        .default_extra_format(ExtraFormat::Table)
        .root(query_command(
            "devsql",
            "Run a direct SQL query. For agents, prefer the Code Mode server: `devsql --mcp`.",
        ))
        .command(
            "query",
            query_command("query", "Execute a SQL query against developer-local data"),
        )
        .command("schema", devsql::tools::schema::build())
        .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())
        .command("recall", devsql::tools::recall::build())
        .command("gather", devsql::tools::gather::build())
        .group(devsql::tools::grok::build_group())
}

fn code_mode_requested() -> bool {
    let mut args = std::env::args_os().skip(1);
    args.next().is_some_and(|arg| arg == "--mcp") && args.next().is_none()
}

async fn serve_code_mode(cli: &Cli) -> Result<(), String> {
    let connector = Arc::new(
        IncurConnector::new(cli.tool_catalog())
            .with_name("devsql")
            .with_instructions(
                "Use devsql.query to inspect existing records across sources; use devsql.gather when prior work and repository context should be loaded together. Devsql observes history and does not manage tasks.",
            ),
    );
    let service = LocalCodeModeService::spawn(move || {
        CodeMode::new(
            Arc::new(MemoryStore::default()),
            LocalExecutor::default(),
            vec![connector],
        )
    })?;
    incurs_codemode_mcp::serve_stdio(Arc::new(DurableCodeModeService(service))).await
}

/// Accept `-f <format>` for `--format <format>`. Agents carry the short form
/// over from other SQL CLIs; incurs defines only the long one. Tokens after
/// `--` pass through untouched.
fn normalized_argv(args: impl Iterator<Item = String>) -> Vec<String> {
    let mut out = Vec::new();
    let mut passthrough = false;
    for arg in args {
        if passthrough {
            out.push(arg);
            continue;
        }
        passthrough = arg == "--";
        match arg.strip_prefix("-f") {
            Some("") => out.push("--format".to_string()),
            Some(rest) if rest.starts_with('=') => out.push(format!("--format{rest}")),
            _ => out.push(arg),
        }
    }
    out
}

#[tokio::main]
async fn main() {
    let cli = build_cli();
    let result = if code_mode_requested() {
        serve_code_mode(&cli)
            .await
            .map_err(|error| -> Box<dyn std::error::Error> { error.into() })
    } else {
        cli.serve_with(normalized_argv(std::env::args().skip(1)))
            .await
    };
    if let Err(e) = result {
        eprintln!("Error: {e}");
        std::process::exit(1);
    }
}