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());
}
}