//! 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, /// 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, }, /// 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, }, /// 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, /// 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, }, } /// 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, 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 selected_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" )] #[expect( clippy::large_types_passed_by_value, reason = "Copy session ID enters runtime configuration without cloning" )] fn eval( prompt: &str, json: bool, session: Option, runtime_provider: RuntimeProvider, selection: Option, 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 { 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, 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 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 { 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, ) -> Result { 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, ) -> Result { 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 { 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::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()); } }