scryer-cli 0.2.0

CLI tool and shared daemon runner for Scryer code intelligence
//! CLI glue for the shared daemon: the thin stdio client, daemon control commands, and
//! routing `index` / `status` through a running daemon.

use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use rmcp::model::CallToolRequestParams;
use rmcp::service::RunningService;
use rmcp::{RoleClient, ServiceExt};
use scryer_mcp::daemon::{
    self, ControlCommand, DaemonPaths, Hello, HelloMode, client::spawn_daemon_process,
};
use serde_json::Value;

/// How long `scryer serve` waits for an auto-started daemon to accept connections.
const STARTUP_TIMEOUT: Duration = Duration::from_secs(15);

/// How long `scryer daemon stop` waits for the daemon to exit.
const STOP_TIMEOUT: Duration = Duration::from_secs(10);

/// An MCP client session on the daemon.
pub type DaemonClient = RunningService<RoleClient, ()>;

/// Proxy this process's stdio to the daemon for `db_path`, starting the daemon if needed.
pub async fn serve_stdio(db_path: &str, watch: bool, idle_timeout_secs: u64) -> anyhow::Result<()> {
    let paths = DaemonPaths::for_db(Path::new(db_path));
    let exe = std::env::current_exe()?;
    if let Err(e) = daemon::replace_stale_daemon(&paths).await {
        eprintln!("warning: could not replace the stale Scryer daemon: {e}");
    }
    let stream = daemon::connect_or_spawn(
        &paths,
        |paths| spawn_daemon_process(&exe, paths, idle_timeout_secs),
        STARTUP_TIMEOUT,
    )
    .await?;

    let hello = Hello::new(HelloMode::Mcp {
        cwd: current_dir(),
        watch,
    });
    let conn = daemon::handshake(stream, &hello).await?;
    warn_on_version_mismatch(&conn.reply.version, conn.reply.build.as_deref());

    daemon::pipe(
        tokio::io::stdin(),
        tokio::io::stdout(),
        conn.reader,
        conn.writer,
    )
    .await?;
    Ok(())
}

/// Run the daemon in the foreground.
pub async fn run(db_path: &str, idle_timeout_secs: u64) -> anyhow::Result<()> {
    if !daemon::is_local_db(db_path) {
        anyhow::bail!("The Scryer daemon serves local database files only, not '{db_path}'");
    }
    let idle_timeout = (idle_timeout_secs > 0).then(|| Duration::from_secs(idle_timeout_secs));
    daemon::run_daemon(DaemonPaths::for_db(Path::new(db_path)), idle_timeout).await
}

/// Print the daemon's status.
pub async fn status(db_path: &str) -> anyhow::Result<()> {
    let paths = DaemonPaths::for_db(Path::new(db_path));
    let Some(reply) = daemon::control(&paths, ControlCommand::Status).await? else {
        if daemon::daemon_running(&paths) {
            println!(
                "Scryer daemon for {} is starting up.",
                paths.db_path.display()
            );
        } else {
            println!(
                "No Scryer daemon is running for {}.",
                paths.db_path.display()
            );
        }
        return Ok(());
    };
    let Some(status) = reply.status else {
        anyhow::bail!("Scryer daemon returned no status");
    };

    println!(
        "Scryer daemon (pid {}, v{}, build {})",
        status.pid,
        status.version,
        status.build.as_deref().unwrap_or("unknown")
    );
    println!("Database: {}", status.db_path.display());
    println!("Socket:   {}", paths.socket.display());
    println!("Log:      {}", paths.log_file.display());
    println!("Uptime:   {}s", status.uptime_secs);
    println!("Sessions: {}", status.sessions);
    if status.watched.is_empty() {
        println!("Watching: nothing");
    } else {
        println!("Watching:");
        for project in status.watched {
            println!(
                "  - [{}] {} ({} director{})",
                project.project_id,
                project.root.display(),
                project.directories,
                if project.directories == 1 { "y" } else { "ies" }
            );
        }
    }
    warn_on_version_mismatch(&status.version, status.build.as_deref());
    Ok(())
}

/// Stop the daemon and wait for it to exit.
pub async fn stop(db_path: &str) -> anyhow::Result<()> {
    let paths = DaemonPaths::for_db(Path::new(db_path));
    if daemon::control(&paths, ControlCommand::Stop)
        .await?
        .is_none()
    {
        println!(
            "No Scryer daemon is running for {}.",
            paths.db_path.display()
        );
        return Ok(());
    }

    let deadline = Instant::now() + STOP_TIMEOUT;
    while daemon::daemon_running(&paths) {
        if Instant::now() >= deadline {
            anyhow::bail!("Scryer daemon did not exit within {STOP_TIMEOUT:?}");
        }
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
    println!("Stopped the Scryer daemon for {}.", paths.db_path.display());
    Ok(())
}

/// Open an MCP session on the daemon for `db_path`, if one is running.
pub async fn connect_client(db_path: &str) -> anyhow::Result<Option<DaemonClient>> {
    if !daemon::is_local_db(db_path) {
        return Ok(None);
    }
    let paths = DaemonPaths::for_db(Path::new(db_path));
    if let Err(e) = daemon::replace_stale_daemon(&paths).await {
        eprintln!("warning: could not replace the stale Scryer daemon: {e}");
    }
    let Ok(stream) = daemon::connect(&paths).await else {
        return Ok(None);
    };

    let hello = Hello::new(HelloMode::Mcp {
        cwd: current_dir(),
        watch: false,
    });
    let conn = daemon::handshake(stream, &hello).await?;
    warn_on_version_mismatch(&conn.reply.version, conn.reply.build.as_deref());
    let client = ().serve((conn.reader, conn.writer)).await?;
    Ok(Some(client))
}

/// Open an MCP session on the daemon for `db_path`, starting the daemon if needed.
pub async fn connect_or_spawn_client(
    db_path: &str,
    idle_timeout_secs: u64,
) -> anyhow::Result<DaemonClient> {
    if !daemon::is_local_db(db_path) {
        anyhow::bail!("The Scryer daemon serves local database files only, not '{db_path}'");
    }
    let paths = DaemonPaths::for_db(Path::new(db_path));
    let exe = std::env::current_exe()?;
    if let Err(e) = daemon::replace_stale_daemon(&paths).await {
        eprintln!("warning: could not replace the stale Scryer daemon: {e}");
    }
    let stream = daemon::connect_or_spawn(
        &paths,
        |paths| spawn_daemon_process(&exe, paths, idle_timeout_secs),
        STARTUP_TIMEOUT,
    )
    .await?;

    let hello = Hello::new(HelloMode::Mcp {
        cwd: current_dir(),
        watch: false,
    });
    let conn = daemon::handshake(stream, &hello).await?;
    warn_on_version_mismatch(&conn.reply.version, conn.reply.build.as_deref());
    let client = ().serve((conn.reader, conn.writer)).await?;
    Ok(client)
}

/// Call a Scryer tool on the daemon and return its text payload and whether it was an error.
pub async fn call_tool_raw(
    client: &DaemonClient,
    name: &str,
    args: Value,
) -> anyhow::Result<(String, bool)> {
    let Value::Object(args) = args else {
        anyhow::bail!("tool arguments must be a JSON object");
    };
    let result = client
        .call_tool(CallToolRequestParams::new(name.to_string()).with_arguments(args))
        .await?;
    let text = result
        .content
        .iter()
        .find_map(|block| block.as_text())
        .map(|text| text.text.to_string())
        .unwrap_or_default();
    let is_err = result.is_error.unwrap_or(false);
    Ok((text, is_err))
}

/// Call a Scryer tool on the daemon and parse its JSON payload.
pub async fn call_tool(client: &DaemonClient, name: &str, args: Value) -> anyhow::Result<Value> {
    let (text, is_err) = call_tool_raw(client, name, args).await?;
    if is_err {
        anyhow::bail!("{text}");
    }
    Ok(serde_json::from_str(&text)?)
}

fn current_dir() -> Option<PathBuf> {
    std::env::current_dir()
        .ok()
        .map(|dir| dunce::canonicalize(&dir).unwrap_or(dir))
}

fn warn_on_version_mismatch(daemon_version: &str, daemon_build: Option<&str>) {
    if daemon_version != scryer_mcp::mcp_version() {
        eprintln!(
            "warning: the running Scryer daemon is v{daemon_version} but this binary is v{}; \
             run `scryer daemon stop` to restart it on the next connection",
            scryer_mcp::mcp_version()
        );
    } else if daemon::is_stale_build(daemon_build) {
        eprintln!(
            "warning: the running Scryer daemon was started from a different build of this binary; \
             run `scryer daemon stop` to restart it on the next connection"
        );
    }
}