Luigit
repositories / smith

smith

There are many coding harnesses - but this one is fast

owned by admin

smith-cli/src/main.rs

Raw
#![forbid(unsafe_code)]
#![warn(missing_docs)]

//! Smith command-line entry point.
//!
//! Two modes today: `eval` runs one prompt through the agent and prints the
//! result; `rpc` speaks LF-delimited JSON-RPC on standard input and output.
//! Both use the same 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};

/// Every allocation is charged to the current lifetime scope (`SMH-SPEC-SPEC0001`, memory accounting).
#[global_allocator]
static ALLOC: smith_alloc::Counting = smith_alloc::Counting;

/// 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,
        /// 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>,
    },
    /// Serve LF-delimited JSON-RPC on standard input and output.
    Rpc {
        /// 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>,
    },
}

/// 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")]
fn main() -> 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 result = match cli.command {
        Command::Auth { action } => auth(&action),
        Command::Eval {
            prompt,
            json,
            r#mock,
            provider,
            model,
            base_url,
        } => eval(
            &prompt,
            json,
            r#mock,
            provider,
            model.as_deref(),
            base_url,
            &subcommand_sources(&matches, "eval"),
        ),
        Command::Rpc {
            provider,
            model,
            base_url,
        } => rpc(provider, model, base_url),
    };
    match result {
        Ok(()) => std::process::ExitCode::SUCCESS,
        Err(err) => {
            eprintln!("smith: {err}");
            std::process::ExitCode::FAILURE
        }
    }
}

#[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 secret = String::new();
            std::io::stdin()
                .lock()
                .read_line(&mut secret)
                .map_err(|e| smith::error::SmithError::Session {
                    code: "AUTH_READ".to_string(),
                    message: e.to_string(),
                })?;
            let secret = secret.trim_end_matches(['\r', '\n']);
            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 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"
)]
fn eval(
    prompt: &str,
    json: bool,
    mock: bool,
    selection: Option<ProviderArg>,
    model: Option<&str>,
    base_url: Option<String>,
    sources: &SelectionSources,
) -> Result<()> {
    let runtime_provider = provider(mock, selection, model.map(String::from), base_url)?;
    if let Some(provider) = selection
        && let Some(model) = &model
    {
        eprintln!("smith: {}", sources.describe(provider, model));
    }
    let dir = session_dir()?;
    let config = RuntimeConfig {
        provider: runtime_provider,
        workdir: dir.clone(),
        session_path: dir.join("eval.smh"),
        api_key: None,
        auth_dir: smith::config::auth_dir(),
    };
    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(())
}

fn rpc(
    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 dir = session_dir()?;
    let config = RuntimeConfig {
        provider: runtime_provider,
        workdir: dir.clone(),
        session_path: dir.join("rpc.smh"),
        api_key: None,
        auth_dir: smith::config::auth_dir(),
    };
    let mut state = RpcState::new(build_agent(&config)?);
    let (input_tx, input_rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        for line in std::io::stdin().lock().lines() {
            if input_tx.send(Some(line)).is_err() {
                return;
            }
        }
        let _ = 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(smith::error::SmithError::Session {
                    code: "RPC_STDIN".to_string(),
                    message: error.to_string(),
                });
            }
            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: "RPC_STDOUT".to_string(),
        message,
    }
}

fn session_dir() -> Result<std::path::PathBuf> {
    let config = smith::config::Config::default_valid();
    let dir = std::path::PathBuf::from(config.session_dir).join("run");
    std::fs::create_dir_all(&dir).map_err(|e| smith::error::SmithError::Session {
        code: "RUNTIME_DIR".to_string(),
        message: e.to_string(),
    })?;
    Ok(dir)
}