#![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, /// Model id; required for real endpoints. #[arg(long, env = "SMITH_MODEL")] model: Option, /// Endpoint base URL override. #[arg(long, env = "SMITH_BASE_URL")] base_url: Option, }, /// 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, /// Model id; required for real endpoints. #[arg(long, env = "SMITH_MODEL")] model: Option, /// Endpoint base URL override. #[arg(long, env = "SMITH_BASE_URL")] base_url: Option, }, } /// 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, model: Option, base_url: Option, ) -> Result { 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, model: Option, base_url: Option, ) -> Result { 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, model: Option<&str>, base_url: Option, 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, model: Option, base_url: Option, ) -> 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 { 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) }