#[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");
#[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. They are mutually exclusive, so
// `hrdr /model` can't be mistaken for a malformed subcommand invocation.
args_conflicts_with_subcommands = 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 = "auto-prune", global = true, value_name = "on|off")]
auto_prune: Option<String>,
#[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)]
timestamps: Option<String>,
#[arg(long, global = true)]
statusbar: Option<String>,
#[arg(long, global = true)]
todo_ttl: Option<u64>,
#[arg(long = "show-thinking", global = true, value_name = "on|off")]
show_thinking: Option<String>,
#[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(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::ResolvedModel::from_config(config);
let verdict = hrdr_agent::validate_identity(&resolved, config);
for w in hrdr_agent::confirm_identity(verdict).await? {
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(())
}
#[tokio::main]
async fn main() -> Result<()> {
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 = AgentConfig::load();
let mut ui = hrdr_app::UiConfig::load();
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(&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 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(v) = cli
.auto_prune
.as_deref()
.and_then(hrdr_agent::parse_env_bool)
{
config.auto_prune = v;
}
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(t) = cli.timestamps {
ui.timestamps = Some(t);
}
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(v) = cli
.show_thinking
.as_deref()
.and_then(hrdr_agent::parse_env_bool)
{
ui.show_thinking = v;
}
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(),
);
} else {
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();
}
}
match cli.command {
Some(Command::Run {
json,
quiet,
max_steps,
max_cost,
prompt,
}) => {
if let Some(n) = max_steps {
config.max_steps = n;
}
if max_cost.is_some() {
config.max_cost = max_cost;
}
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
}
}
}
async fn run_headless(config: AgentConfig, prompt: String, json: bool, quiet: bool) -> Result<()> {
let mut agent = Agent::new(config)?;
let prompt = hrdr_app::prepare_outgoing(&prompt, agent.agent_names(), &agent.cwd());
for notice in agent.connect_mcp().await {
if !quiet {
eprintln!("\x1b[90m[{notice}]\x1b[0m");
}
}
for note in agent
.run_session_hooks(hrdr_tools::HookEvent::SessionStart)
.await
{
if !quiet {
eprintln!("\x1b[90m[{note}]\x1b[0m");
}
}
let result = agent
.run(prompt, hrdr_agent::steering_queue(), |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 => {
eprintln!(
"\x1b[33m⚙ {name}\x1b[0m {}",
hrdr_tools::truncate_inline(&args, 120)
);
}
AgentEvent::ToolOutput { chunk, .. } if !quiet => {
eprint!("\x1b[90m{chunk}\x1b[0m");
let _ = std::io::stderr().flush();
}
AgentEvent::Notice(text) if !quiet => eprintln!("\x1b[90m[{text}]\x1b[0m"),
AgentEvent::ToolEnd { name, ok, .. } if !quiet => {
let mark = if ok {
"\x1b[32m✓\x1b[0m"
} else {
"\x1b[31m✗\x1b[0m"
};
eprintln!("{mark} {name}");
}
AgentEvent::Usage {
prompt_tokens,
completion_tokens,
cached_prompt_tokens,
reasoning_tokens,
session_cost_usd,
..
} 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(c)))
.unwrap_or_default();
eprintln!(
"\x1b[90m[usage] ctx {prompt_tokens}{cached} · out {completion_tokens}{reasoning}{cost}\x1b[0m"
);
}
AgentEvent::TurnDone => println!(),
_ => {}
}
})
.await;
for note in agent
.run_session_hooks(hrdr_tools::HookEvent::SessionEnd)
.await
{
if !quiet {
eprintln!("\x1b[90m[{note}]\x1b[0m");
}
}
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,
cached_prompt_tokens,
reasoning_tokens,
cost_usd,
session_cost_usd,
} => {
json!({
"type": "usage",
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"cached_prompt_tokens": cached_prompt_tokens,
"reasoning_tokens": reasoning_tokens,
"cost_usd": cost_usd,
"session_cost_usd": session_cost_usd,
})
}
AgentEvent::TurnDone => json!({"type": "done"}),
};
v.to_string()
}
async fn list_models(config: AgentConfig) -> Result<()> {
let models = hrdr_agent::list_provider_models(&config).await?;
for m in models {
println!("{m}");
}
Ok(())
}
#[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 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 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")]),
"chatgpt://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 chatgpt: ModelSpec = "chatgpt://".parse().unwrap();
assert_eq!(
settle_identity(&LastModels::default(), &[chatgpt], &cfg)
.unwrap()
.to_string(),
"chatgpt://gpt-5.5"
);
}
#[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());
}
}