From fa542605d7e5c3dc1b4cd835094da1bb9a414ba4 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 20 Feb 2026 12:48:19 -0400 Subject: [PATCH] Add ullm CLI for unified-llm library New crate `unified-llm-cli` with binary `ullm` providing: - `prompt` command: generate text via streaming/non-streaming, with system prompts, options (-o temperature=0.5), stdin piping, and token usage display - `models list` command: browse catalog models with --provider and --query filters - Auto-detects provider from catalog for correct API routing Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 295 ++++++++++++++++++++ Cargo.toml | 1 + crates/unified-llm-cli/Cargo.toml | 27 ++ crates/unified-llm-cli/src/main.rs | 422 +++++++++++++++++++++++++++++ 4 files changed, 745 insertions(+) create mode 100644 crates/unified-llm-cli/Cargo.toml create mode 100644 crates/unified-llm-cli/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 2a702e07f..02da2ded9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,86 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -25,6 +99,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -37,6 +117,17 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.1" @@ -65,6 +156,66 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "coding-agent-loop" +version = "0.1.0" +dependencies = [ + "async-trait", + "futures", + "serde", + "serde_json", + "thiserror", + "tokio", + "unified-llm", + "uuid", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "core-foundation" version = "0.9.4" @@ -91,6 +242,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "displaydoc" version = "0.2.5" @@ -145,6 +302,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -587,6 +753,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -682,12 +854,33 @@ dependencies = [ "tempfile", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.75" @@ -797,6 +990,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -870,6 +1093,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + [[package]] name = "reqwest" version = "0.12.28" @@ -1128,6 +1380,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1199,6 +1457,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -1405,6 +1669,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "unified-llm-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "dotenvy", + "futures", + "predicates", + "serde_json", + "tokio", + "tokio-stream", + "unified-llm", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -1429,6 +1709,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.21.0" @@ -1446,6 +1732,15 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index ed773b712..496dc91a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,3 +30,4 @@ async-trait = "0.1" base64 = "0.22" bytes = "1" tokio-util = "0.7" +clap = { version = "4", features = ["derive"] } diff --git a/crates/unified-llm-cli/Cargo.toml b/crates/unified-llm-cli/Cargo.toml new file mode 100644 index 000000000..99cc44ffc --- /dev/null +++ b/crates/unified-llm-cli/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "unified-llm-cli" +description = "CLI for the unified-llm library" +edition.workspace = true +version.workspace = true +license.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "ullm" +path = "src/main.rs" + +[dependencies] +unified-llm = { path = "../unified-llm" } +clap = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +anyhow = { workspace = true } +dotenvy = { workspace = true } +futures = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" diff --git a/crates/unified-llm-cli/src/main.rs b/crates/unified-llm-cli/src/main.rs new file mode 100644 index 000000000..ffb154d1d --- /dev/null +++ b/crates/unified-llm-cli/src/main.rs @@ -0,0 +1,422 @@ +use std::io::{self, IsTerminal, Read}; + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use futures::StreamExt; +use unified_llm::catalog; +use unified_llm::generate::{self, GenerateParams}; + +#[derive(Parser)] +#[command(name = "ullm")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Execute a prompt + Prompt { + /// The prompt text (also accepts stdin) + prompt: Option, + + /// Model to use + #[arg(short, long)] + model: Option, + + /// System prompt + #[arg(short, long)] + system: Option, + + /// Do not stream output + #[arg(long)] + no_stream: bool, + + /// Show token usage + #[arg(short, long)] + usage: bool, + + /// key=value options (temperature, `max_tokens`, `top_p`) + #[arg(short, long, value_parser = parse_option)] + option: Vec<(String, String)>, + }, + + /// Manage models + Models { + #[command(subcommand)] + command: Option, + }, +} + +#[derive(Subcommand)] +enum ModelsCommand { + /// List available models + List { + /// Filter by provider + #[arg(short, long)] + provider: Option, + + /// Search for models matching this string + #[arg(short, long)] + query: Option, + }, +} + +fn parse_option(s: &str) -> Result<(String, String), String> { + let (key, value) = s + .split_once('=') + .ok_or_else(|| format!("expected key=value, got {s}"))?; + Ok((key.to_string(), value.to_string())) +} + +fn print_models_table(models: &[unified_llm::types::ModelInfo]) { + println!( + "{:<30} {:<12} {:<30} {:>14}", + "ID", "PROVIDER", "ALIASES", "CONTEXT" + ); + for model in models { + let aliases = model.aliases.join(", "); + println!( + "{:<30} {:<12} {:<30} {:>14}", + model.id, model.provider, aliases, model.context_window + ); + } +} + +fn read_stdin_prompt() -> Option { + let stdin = io::stdin(); + if stdin.is_terminal() { + return None; + } + let mut buf = String::new(); + stdin.lock().read_to_string(&mut buf).ok()?; + let trimmed = buf.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn resolve_prompt(arg: Option, stdin: Option) -> Result { + match (stdin, arg) { + (Some(s), Some(a)) => Ok(format!("{s}\n{a}")), + (Some(s), None) => Ok(s), + (None, Some(a)) => Ok(a), + (None, None) => bail!("Error: no prompt provided. Pass a prompt as an argument or pipe text via stdin."), + } +} + +/// Returns (model_id, provider) from the catalog, falling back to the first catalog model. +fn resolve_model(model_arg: Option) -> (String, Option) { + let model_id = model_arg.unwrap_or_else(|| { + catalog::list_models(None) + .first() + .map_or_else(|| "claude-sonnet-4-5".to_string(), |m| m.id.clone()) + }); + let provider = catalog::get_model_info(&model_id).map(|info| info.provider); + (model_id, provider) +} + +fn apply_options( + mut params: GenerateParams, + options: &[(String, String)], +) -> Result { + let mut provider_opts = serde_json::Map::new(); + + for (key, value) in options { + match key.as_str() { + "temperature" => { + let v: f64 = value + .parse() + .with_context(|| format!("invalid temperature value: {value}"))?; + params = params.temperature(v); + } + "max_tokens" => { + let v: i64 = value + .parse() + .with_context(|| format!("invalid max_tokens value: {value}"))?; + params = params.max_tokens(v); + } + "top_p" => { + let v: f64 = value + .parse() + .with_context(|| format!("invalid top_p value: {value}"))?; + params = params.top_p(v); + } + _ => { + provider_opts.insert(key.clone(), serde_json::Value::String(value.clone())); + } + } + } + + if !provider_opts.is_empty() { + params = params.provider_options(serde_json::Value::Object(provider_opts)); + } + + Ok(params) +} + +async fn run_prompt( + prompt_arg: Option, + model: Option, + system: Option, + no_stream: bool, + show_usage: bool, + options: Vec<(String, String)>, +) -> Result<()> { + let stdin_prompt = read_stdin_prompt(); + let prompt_text = resolve_prompt(prompt_arg, stdin_prompt)?; + let (model_id, provider) = resolve_model(model); + + let mut params = GenerateParams::new(&model_id).prompt(&prompt_text); + if let Some(p) = provider { + params = params.provider(&p); + } + if let Some(sys) = system { + params = params.system(&sys); + } + params = apply_options(params, &options)?; + + if no_stream { + let result = generate::generate(params).await?; + print!("{}", result.text()); + if show_usage { + let usage = result.usage(); + eprintln!( + "Tokens: {} input, {} output, {} total", + usage.input_tokens, usage.output_tokens, usage.total_tokens + ); + } + } else { + let mut stream_result = generate::stream(params).await?; + while let Some(event) = stream_result.next().await { + if let unified_llm::types::StreamEvent::TextDelta { delta, .. } = event? { + print!("{delta}"); + } + } + println!(); + if show_usage { + if let Some(response) = stream_result.response() { + let usage = &response.usage; + eprintln!( + "Tokens: {} input, {} output, {} total", + usage.input_tokens, usage.output_tokens, usage.total_tokens + ); + } + } + } + + Ok(()) +} + +fn main() -> Result<()> { + dotenvy::dotenv().ok(); + let cli = Cli::parse(); + + match cli.command { + Command::Prompt { + prompt, + model, + system, + no_stream, + usage, + option, + } => { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(run_prompt(prompt, model, system, no_stream, usage, option))?; + } + Command::Models { command } => { + let command = command.unwrap_or(ModelsCommand::List { + provider: None, + query: None, + }); + match command { + ModelsCommand::List { provider, query } => { + let mut models = catalog::list_models(provider.as_deref()); + + if let Some(ref q) = query { + let q_lower = q.to_lowercase(); + models.retain(|m| { + m.id.to_lowercase().contains(&q_lower) + || m.display_name.to_lowercase().contains(&q_lower) + || m.aliases + .iter() + .any(|a| a.to_lowercase().contains(&q_lower)) + }); + } + + print_models_table(&models); + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use assert_cmd::Command; + use predicates::prelude::*; + + #[allow(deprecated)] // cargo_bin_cmd! macro has known issues + fn ullm() -> Command { + Command::cargo_bin("ullm").unwrap() + } + + // Step 1: models list prints all catalog models + #[test] + fn models_list_prints_all_models() { + ullm() + .args(["models", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("claude-opus-4-6")) + .stdout(predicate::str::contains("claude-sonnet-4-5")) + .stdout(predicate::str::contains("gpt-5.2")) + .stdout(predicate::str::contains("gemini-3-pro-preview")) + .stdout(predicate::str::contains("anthropic")) + .stdout(predicate::str::contains("openai")) + .stdout(predicate::str::contains("gemini")); + } + + // Step 2: models list --provider filters to that provider only + #[test] + fn models_list_filters_by_provider() { + let assert = ullm() + .args(["models", "list", "--provider", "anthropic"]) + .assert() + .success() + .stdout(predicate::str::contains("claude-opus-4-6")) + .stdout(predicate::str::contains("claude-sonnet-4-5")); + + // Should NOT contain other providers + assert + .stdout(predicate::str::contains("gpt-5.2").not()) + .stdout(predicate::str::contains("gemini-3-pro-preview").not()); + } + + // Step 3: models list --query does substring match on id/name/aliases + #[test] + fn models_list_filters_by_query() { + ullm() + .args(["models", "list", "--query", "opus"]) + .assert() + .success() + .stdout(predicate::str::contains("claude-opus-4-6")) + .stdout(predicate::str::contains("claude-sonnet-4-5").not()); + } + + #[test] + fn models_list_query_is_case_insensitive() { + ullm() + .args(["models", "list", "--query", "OPUS"]) + .assert() + .success() + .stdout(predicate::str::contains("claude-opus-4-6")); + } + + #[test] + fn models_list_query_matches_aliases() { + ullm() + .args(["models", "list", "--query", "codex"]) + .assert() + .success() + .stdout(predicate::str::contains("gpt-5.2-codex")); + } + + // Step 4: bare "models" defaults to list + #[test] + fn models_bare_defaults_to_list() { + ullm() + .args(["models"]) + .assert() + .success() + .stdout(predicate::str::contains("claude-opus-4-6")) + .stdout(predicate::str::contains("gpt-5.2")) + .stdout(predicate::str::contains("gemini-3-pro-preview")); + } + + // Step 5: prompt requires prompt text (errors when no prompt and stdin is tty) + #[test] + fn prompt_errors_without_prompt_text() { + // assert_cmd provides no stdin by default (simulating a tty-like "empty pipe") + // We pass an empty stdin to avoid tty detection + ullm() + .args(["prompt"]) + .write_stdin("") + .assert() + .failure() + .stderr(predicate::str::contains("no prompt provided")); + } + + // Step 9: stdin piping — reads from stdin when no prompt arg + #[test] + fn prompt_reads_from_stdin() { + // This test verifies stdin is read, but will fail at the API call stage + // since no API key is set. The error should NOT be "no prompt provided". + let result = ullm() + .args(["prompt", "--no-stream", "-m", "test-model"]) + .write_stdin("hello from stdin") + .assert() + .failure(); + + // Should NOT complain about missing prompt + result.stderr(predicate::str::contains("no prompt provided").not()); + } + + // Step 9b: stdin + arg concatenation + #[test] + fn prompt_concatenates_stdin_and_arg() { + // Same as above — verifies it doesn't error on "no prompt" + let result = ullm() + .args(["prompt", "--no-stream", "-m", "test-model", "summarize this"]) + .write_stdin("some input text") + .assert() + .failure(); + + result.stderr(predicate::str::contains("no prompt provided").not()); + } + + // Step 10: -o option parsing + #[test] + fn prompt_rejects_bad_option_format() { + ullm() + .args(["prompt", "-o", "bad_option", "hello"]) + .assert() + .failure() + .stderr(predicate::str::contains("expected key=value")); + } + + // Step 6/7/8: Integration tests gated behind API key + #[test] + #[ignore] + fn prompt_no_stream_generates_response() { + ullm() + .args(["prompt", "--no-stream", "-m", "claude-sonnet-4-5", "Say just the word 'hello'"]) + .assert() + .success() + .stdout(predicate::str::is_empty().not()); + } + + #[test] + #[ignore] + fn prompt_stream_generates_response() { + ullm() + .args(["prompt", "-m", "claude-sonnet-4-5", "Say just the word 'hello'"]) + .assert() + .success() + .stdout(predicate::str::is_empty().not()); + } + + #[test] + #[ignore] + fn prompt_usage_shows_tokens() { + ullm() + .args(["prompt", "--no-stream", "-u", "-m", "claude-sonnet-4-5", "Say just the word 'hello'"]) + .assert() + .success() + .stderr(predicate::str::contains("Tokens:")); + } +}