#[cfg(test)]
extern crate hrdr_test_support;
use std::io::Write;
use std::time::Duration;
use anyhow::Result;
use clap::{Parser, Subcommand};
use hrdr_agent::{Agent, AgentConfig, AgentEvent};
const LOGO_ART: &str = include_str!("../art.txt");
fn colour_stderr() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| {
use std::io::IsTerminal;
std::io::stderr().is_terminal()
&& std::env::var_os("NO_COLOR").is_none_or(|v| v.is_empty())
&& std::env::var_os("TERM").is_none_or(|t| t != "dumb")
})
}
fn chrome_line(colour: crossterm::style::Color, styled: &str, rest: &str) {
use crossterm::style::{Print, ResetColor, SetForegroundColor};
let mut err = std::io::stderr();
if colour_stderr() {
let _ = crossterm::execute!(
err,
SetForegroundColor(colour),
Print(styled),
ResetColor,
Print(rest),
Print("\n"),
);
} else {
let _ = writeln!(err, "{styled}{rest}");
}
}
fn chrome_fragment(colour: crossterm::style::Color, text: &str) {
use crossterm::style::{Print, ResetColor, SetForegroundColor};
let mut err = std::io::stderr();
if colour_stderr() {
let _ = crossterm::execute!(err, SetForegroundColor(colour), Print(text), ResetColor);
} else {
let _ = write!(err, "{text}");
}
let _ = err.flush();
}
#[derive(Parser)]
#[command(
name = "hrdr",
version,
about = "hrdr — herder: a fast, agentic coding harness for OpenAI-compatible models.",
before_help = LOGO_ART,
// `hrdr run …` / `hrdr models` are subcommands; anything else trailing is a
// command for the TUI to run at startup. Subcommand names always win — even
// after a global flag (`hrdr --model X run "hi"` is a headless run), which
// is why `args_conflicts_with_subcommands` is NOT set: clap then stops
// recognizing subcommand names once any flag has been parsed. The mutual
// exclusion survives anyway: once the trailing `input` starts consuming,
// clap's `trailing_var_arg` swallows every later word, so a TUI command and
// a subcommand can never both be present.
subcommand_precedence_over_arg = true,
)]
struct Cli {
#[arg(long, global = true, value_name = "PROVIDER://MODEL|MODEL")]
model: Option<String>,
#[arg(long, global = true)]
vim: bool,
#[arg(long, global = true)]
theme: Option<String>,
#[arg(long, global = true)]
effort: Option<String>,
#[arg(
long = "subagent-model",
global = true,
value_name = "PROVIDER://MODEL|MODEL"
)]
subagent_model: Option<String>,
#[arg(long = "agent", global = true, value_name = "NAME")]
agent: Option<String>,
#[arg(long = "memory-dir", global = true, value_name = "DIR")]
memory_dir: Option<std::path::PathBuf>,
#[arg(long, global = true)]
auto_compact: Option<String>,
#[arg(long, global = true)]
compaction_reserved: Option<u32>,
#[arg(long, global = true, value_name = "N")]
max_readonly_subagents: Option<usize>,
#[arg(long, global = true, value_name = "N")]
max_write_subagents: Option<usize>,
#[arg(long, global = true, value_name = "write|read|jail|none")]
sandbox: Option<String>,
#[arg(
long = "sandbox-writable-root",
global = true,
value_name = "PATH",
action = clap::ArgAction::Append
)]
sandbox_writable_root: Vec<std::path::PathBuf>,
#[arg(long = "no-sandbox", global = true, conflicts_with = "sandbox")]
no_sandbox: bool,
#[arg(
long = "yolo",
global = true,
conflicts_with_all = ["sandbox", "no_sandbox"]
)]
yolo: bool,
#[arg(long = "prompt-cache", global = true, value_name = "off|on|auto")]
prompt_cache: Option<String>,
#[arg(long = "no-auto-resume", global = true)]
no_auto_resume: bool,
#[arg(long = "no-bell", global = true)]
no_bell: bool,
#[arg(long, global = true)]
icons: Option<String>,
#[arg(long, global = true)]
statusbar: Option<String>,
#[arg(long, global = true)]
todo_ttl: Option<u64>,
#[arg(long, global = true)]
session_compress_after: Option<u64>,
#[arg(long, global = true)]
session_purge_after: Option<u64>,
#[arg(long, value_enum, value_name = "SHELL", hide = true)]
completions: Option<CompletionShell>,
#[arg(long, hide = true)]
man: bool,
#[command(subcommand)]
command: Option<Command>,
#[arg(trailing_var_arg = true, value_name = "COMMAND")]
input: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum CompletionShell {
Bash,
Zsh,
Fish,
Powershell,
Elvish,
Nushell,
}
impl CompletionShell {
fn generate(self, cmd: &mut clap::Command) {
use clap_complete::Shell;
let out = &mut std::io::stdout();
match self {
CompletionShell::Bash => clap_complete::generate(Shell::Bash, cmd, "hrdr", out),
CompletionShell::Zsh => clap_complete::generate(Shell::Zsh, cmd, "hrdr", out),
CompletionShell::Fish => clap_complete::generate(Shell::Fish, cmd, "hrdr", out),
CompletionShell::Powershell => {
clap_complete::generate(Shell::PowerShell, cmd, "hrdr", out)
}
CompletionShell::Elvish => clap_complete::generate(Shell::Elvish, cmd, "hrdr", out),
CompletionShell::Nushell => {
clap_complete::generate(clap_complete_nushell::Nushell, cmd, "hrdr", out)
}
}
}
}
#[derive(Subcommand)]
enum Command {
Run {
#[arg(long)]
json: bool,
#[arg(long)]
quiet: bool,
#[arg(long, value_name = "N")]
max_steps: Option<usize>,
#[arg(long, value_name = "USD")]
max_cost: Option<f64>,
#[arg(long)]
allow_unpriced: bool,
#[arg(trailing_var_arg = true, required = true)]
prompt: Vec<String>,
},
Models,
}
fn settle_identity(
store: &hrdr_agent::LastModels,
specs: &[hrdr_agent::ModelSpec],
config: &AgentConfig,
) -> Result<hrdr_agent::ModelRef> {
let mut identity = store.last.clone().unwrap_or_else(|| {
hrdr_agent::DEFAULT_MODEL_REF
.parse()
.expect("a valid default identity")
});
for spec in specs {
identity = match spec.apply(&identity) {
Some(r) => r,
None => {
let provider = spec.provider().expect("ProviderOnly names a provider");
hrdr_agent::model_for_provider_in(store, provider, config)?
}
};
}
Ok(identity)
}
async fn startup_checks(config: &AgentConfig, listing: bool) -> Result<()> {
let resolved = hrdr_agent::oauth_derived(hrdr_agent::ResolvedModel::from_config(config));
let verdict = hrdr_agent::validate_identity(&resolved, config);
let already_known = match &verdict {
hrdr_agent::Identity::Known(w) => w.clone(),
hrdr_agent::Identity::Unconfirmed(_) => Vec::new(),
};
for w in hrdr_agent::confirm_identity(verdict).await? {
if !already_known.contains(&w) {
eprintln!("{w}");
}
}
if !listing && resolved.reference().model() == hrdr_agent::PLACEHOLDER_MODEL {
let probe = hrdr_llm::Client::new(
resolved.base_url().to_string(),
resolved.api_key().map(str::to_string),
hrdr_agent::PLACEHOLDER_MODEL.to_string(),
);
let advertised = tokio::time::timeout(Duration::from_secs(3), probe.list_models())
.await
.ok()
.and_then(Result::ok);
hrdr_agent::validate_placeholder_model(resolved.reference(), advertised.as_deref())?;
}
Ok(())
}
#[cfg(windows)]
fn run_sandbox_exec_wrapper() -> Option<Result<std::process::ExitCode>> {
use anyhow::Context as _;
let mut argv = std::env::args_os().skip(1);
if argv.next()? != hrdr_tools::sandbox::SANDBOX_EXEC_ARG {
return None;
}
Some((|| {
let rest: Vec<std::ffi::OsString> = argv.skip_while(|a| a == "--").collect();
let (program, args) = rest
.split_first()
.context("__sandbox-exec: no program after `--`")?;
hrdr_tools::sandbox::lower_current_process_to_low_integrity()
.context("__sandbox-exec: could not lower this process to Low integrity")?;
let status = std::process::Command::new(program)
.args(args)
.status()
.with_context(|| format!("__sandbox-exec: spawning {}", program.display()))?;
Ok(std::process::ExitCode::from(
u8::try_from(status.code().unwrap_or(1)).unwrap_or(1),
))
})())
}
#[tokio::main]
async fn main() -> Result<()> {
#[cfg(windows)]
if let Some(result) = run_sandbox_exec_wrapper() {
let code = result?;
std::process::exit(match code == std::process::ExitCode::SUCCESS {
true => 0,
false => 1,
});
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()),
)
.with_writer(std::io::stderr)
.init();
let cli = Cli::parse();
if let Some(shell) = cli.completions {
use clap::CommandFactory;
shell.generate(&mut Cli::command());
return Ok(());
}
if cli.man {
use clap::CommandFactory;
clap_mangen::Man::new(Cli::command()).render(&mut std::io::stdout())?;
return Ok(());
}
if let Err(e) = hrdr_agent::check_config_compat() {
eprintln!("{e}");
std::process::exit(2);
}
let (mut config, config_diags) = AgentConfig::load_diagnosed();
let (mut ui, ui_warnings) = hrdr_app::UiConfig::load_diagnosed();
if let Some(msg) = config_diags.error_message() {
eprintln!("{msg}");
std::process::exit(2);
}
let config_warnings: Vec<String> = config_diags
.warnings
.into_iter()
.chain(ui_warnings)
.collect();
let store = hrdr_agent::load_last_models();
let cli_spec = cli
.model
.as_deref()
.map(str::parse::<hrdr_agent::ModelSpec>)
.transpose()
.map_err(|e| anyhow::anyhow!("--model {}: {e}", cli.model.clone().unwrap_or_default()))?;
let named_specs = hrdr_agent::named_model_specs();
let specs: Vec<hrdr_agent::ModelSpec> =
named_specs.iter().chain(cli_spec.iter()).cloned().collect();
let identity = settle_identity(&store, &specs, &config)?;
let name = identity.provider().as_str().to_string();
let p = config.resolve_provider(&name).ok_or_else(|| {
anyhow::anyhow!(
"unknown provider '{name}' (built-ins: {}; or define [providers.{name}] in config)",
hrdr_agent::BUILTIN_PROVIDERS.join(", ")
)
})?;
config.base_url = p.base_url.clone();
let auth_state = hrdr_agent::provider_auth_state(&name, &p, None, None);
if let Some(key) = hrdr_agent::resolve_api_key_or_public(&name, &p, None, None) {
config.api_key = Some(key);
} else if config.api_key.is_none() && auth_state == hrdr_agent::ProviderAuthState::Missing {
let env = p.key_env.as_deref().unwrap_or("HRDR_API_KEY");
eprintln!("hrdr: provider '{name}' needs an API key — set ${env}, or run /login");
}
if let Some(var) = hrdr_agent::api_key_env_source(&p) {
eprintln!(
"hrdr: using the API key from ${var} (environment) for '{name}' — overrides any /login credential"
);
}
if config.context_window.is_none() && p.base_url != hrdr_agent::CHATGPT_CODEX_BASE_URL {
config.context_window = p.context_window;
}
config.headers = p.headers.into_iter().collect();
config.api_version = p.api_version;
let remote_provider = p.remote;
config.model = identity;
if cli.vim {
ui.vim_mode = true;
}
if let Some(t) = cli.theme {
ui.theme = Some(t);
}
if let Some(e) = cli.effort {
config.effort = Some(e);
}
if let Some(m) = cli.subagent_model {
config.subagent_model = Some(
m.parse()
.map_err(|e| anyhow::anyhow!("--subagent-model {m}: {e}"))?,
);
}
if let Some(d) = cli.memory_dir {
config.memory_dir = Some(d);
}
if let Some(name) = cli.agent.as_deref() {
let profiles = hrdr_agent::resolve_agent_profiles(&config)?;
let profile = profiles
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| {
let names: Vec<&str> = profiles.iter().map(|p| p.name.as_str()).collect();
anyhow::anyhow!("unknown --agent '{name}' (available: {})", names.join(", "))
})?;
config = hrdr_agent::config_for_agent_profile(&config, profile)?;
}
if let Some(b) = cli
.auto_compact
.as_deref()
.and_then(hrdr_agent::parse_toggle_or_num)
{
config.auto_compact = b;
}
if let Some(n) = cli.compaction_reserved {
config.compaction_reserved = n;
}
if let Some(n) = cli.max_readonly_subagents {
config.max_readonly_subagents = n;
}
if let Some(n) = cli.max_write_subagents {
config.max_write_subagents = n;
}
if let Some(s) = cli.sandbox.as_deref() {
match s.parse::<hrdr_tools::SandboxMode>() {
Ok(m) => config.sandbox = m,
Err(e) => eprintln!("warning: --sandbox: {e} — keeping {}", config.sandbox),
}
}
if cli.no_sandbox || cli.yolo {
config.sandbox = hrdr_tools::SandboxMode::None;
}
config
.sandbox_writable_roots
.extend(cli.sandbox_writable_root);
if cli.no_auto_resume {
ui.auto_resume = false;
}
if cli.no_bell {
ui.bell = false;
}
if let Some(i) = cli.icons {
ui.icons = Some(i);
}
if let Some(s) = cli.statusbar {
ui.statusbar = Some(s);
}
if let Some(p) = cli.prompt_cache {
config.prompt_cache = Some(p);
}
if let Some(n) = cli.todo_ttl {
ui.todo_ttl = n;
}
if let Some(n) = cli.session_compress_after {
config.session_compress_after = Some(n);
}
if let Some(n) = cli.session_purge_after {
config.session_purge_after = Some(n);
}
if remote_provider && config.has_default_model() {
eprintln!(
"hrdr: set a model with --model (run `hrdr models` to list this provider's models)"
);
}
let listing = matches!(cli.command, Some(Command::Models));
if let Err(e) = startup_checks(&config, listing).await {
eprintln!("hrdr: {e:#}");
std::process::exit(2);
}
if config.context_window.is_none() {
if config.base_url == hrdr_agent::CHATGPT_CODEX_BASE_URL {
config.context_window = hrdr_agent::context_window_for(
Some(config.model.provider().as_str()),
&config.base_url,
config.model.model(),
);
}
}
if cli.command.is_some() {
for w in &config_warnings {
eprintln!("hrdr: {w}");
}
}
match trust_gate(&config.cwd, cli.command.is_some(), ui.theme.as_deref()) {
TrustGate::Proceed => {}
TrustGate::Jail => {
config.sandbox = hrdr_tools::SandboxMode::Jail;
config.read_only = true;
}
TrustGate::Stop => return Ok(()),
}
match cli.command {
Some(Command::Run {
json,
quiet,
max_steps,
max_cost,
allow_unpriced,
prompt,
}) => {
if let Some(cost) = max_cost
&& (!cost.is_finite() || cost < 0.0)
{
anyhow::bail!("--max-cost must be a finite, non-negative number");
}
if let Some(n) = max_steps {
config.max_steps = n;
}
if max_cost.is_some() {
config.max_cost = max_cost;
}
if allow_unpriced {
config.allow_unpriced = true;
}
run_headless(config, prompt.join(" "), json, quiet).await
}
Some(Command::Models) => list_models(config).await,
None => {
let command = (!cli.input.is_empty()).then(|| cli.input.join(" "));
hrdr_tui::run(config, ui, LOGO_ART, command).await
}
}
}
enum TrustGate {
Proceed,
Jail,
Stop,
}
fn trust_gate(cwd: &std::path::Path, headless: bool, theme: Option<&str>) -> TrustGate {
trust_gate_with(cwd, headless, hrdr_agent::trust::is_trusted(cwd), |c| {
ask_to_trust(c, theme)
})
}
fn trust_gate_with(
cwd: &std::path::Path,
headless: bool,
trusted: bool,
ask: impl FnOnce(&std::path::Path) -> hrdr_agent::trust::TrustChoice,
) -> TrustGate {
use hrdr_agent::trust;
if trusted {
return TrustGate::Proceed;
}
if headless {
eprintln!(
"hrdr: {} is not a trusted directory — running in jail mode (read-only, no shell).\n\
hrdr: open hrdr here interactively once to decide.",
cwd.display()
);
return TrustGate::Jail;
}
match ask(cwd) {
trust::TrustChoice::Trusted => {
if let Err(e) = trust::trust(cwd) {
eprintln!("hrdr: could not record this directory as trusted: {e:#}");
eprintln!("hrdr: continuing for this session; you will be asked again.");
}
TrustGate::Proceed
}
trust::TrustChoice::Untrusted => {
eprintln!("hrdr: opening jailed — read-only tools, no shell, no project instructions.");
TrustGate::Jail
}
trust::TrustChoice::Cancel => TrustGate::Stop,
}
}
fn ask_to_trust(cwd: &std::path::Path, theme: Option<&str>) -> hrdr_agent::trust::TrustChoice {
hrdr_tui::ask_trust(cwd, LOGO_ART, theme)
}
async fn run_headless(config: AgentConfig, prompt: String, json: bool, quiet: bool) -> Result<()> {
let mut config = config;
if config.context_window.is_none()
&& config.base_url != hrdr_agent::CHATGPT_CODEX_BASE_URL
&& hrdr_agent::context_window_for(
Some(config.model.provider().as_str()),
&config.base_url,
config.model.model(),
)
.is_none()
{
let probe = hrdr_llm::Client::new(
config.base_url.clone(),
config.api_key.clone(),
config.model.model().to_string(),
);
config.context_window =
tokio::time::timeout(Duration::from_secs(3), probe.context_window())
.await
.ok()
.flatten();
}
let mut agent = Agent::new(config)?;
let todos = agent.todos_owned();
let (prompt, inlined) =
hrdr_app::prepare_outgoing_tracked(&prompt, agent.agent_names(), &agent.cwd(), &todos);
agent.mark_files_read(&inlined);
for notice in agent.connect_mcp().await {
if !quiet {
chrome_line(
crossterm::style::Color::DarkGrey,
&format!("[{notice}]"),
"",
);
}
}
for note in agent
.run_session_hooks(hrdr_tools::HookEvent::SessionStart)
.await
{
if !quiet {
chrome_line(crossterm::style::Color::DarkGrey, &format!("[{note}]"), "");
}
}
let steering = hrdr_agent::steering_queue();
steering
.lock()
.unwrap()
.push_back(hrdr_agent::Steer::plain(prompt));
let result = agent
.run(steering, |ev| {
if json {
println!("{}", event_json(&ev));
let _ = std::io::stdout().flush();
return;
}
match ev {
AgentEvent::Text(t) => {
print!("{t}");
let _ = std::io::stdout().flush();
}
AgentEvent::Reasoning(_) => {}
AgentEvent::ToolStart { name, args, .. } if !quiet => {
chrome_line(
crossterm::style::Color::DarkYellow,
&format!("⚙ {name}"),
&format!(" {}", hrdr_tools::truncate_inline(&args, 120)),
);
}
AgentEvent::ToolOutput { chunk, .. } if !quiet => {
chrome_fragment(crossterm::style::Color::DarkGrey, &chunk);
let _ = std::io::stderr().flush();
}
AgentEvent::Notice(text) if !quiet => chrome_line(crossterm::style::Color::DarkGrey, &format!("[{text}]"), ""),
AgentEvent::ToolEnd { name, ok, .. } if !quiet => {
let (mark, colour) = if ok {
("✓", crossterm::style::Color::DarkGreen)
} else {
("✗", crossterm::style::Color::DarkRed)
};
chrome_line(colour, mark, &format!(" {name}"));
}
AgentEvent::Usage {
prompt_tokens,
completion_tokens,
cached_prompt_tokens,
reasoning_tokens,
session_cost_usd,
cost_partial,
..
} if !quiet => {
let cached = cached_prompt_tokens
.map(|c| format!(" ({c} cached)"))
.unwrap_or_default();
let reasoning = reasoning_tokens
.map(|r| format!(" · reasoning {r}"))
.unwrap_or_default();
let cost = session_cost_usd
.map(|c| {
format!(
" · est. {}",
hrdr_app::fmt_cost_maybe_partial(c, cost_partial)
)
})
.unwrap_or_default();
chrome_line(
crossterm::style::Color::DarkGrey,
&format!(
"[usage] ctx {prompt_tokens}{cached} · out {completion_tokens}{reasoning}{cost}"
),
"",
);
}
AgentEvent::TurnDone => println!(),
_ => {}
}
})
.await;
for note in agent
.run_session_hooks(hrdr_tools::HookEvent::SessionEnd)
.await
{
if !quiet {
chrome_line(crossterm::style::Color::DarkGrey, &format!("[{note}]"), "");
}
}
if let Err(e) = result {
if json {
println!(
"{}",
serde_json::json!({"type": "error", "message": e.to_string()})
);
}
return Err(e);
}
Ok(())
}
fn event_json(ev: &AgentEvent) -> String {
use serde_json::json;
let v = match ev {
AgentEvent::Text(t) => json!({"type": "text", "text": t}),
AgentEvent::Reasoning(t) => json!({"type": "reasoning", "text": t}),
AgentEvent::ToolStart { id, name, args } => {
json!({"type": "tool_start", "id": id, "name": name, "args": args})
}
AgentEvent::ToolOutput { id, chunk } => {
json!({"type": "tool_output", "id": id, "chunk": chunk})
}
AgentEvent::ToolEnd {
id,
name,
result,
ok,
} => {
json!({"type": "tool_end", "id": id, "name": name, "ok": ok, "result": result})
}
AgentEvent::History(msgs) => json!({"type": "history", "messages": msgs.len()}),
AgentEvent::Notice(text) => json!({"type": "notice", "text": text}),
AgentEvent::Steered(text) => json!({"type": "steer", "text": text}),
AgentEvent::TodoUpdated(todos) => json!({"type": "todo", "todos": todos}),
AgentEvent::Usage {
prompt_tokens,
completion_tokens,
decode_ms,
cached_prompt_tokens,
cache_creation_tokens,
reasoning_tokens,
cost_usd,
session_cost_usd,
cost_partial,
} => {
json!({
"type": "usage",
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"decode_ms": decode_ms,
"cached_prompt_tokens": cached_prompt_tokens,
"cache_creation_tokens": cache_creation_tokens,
"reasoning_tokens": reasoning_tokens,
"cost_usd": cost_usd,
"session_cost_usd": session_cost_usd,
"cost_partial": cost_partial,
})
}
AgentEvent::TurnDone => json!({"type": "done"}),
};
v.to_string()
}
async fn list_models(config: AgentConfig) -> Result<()> {
hrdr_agent::refresh_models(config.clone()).await;
let active = config.model.provider().as_str().to_string();
for m in hrdr_agent::available_models(&config, Some(&active)) {
println!("{}://{}", m.provider, m.model);
}
Ok(())
}
#[cfg(test)]
mod trust_gate_tests {
use super::*;
use hrdr_agent::trust::TrustChoice;
fn never_asked(_: &std::path::Path) -> TrustChoice {
panic!("a trusted directory must not be asked about")
}
#[test]
fn a_trusted_directory_is_never_asked_about() {
let g = trust_gate_with(std::path::Path::new("/x"), false, true, never_asked);
assert!(matches!(g, TrustGate::Proceed));
}
#[test]
fn headless_in_an_unknown_directory_jails_instead_of_asking() {
let g = trust_gate_with(std::path::Path::new("/x"), true, false, never_asked);
assert!(matches!(g, TrustGate::Jail));
}
#[test]
fn headless_still_honours_an_existing_answer() {
let g = trust_gate_with(std::path::Path::new("/x"), true, true, never_asked);
assert!(matches!(g, TrustGate::Proceed));
}
#[test]
fn declining_jails_and_cancelling_starts_nothing() {
let jailed = trust_gate_with(std::path::Path::new("/x"), false, false, |_| {
TrustChoice::Untrusted
});
assert!(matches!(jailed, TrustGate::Jail));
let stopped = trust_gate_with(std::path::Path::new("/x"), false, false, |_| {
TrustChoice::Cancel
});
assert!(matches!(stopped, TrustGate::Stop));
}
}
#[cfg(test)]
mod cli_tests {
use super::*;
use clap::Parser;
#[test]
fn a_trailing_command_is_collected_for_the_tui() {
for (argv, want) in [
(vec!["hrdr", "/new"], "/new"),
(vec!["hrdr", "/model"], "/model"),
(vec!["hrdr", "/model", "gpt-5"], "/model gpt-5"),
(vec!["hrdr", ":review", "src/lib.rs"], ":review src/lib.rs"),
(vec!["hrdr", "!git status"], "!git status"),
(vec!["hrdr", "fix the failing test"], "fix the failing test"),
] {
let cli = Cli::parse_from(&argv);
assert!(cli.command.is_none(), "{argv:?} is not a subcommand");
assert_eq!(cli.input.join(" "), want, "{argv:?}");
}
}
#[test]
fn allow_unpriced_parses_with_and_without_max_cost() {
let run = |argv: &[&str]| match Cli::parse_from(argv).command {
Some(Command::Run {
allow_unpriced,
max_cost,
..
}) => (allow_unpriced, max_cost),
_ => panic!("expected a Run command"),
};
assert_eq!(
run(&["hrdr", "run", "--allow-unpriced", "--max-cost", "1.5", "hi"]),
(true, Some(1.5))
);
assert_eq!(
run(&["hrdr", "run", "--allow-unpriced", "hi"]),
(true, None)
);
assert_eq!(run(&["hrdr", "run", "hi"]), (false, None));
}
#[test]
fn flags_before_the_command_still_reach_hrdr() {
let cli = Cli::parse_from(["hrdr", "--model", "zen://kimi-k2", "--vim", "/model"]);
assert_eq!(cli.model.as_deref(), Some("zen://kimi-k2"));
assert!(cli.vim);
assert_eq!(cli.input.join(" "), "/model");
}
#[test]
fn a_subcommand_is_not_swallowed_by_a_leading_flag() {
let cli = Cli::parse_from(["hrdr", "--model", "zen://kimi-k2", "run", "hi"]);
assert_eq!(
cli.model.as_deref(),
Some("zen://kimi-k2"),
"--model still binds"
);
match cli.command {
Some(Command::Run { prompt, .. }) => assert_eq!(prompt.join(" "), "hi"),
_ => panic!("`--model X run hi` must be the run subcommand"),
}
assert!(cli.input.is_empty());
let cli = Cli::parse_from(["hrdr", "--vim", "run", "fix", "the", "bug"]);
match cli.command {
Some(Command::Run { prompt, .. }) => assert_eq!(prompt.join(" "), "fix the bug"),
_ => panic!("`--vim run …` must be the run subcommand"),
}
assert!(cli.input.is_empty());
let cli = Cli::parse_from(["hrdr", "--model", "zen://kimi-k2", "models"]);
assert!(
matches!(cli.command, Some(Command::Models)),
"`--model X models`"
);
assert!(cli.input.is_empty());
}
#[test]
fn the_provider_flag_no_longer_exists() {
assert!(
Cli::try_parse_from(["hrdr", "--provider", "zen"]).is_err(),
"--provider must not parse — it is spelled `--model zen://<model>` now"
);
}
#[test]
fn the_model_flag_takes_a_spec_of_either_shape() {
use hrdr_agent::{ModelRef, ModelSpec};
let spec = |argv: [&str; 3]| -> ModelSpec {
Cli::parse_from(argv)
.model
.expect("--model was passed")
.parse()
.expect("a valid spec")
};
let base: ModelRef = "zen://kimi-k2".parse().unwrap();
let full = spec(["hrdr", "--model", "chatgpt://gpt-5.5"]);
assert_eq!(full, ModelSpec::Full("chatgpt://gpt-5.5".parse().unwrap()));
assert_eq!(
full.apply(&base),
Some("chatgpt://gpt-5.5".parse().unwrap())
);
for (arg, want) in [
("gpt-5.5", "zen://gpt-5.5"),
("moonshotai/kimi-k2", "zen://moonshotai/kimi-k2"),
("llama3:8b", "zen://llama3:8b"),
] {
let s = spec(["hrdr", "--model", arg]);
assert!(matches!(s, ModelSpec::ModelOnly(_)), "{arg}");
assert_eq!(s.apply(&base), Some(want.parse().unwrap()), "{arg}");
}
}
#[test]
fn subcommands_are_not_swallowed_by_the_trailing_command() {
let cli = Cli::parse_from(["hrdr", "run", "fix", "the", "bug"]);
match cli.command {
Some(Command::Run { prompt, .. }) => assert_eq!(prompt.join(" "), "fix the bug"),
_ => panic!("`hrdr run` must still be the run subcommand"),
}
assert!(cli.input.is_empty());
let cli = Cli::parse_from(["hrdr", "models"]);
assert!(matches!(cli.command, Some(Command::Models)));
}
#[test]
fn the_model_spec_layers_settle_the_launch_identity() {
use hrdr_agent::{LastModels, ModelRef, ModelSpec};
let spec = |s: &str| s.parse::<ModelSpec>().unwrap();
let store = |last: Option<&str>| LastModels {
last: last.map(|s| s.parse::<ModelRef>().unwrap()),
..Default::default()
};
let cfg = AgentConfig::default();
let got = |last: Option<&str>, specs: &[ModelSpec]| {
settle_identity(&store(last), specs, &cfg)
.expect("resolves")
.to_string()
};
assert_eq!(
got(Some("zen://kimi-k2"), &[spec("chatgpt://gpt-5.5")]),
"openai://gpt-5.5"
);
assert_eq!(
got(Some("zen://kimi-k2"), &[spec("gpt-5.5")]),
"zen://gpt-5.5"
);
assert_eq!(got(Some("zen://kimi-k2"), &[]), "zen://kimi-k2");
assert_eq!(got(None, &[]), hrdr_agent::DEFAULT_MODEL_REF);
assert_eq!(
settle_identity(&store(None), &[], &cfg)
.unwrap()
.provider()
.as_str(),
"local",
"a bare `hrdr` is a `local` run"
);
assert_eq!(
got(None, &[spec("openrouter://deepseek-chat"), spec("kimi-k2")]),
"openrouter://kimi-k2"
);
assert_eq!(
got(
Some("zen://kimi-k2"),
&[spec("openrouter://deepseek-chat"), spec("local://qwen3")]
),
"local://qwen3"
);
}
#[test]
fn a_provider_only_model_flag_resolves_through_the_interactive_chain() {
use hrdr_agent::{LastModels, ModelRef, ModelSpec};
let spec: ModelSpec = "openai://".parse().unwrap();
let cfg = AgentConfig::default();
let store = LastModels {
last: Some("zen://kimi-k2".parse::<ModelRef>().unwrap()),
by_provider: [("openai".to_string(), "gpt-5.1-codex".to_string())]
.into_iter()
.collect(),
};
assert_eq!(
settle_identity(&store, std::slice::from_ref(&spec), &cfg)
.unwrap()
.to_string(),
"openai://gpt-5.1-codex"
);
let store = LastModels {
last: Some("zen://kimi-k2".parse::<ModelRef>().unwrap()),
..Default::default()
};
let err = settle_identity(&store, std::slice::from_ref(&spec), &cfg)
.unwrap_err()
.to_string();
assert!(err.contains("provider 'openai' needs a model"), "{err}");
assert!(err.contains("openai://<model>"), "{err}");
assert!(
!err.contains("kimi-k2"),
"never the old provider's model: {err}"
);
let mut cfg_declares = AgentConfig::default();
cfg_declares.providers.insert(
"declares".to_string(),
hrdr_agent::ProviderConfig {
base_url: "https://declares.example/v1".to_string(),
key_env: None,
api_key: None,
model: Some("its-own-model".to_string()),
remote: None,
context_window: None,
headers: std::collections::HashMap::new(),
api_version: None,
},
);
let declares: ModelSpec = "declares://".parse().unwrap();
assert_eq!(
settle_identity(&LastModels::default(), &[declares], &cfg_declares)
.unwrap()
.to_string(),
"declares://its-own-model"
);
}
#[test]
fn there_is_no_endpoint_override_flag() {
assert!(
Cli::try_parse_from(["hrdr", "--base-url", "http://evil.example/v1"]).is_err(),
"--base-url must not exist"
);
}
#[test]
fn the_default_run_resolves_to_the_local_preset() {
let cfg = AgentConfig::default();
let identity = settle_identity(&hrdr_agent::LastModels::default(), &[], &cfg).unwrap();
assert_eq!(identity.to_string(), "local://default");
let p = cfg
.resolve_provider(identity.provider().as_str())
.expect("`local` is a built-in");
assert_eq!(p.base_url, hrdr_agent::DEFAULT_BASE_URL);
assert_eq!(p.base_url, "http://localhost:8080/v1");
}
#[test]
fn a_user_defined_provider_supplies_its_own_endpoint() {
use hrdr_agent::ProviderConfig;
let mut cfg = AgentConfig::default();
cfg.providers.insert(
"myserver".to_string(),
ProviderConfig {
base_url: "http://localhost:1234/v1".to_string(),
key_env: None,
api_key: None,
model: None,
remote: None,
context_window: None,
headers: Default::default(),
api_version: None,
},
);
let spec: hrdr_agent::ModelSpec = "myserver://qwen".parse().unwrap();
let identity = settle_identity(&hrdr_agent::LastModels::default(), &[spec], &cfg).unwrap();
assert_eq!(identity.to_string(), "myserver://qwen");
let p = cfg
.resolve_provider(identity.provider().as_str())
.expect("a [providers.*] table defines a provider");
assert_eq!(p.base_url, "http://localhost:1234/v1");
}
#[test]
fn no_command_is_no_startup_input() {
let cli = Cli::parse_from(["hrdr"]);
assert!(cli.command.is_none());
assert!(cli.input.is_empty());
}
}