Luigit
repositories / smith

smith

There are many coding harnesses - but this one is fast

owned by admin

smith-cli/src/cli.rs

Raw
//! Command-line engine boundary (`SMH-SPEC-SPEC0001`).
//!
//! Commands include credential management, eval, read-only observation, session inspection, and JSON-RPC.
//! Eval and RPC use the shared runtime assembly from `smith-harness`.

use clap::{CommandFactory, FromArgMatches, Parser, ValueEnum};
use smith::error::Result;
use smith_harness::runtime::{RuntimeConfig, RuntimeProvider, build_agent};
use smith_harness::{AuthStore, ProviderKind, credential_env_var};
use smith_rpc::{RpcState, handle_line};
use std::io::{BufRead, Write};

/// Smith, a fast coding-agent runtime.
#[derive(Debug, Parser)]
#[command(name = "smith", version, about)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

/// Interface mode.
#[derive(Debug, Parser)]
enum Command {
    /// Manage stored provider credentials.
    Auth {
        #[command(subcommand)]
        action: AuthAction,
    },
    /// Run one prompt without the TUI.
    Eval {
        /// The prompt to run.
        prompt: String,
        /// Print the structured outcome as JSON.
        #[arg(long)]
        json: bool,
        /// Explicitly resume this existing session ID.
        #[arg(long)]
        session: Option<smith::id::SessionId>,
        /// Use the scripted mock provider instead of a real endpoint.
        #[arg(long)]
        r#mock: bool,
        /// Provider kind; required for real endpoints.
        #[arg(long, env = "SMITH_PROVIDER")]
        provider: Option<ProviderArg>,
        /// Model id; required for real endpoints.
        #[arg(long, env = "SMITH_MODEL")]
        model: Option<String>,
        /// Endpoint base URL override.
        #[arg(long, env = "SMITH_BASE_URL")]
        base_url: Option<String>,
    },
    /// Attach read-only to a session, including one owned by another writer.
    Observe {
        /// Session identity to observe without provider or tool initialization.
        id: smith::id::SessionId,
        /// Optional explicit export target; created only on 'e', never overwritten.
        #[arg(long)]
        export: Option<std::path::PathBuf>,
    },
    /// Inspect stored sessions without acquiring a writer or provider.
    Sessions {
        #[command(subcommand)]
        action: SessionAction,
    },
    /// Serve LF-delimited JSON-RPC on standard input and output.
    Rpc {
        /// Explicitly resume this existing session ID.
        #[arg(long)]
        session: Option<smith::id::SessionId>,
        /// Provider kind; the mock provider serves when unset.
        #[arg(long, env = "SMITH_PROVIDER")]
        provider: Option<ProviderArg>,
        /// Model id; required for real endpoints.
        #[arg(long, env = "SMITH_MODEL")]
        model: Option<String>,
        /// Endpoint base URL override.
        #[arg(long, env = "SMITH_BASE_URL")]
        base_url: Option<String>,
    },
}

/// Read-only inspection operations.
#[derive(Debug, Parser)]
enum SessionAction {
    /// List session IDs and file sizes in stable ID order.
    List {
        #[arg(long)]
        json: bool,
    },
    /// Read all recorded entries in physical order.
    Get {
        id: smith::id::SessionId,
        #[arg(long)]
        json: bool,
    },
    /// Search literal case-sensitive UTF-8 substring across all recorded branches.
    /// Exit 1 when no match; exit 2 on error.
    Grep {
        pattern: String,
        #[arg(long)]
        json: bool,
    },
}

/// Credential operations.
#[derive(Debug, Parser)]
enum AuthAction {
    /// Store a credential read from standard input.
    Add {
        /// Provider the credential belongs to.
        #[arg(long)]
        provider: ProviderArg,
    },
    /// List providers with stored credentials.
    List,
    /// Delete one stored credential.
    Remove {
        /// Provider whose credential is deleted.
        #[arg(long)]
        provider: ProviderArg,
    },
    /// Resolve one provider's credential without any network access.
    Check {
        /// Provider whose credential is checked.
        #[arg(long)]
        provider: ProviderArg,
    },
}

/// Provider names accepted on the command line.
#[derive(Copy, Clone, Debug, ValueEnum)]
enum ProviderArg {
    /// OpenAI-compatible endpoints.
    Openai,
    /// Anthropic Messages API.
    Anthropic,
    /// Google Gemini API.
    Google,
}

impl ProviderArg {
    const fn kind(self) -> ProviderKind {
        match self {
            Self::Openai => ProviderKind::OpenAiCompatible,
            Self::Anthropic => ProviderKind::Anthropic,
            Self::Google => ProviderKind::Google,
        }
    }
}

#[expect(clippy::print_stderr, reason = "the CLI reports failures on stderr")]
pub fn run() -> std::process::ExitCode {
    let matches = Cli::command().get_matches();
    let cli = match Cli::from_arg_matches(&matches) {
        Ok(cli) => cli,
        Err(error) => {
            error.exit();
        }
    };
    let inspection = matches!(cli.command, Command::Sessions { .. });
    let result = match cli.command {
        Command::Auth { action } => auth(&action).map(|()| true),
        Command::Sessions { action } => sessions(action),
        Command::Observe { id, export } => observe(id, export.as_ref()).map(|()| true),
        Command::Eval {
            prompt,
            json,
            session,
            r#mock,
            provider,
            model,
            base_url,
        } => selected_provider(r#mock, provider, model.clone(), base_url)
            .and_then(|runtime_provider| {
                eval(
                    &prompt,
                    json,
                    session,
                    runtime_provider,
                    provider,
                    model.as_deref(),
                    &subcommand_sources(&matches, "eval"),
                )
            })
            .map(|()| true),
        Command::Rpc {
            session,
            provider,
            model,
            base_url,
        } => rpc(session, provider, model, base_url).map(|()| true),
    };
    match result {
        Ok(true) | Err(smith::error::SmithError::Cancelled) if inspection => {
            std::process::ExitCode::SUCCESS
        }
        Ok(true) => std::process::ExitCode::SUCCESS,
        Ok(false) => std::process::ExitCode::FAILURE,
        Err(error) => {
            eprintln!("smith: {error}");
            std::process::ExitCode::from(if inspection { 2 } else { 1 })
        }
    }
}

#[expect(clippy::print_stdout, reason = "auth command output is the product")]
fn auth(action: &AuthAction) -> Result<()> {
    let store = AuthStore::at_default();
    match action {
        AuthAction::Add { provider } => {
            let mut stdin = std::io::stdin().lock();
            let line = read_stdin_line(&mut stdin, AUTH_SECRET_BYTES_MAX)
                .map_err(|error| auth_stdin_error(&error))?;
            drop(stdin);
            let secret = match line {
                StdinLine::Line(line) => line,
                StdinLine::Eof => String::new(),
            };
            if secret.is_empty() {
                return Err(smith::error::SmithError::Provider {
                    fault: smith::error::ProviderFault::Authentication {
                        message: "refusing to store an empty credential".to_string(),
                    },
                });
            }
            store.save(provider.kind(), &secret)?;
            println!("{}: stored", provider.kind().slug());
        }
        AuthAction::List => {
            for kind in store.list()? {
                println!("{}: store", kind.slug());
            }
        }
        AuthAction::Remove { provider } => {
            if store.remove(provider.kind())? {
                println!("{}: removed", provider.kind().slug());
            } else {
                return Err(smith::error::SmithError::Provider {
                    fault: smith::error::ProviderFault::Authentication {
                        message: format!("no stored credential for {}", provider.kind().slug()),
                    },
                });
            }
        }
        AuthAction::Check { provider } => {
            let kind = provider.kind();
            let var = credential_env_var(kind);
            if store.load(kind)?.is_some() {
                println!("{}: store", kind.slug());
            } else if std::env::var(var).is_ok_and(|value| !value.is_empty()) {
                println!("{}: env:{var}", kind.slug());
            } else {
                return Err(smith::error::SmithError::Provider {
                    fault: smith::error::ProviderFault::Authentication {
                        message: format!(
                            "no credential for {} (tried store, env {var})",
                            kind.slug()
                        ),
                    },
                });
            }
        }
    }
    Ok(())
}

/// Which surface supplied each selection option, for provenance reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OptionSource {
    /// An explicit command-line flag.
    Cli,
    /// A `SMITH_` environment variable.
    Env,
    /// Nothing supplied a value.
    Unset,
}

fn source_of(matches: &clap::ArgMatches, field: &str) -> OptionSource {
    match matches.value_source(field) {
        Some(clap::parser::ValueSource::CommandLine) => OptionSource::Cli,
        Some(clap::parser::ValueSource::EnvVariable) => OptionSource::Env,
        _ => OptionSource::Unset,
    }
}

fn subcommand_sources(matches: &clap::ArgMatches, name: &str) -> SelectionSources {
    let sub = matches.subcommand_matches(name);
    SelectionSources {
        provider: sub.map_or(OptionSource::Unset, |m| source_of(m, "provider")),
        model: sub.map_or(OptionSource::Unset, |m| source_of(m, "model")),
        base_url: sub.map_or(OptionSource::Unset, |m| source_of(m, "base_url")),
    }
}

/// Provenance of each selection option.
struct SelectionSources {
    provider: OptionSource,
    model: OptionSource,
    base_url: OptionSource,
}

impl SelectionSources {
    fn describe(&self, provider: ProviderArg, model: &str) -> String {
        let provider = provider.slug();
        format!(
            "provider={provider}({}) model={model}({}) base-url({})",
            self.provider.tag(),
            self.model.tag(),
            self.base_url.tag()
        )
    }
}

impl OptionSource {
    const fn tag(self) -> &'static str {
        match self {
            Self::Cli => "cli",
            Self::Env => "env",
            Self::Unset => "unset",
        }
    }
}

impl ProviderArg {
    const fn slug(self) -> &'static str {
        self.kind().slug()
    }
}

fn real_provider(
    provider: Option<ProviderArg>,
    model: Option<String>,
    base_url: Option<String>,
) -> Result<RuntimeProvider> {
    let Some(provider) = provider else {
        return Err(smith::error::SmithError::Provider {
            fault: smith::error::ProviderFault::Authentication {
                message: "no provider selected (tried --provider/SMITH_PROVIDER)".to_string(),
            },
        });
    };
    let Some(model) = model else {
        return Err(smith::error::SmithError::Provider {
            fault: smith::error::ProviderFault::Authentication {
                message: format!(
                    "no model selected for {} (tried --model/SMITH_MODEL)",
                    provider.slug()
                ),
            },
        });
    };
    Ok(RuntimeProvider::real(provider.kind(), base_url, model))
}

fn selected_provider(
    mock: bool,
    provider: Option<ProviderArg>,
    model: Option<String>,
    base_url: Option<String>,
) -> Result<RuntimeProvider> {
    if mock {
        return Ok(RuntimeProvider::Mock(vec![smith_harness::mock_text_reply(
            "mock provider: no model configured",
        )]));
    }
    real_provider(provider, model, base_url)
}

#[expect(clippy::print_stdout, reason = "eval output is the command's product")]
#[expect(
    clippy::print_stderr,
    reason = "selection provenance reports on stderr"
)]
#[expect(
    clippy::large_types_passed_by_value,
    reason = "Copy session ID enters runtime configuration without cloning"
)]
fn eval(
    prompt: &str,
    json: bool,
    session: Option<smith::id::SessionId>,
    runtime_provider: RuntimeProvider,
    selection: Option<ProviderArg>,
    model: Option<&str>,
    sources: &SelectionSources,
) -> Result<()> {
    if let Some(provider) = selection
        && let Some(model) = &model
    {
        eprintln!("smith: {}", sources.describe(provider, model));
    }
    let config = runtime_config(runtime_provider, session)?;
    let mut agent = build_agent(&config)?;
    let outcome = {
        let _turn = smith_alloc::enter(smith_alloc::Scope::Turn);
        agent.run_turn(prompt)
    }?;
    if json {
        println!(
            "{}",
            serde_json::json!({
                "text": outcome.text,
                "reason": format!("{:?}", outcome.reason),
                "cost": {
                    "input_tokens": outcome.cost.input_tokens,
                    "output_tokens": outcome.cost.output_tokens,
                },
            })
        );
    } else {
        println!("{}", outcome.text);
    }
    Ok(())
}

/// Largest credential accepted from standard input, matching the auth store.
const AUTH_SECRET_BYTES_MAX: usize = 64 * 1024;

/// Largest JSON-RPC request line, matching Smith's default frame policy.
const RPC_INPUT_LINE_BYTES_MAX: usize = 1024 * 1024;

/// Lines queued from stdin while the RPC loop drains responses.
const RPC_INPUT_QUEUE_LINES: usize = 64;

#[derive(Debug)]
enum StdinLine {
    Line(String),
    Eof,
}

/// Read one UTF-8 line without allocating beyond `bytes_max`.
fn read_stdin_line(reader: &mut impl BufRead, bytes_max: usize) -> std::io::Result<StdinLine> {
    let mut bytes = Vec::new();
    loop {
        let buffer = reader.fill_buf()?;
        if buffer.is_empty() {
            return if bytes.is_empty() {
                Ok(StdinLine::Eof)
            } else {
                String::from_utf8(bytes)
                    .map(StdinLine::Line)
                    .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))
            };
        }
        let line_end = buffer.iter().position(|byte| *byte == b'\n');
        let text = &buffer[..line_end.unwrap_or(buffer.len())];
        if bytes.len().saturating_add(text.len()) > bytes_max {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                format!("standard input line exceeds {bytes_max} bytes"),
            ));
        }
        let consumed_bytes = text.len() + usize::from(line_end.is_some());
        bytes.extend_from_slice(text);
        reader.consume(consumed_bytes);
        if line_end.is_some() {
            if bytes.last() == Some(&b'\r') {
                bytes.pop();
            }
            return String::from_utf8(bytes)
                .map(StdinLine::Line)
                .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error));
        }
    }
}

fn auth_stdin_error(error: &std::io::Error) -> smith::error::SmithError {
    let message = if error.kind() == std::io::ErrorKind::InvalidData
        && error.to_string() == format!("standard input line exceeds {AUTH_SECRET_BYTES_MAX} bytes")
    {
        format!(
            "credential input exceeds {AUTH_SECRET_BYTES_MAX} bytes; provide one credential no larger than the auth-store limit"
        )
    } else {
        error.to_string()
    };
    smith::error::SmithError::Session {
        code: smith::error::SessionCode::AuthRead,
        message,
    }
}

fn rpc_stdin_error(error: &std::io::Error) -> smith::error::SmithError {
    let message = if error.kind() == std::io::ErrorKind::InvalidData
        && error.to_string()
            == format!("standard input line exceeds {RPC_INPUT_LINE_BYTES_MAX} bytes")
    {
        format!(
            "JSON-RPC request exceeds {RPC_INPUT_LINE_BYTES_MAX} bytes; split the request into smaller requests"
        )
    } else {
        error.to_string()
    };
    smith::error::SmithError::Session {
        code: smith::error::SessionCode::RpcStdin,
        message,
    }
}

#[expect(
    clippy::large_types_passed_by_value,
    reason = "Copy session ID enters runtime configuration without cloning"
)]
fn rpc(
    session: Option<smith::id::SessionId>,
    provider: Option<ProviderArg>,
    model: Option<String>,
    base_url: Option<String>,
) -> Result<()> {
    let runtime_provider = match provider {
        Some(provider) => real_provider(Some(provider), model, base_url)?,
        None => RuntimeProvider::Mock(vec![smith_harness::mock_text_reply(
            "rpc mock provider ready",
        )]),
    };
    let config = runtime_config(runtime_provider, session)?;
    let mut state = RpcState::new(build_agent(&config)?);
    let (input_tx, input_rx) = std::sync::mpsc::sync_channel(RPC_INPUT_QUEUE_LINES);
    std::thread::spawn(move || {
        {
            let mut stdin = std::io::stdin().lock();
            loop {
                let line = match read_stdin_line(&mut stdin, RPC_INPUT_LINE_BYTES_MAX) {
                    Ok(StdinLine::Line(line)) => Ok(line),
                    Ok(StdinLine::Eof) => break,
                    Err(error) => Err(error),
                };
                let errored = line.is_err();
                if input_tx.send(Some(line)).is_err() || errored {
                    return;
                }
            }
            drop(stdin);
        }
        // The consumer may close after stdin reaches EOF.
        let _end_send_result = input_tx.send(None);
    });

    let mut stdout = std::io::stdout();
    let mut input_closed = false;
    loop {
        match input_rx.recv_timeout(std::time::Duration::from_millis(10)) {
            Ok(Some(Ok(line))) => {
                for response in handle_line(&mut state, &line) {
                    stdout
                        .write_all(response.as_bytes())
                        .map_err(rpc_stdout_error)?;
                }
            }
            Ok(Some(Err(error))) => return Err(rpc_stdin_error(&error)),
            Ok(None) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
                input_closed = true;
            }
            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
        }
        for response in state.drain_lines() {
            stdout
                .write_all(response.as_bytes())
                .map_err(rpc_stdout_error)?;
        }
        stdout.flush().map_err(rpc_stdout_error)?;
        if input_closed && state.is_settled() {
            for response in state.drain_lines() {
                stdout
                    .write_all(response.as_bytes())
                    .map_err(rpc_stdout_error)?;
            }
            return Ok(());
        }
    }
}

fn rpc_stdout_error(error: std::io::Error) -> smith::error::SmithError {
    let message = error.to_string();
    drop(error);
    smith::error::SmithError::Session {
        code: smith::error::SessionCode::RpcStdout,
        message,
    }
}

fn observe(id: smith::id::SessionId, export: Option<&std::path::PathBuf>) -> Result<()> {
    let dir = session_dir()?;
    let path = session_path(&dir, Some(id))?;
    smith_harness::observer::run(id, path, export)
}

fn sessions(action: SessionAction) -> Result<bool> {
    use smith_harness::inspection;
    let dir =
        std::path::PathBuf::from(smith::config::Config::default_valid().session_dir).join("run");
    let mut stdout = std::io::stdout().lock();
    match action {
        SessionAction::List { json } => {
            // Proquint text needs no JSON escaping, so both forms stream
            // through Display without an intermediate string.
            inspection::list(&dir, |summary| {
                let (id, bytes) = (summary.id, summary.bytes);
                if json {
                    writeln!(stdout, "{{\"session\":\"{id}\",\"bytes\":{bytes}}}")
                } else {
                    writeln!(stdout, "{id} {bytes} bytes")
                }
                .map_err(|e| session_stdout_error(&e))
            })?;
            Ok(true)
        }
        SessionAction::Get { id, json } => {
            let path = dir.join(format!("{id}.smh"));
            inspection::entries(&path, |entry, content| {
                inspection::output(&mut stdout, id, entry, content, json, true)
                    .map_err(|e| session_stdout_error(&e))
            })?;
            Ok(true)
        }
        SessionAction::Grep { pattern, json } => {
            let mut found = false;
            inspection::list(&dir, |summary| {
                let id = summary.id;
                let path = dir.join(format!("{id}.smh"));
                inspection::entries(&path, |entry, content| {
                    if inspection::contains(content, &pattern) {
                        found = true;
                        inspection::output(&mut stdout, id, entry, content, json, false)
                            .map_err(|e| session_stdout_error(&e))?;
                    }
                    Ok(())
                })
            })?;
            Ok(found)
        }
    }
}

fn session_stdout_error(error: &std::io::Error) -> smith::error::SmithError {
    if error.kind() == std::io::ErrorKind::BrokenPipe {
        smith::error::SmithError::Cancelled
    } else {
        smith::error::SmithError::Session {
            code: smith::error::SessionCode::SessionStdout,
            message: error.to_string(),
        }
    }
}

#[expect(
    clippy::large_types_passed_by_value,
    reason = "Copy session ID is consumed once to form the runtime path"
)]
fn runtime_config(
    provider: RuntimeProvider,
    session: Option<smith::id::SessionId>,
) -> Result<RuntimeConfig> {
    let dir = session_dir()?;
    Ok(RuntimeConfig {
        session_path: session_path(&dir, session)?,
        provider,
        workdir: dir,
        api_key: None,
        auth_dir: smith::config::auth_dir(),
    })
}

#[expect(
    clippy::large_types_passed_by_value,
    reason = "Copy session ID is inspected once when constructing its path"
)]
fn session_path(
    dir: &std::path::Path,
    selected: Option<smith::id::SessionId>,
) -> Result<std::path::PathBuf> {
    let path = dir.join(format!("{}.smh", selected.unwrap_or_default()));
    if selected.is_some() && !path.is_file() {
        return Err(smith::error::SmithError::Session {
            code: smith::error::SessionCode::SessionNotFound,
            message: format!("session does not exist: {}", path.display()),
        });
    }
    Ok(path)
}

fn session_dir() -> Result<std::path::PathBuf> {
    let config = smith::config::Config::default_valid();
    create_session_dir(std::path::PathBuf::from(config.session_dir).join("run"))
}

fn create_session_dir(dir: std::path::PathBuf) -> Result<std::path::PathBuf> {
    std::fs::create_dir_all(&dir).map_err(|error| smith::error::SmithError::Storage {
        code: smith::error::StorageCode::RuntimeDir,
        operation: smith::error::StorageOperation::CreateDirAll,
        path: dir.clone(),
        message: error.to_string(),
        publication: None,
    })?;
    Ok(dir)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn bounded_stdin_line_distinguishes_eof_limit_newline_and_utf8() {
        let at_limit = b"four\n";
        assert!(matches!(
            read_stdin_line(&mut &at_limit[..], 4),
            Ok(StdinLine::Line(line)) if line == "four"
        ));
        let over_limit = b"fives";
        assert!(read_stdin_line(&mut &over_limit[..], 4).is_err());
        let no_newline = b"four";
        assert!(matches!(
            read_stdin_line(&mut &no_newline[..], 4),
            Ok(StdinLine::Line(line)) if line == "four"
        ));
        assert!(matches!(
            read_stdin_line(&mut &b""[..], 4),
            Ok(StdinLine::Eof)
        ));
        assert_eq!(
            read_stdin_line(&mut &b"\xff\n"[..], 4)
                .expect_err("invalid UTF-8 is rejected")
                .kind(),
            std::io::ErrorKind::InvalidData
        );
    }

    #[test]
    fn session_directory_creation_reports_storage_path_and_operation() {
        let root =
            std::env::temp_dir().join(format!("smith_cli_session_dir_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        assert!(std::fs::write(&root, "block directory creation").is_ok());
        let attempted = root.join("run");
        let error = create_session_dir(attempted.clone()).expect_err("file blocks directory");
        assert_eq!(error.code(), "RUNTIME_DIR");
        assert!(
            error
                .to_string()
                .contains("storage [RUNTIME_DIR] create_dir_all")
        );
        match &error {
            smith::error::SmithError::Storage {
                code,
                operation,
                path,
                ..
            } => {
                assert_eq!(*code, smith::error::StorageCode::RuntimeDir);
                assert_eq!(operation.as_str(), "create_dir_all");
                assert_eq!(path, &attempted);
            }
            other => panic!("expected storage error, got {other:?}"),
        }
        let serialized = serde_json::to_value(&error).expect("serialize error");
        assert_eq!(serialized["Storage"]["code"], "RUNTIME_DIR");
        assert_eq!(
            serialized["Storage"]["path"],
            attempted.to_string_lossy().as_ref()
        );
        assert!(std::fs::remove_file(root).is_ok());
    }
}