mod backend;
use std::io::Write;
use anyhow::Result;
use clap::{Parser, Subcommand};
use hrdr_agent::{Agent, AgentConfig, AgentEvent};
use hrdr_llm::Client;
use backend::{Backend, BackendConfig};
#[derive(Parser)]
#[command(
name = "hrdr",
version,
about = "hrdr — herder: a fast, agentic coding harness for OpenAI-compatible models.",
before_help = include_str!("../art.txt"),
)]
struct Cli {
#[arg(long, global = true)]
base_url: Option<String>,
#[arg(long, global = true)]
model: Option<String>,
#[arg(long, global = true)]
provider: Option<String>,
#[arg(long, global = true)]
vim: bool,
#[arg(long, global = true)]
theme: Option<String>,
#[arg(long, global = true)]
effort: Option<String>,
#[arg(long, global = true)]
auto_compact: Option<f64>,
#[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)]
checkpoints: 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, global = true)]
no_backend: bool,
#[arg(long, global = true)]
backend_model: Option<String>,
#[arg(long, global = true)]
backend_bin: Option<String>,
#[arg(long, global = true)]
backend_ctx: Option<u32>,
#[arg(long = "backend-arg", global = true)]
backend_args: Vec<String>,
#[arg(long, value_enum, value_name = "SHELL", hide = true)]
completions: Option<CompletionShell>,
#[arg(long, hide = true)]
man: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[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(trailing_var_arg = true, required = true)]
prompt: Vec<String>,
},
Models,
}
#[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(());
}
let mut config = AgentConfig::load();
let mut ui = hrdr_app::UiConfig::load();
let mut remote_provider = false;
let provider_name = cli.provider.clone().or_else(|| config.provider.clone());
if let Some(name) = &provider_name {
let p = config.resolve_provider(name).ok_or_else(|| {
anyhow::anyhow!(
"unknown provider '{name}' (built-ins: zen, openai, openrouter, claude, local; \
or define [providers.{name}] in config)"
)
})?;
let base_overridden = cli.base_url.is_some() || std::env::var_os("HRDR_BASE_URL").is_some();
if !base_overridden {
config.base_url = p.base_url.clone();
}
if let Some(key) = p.api_key.clone() {
config.api_key = Some(key);
} else if let Some(env) = &p.key_env {
if let Ok(key) = std::env::var(env) {
config.api_key = Some(key);
} else if p.remote && config.api_key.is_none() {
eprintln!("hrdr: provider '{name}' needs an API key — set ${env}");
}
}
let model_overridden = cli.model.is_some() || std::env::var_os("HRDR_MODEL").is_some();
if !model_overridden && let Some(m) = p.model.clone() {
config.model = m;
}
if config.context_window.is_none() {
config.context_window = p.context_window;
}
remote_provider = p.remote;
}
if let Some(u) = cli.base_url {
config.base_url = u;
}
if let Some(m) = cli.model {
config.model = m;
}
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(r) = cli.auto_compact {
config.auto_compact = r;
}
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(c) = cli.checkpoints {
config.checkpoints = Some(c);
}
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.model == "default" {
eprintln!(
"hrdr: set a model with --model (run `hrdr models` to list this provider's models)"
);
}
let mut backend_ctx_fallback: Option<u32> = None;
let _backend = if cli.no_backend || remote_provider {
None
} else {
let mut bcfg = BackendConfig::default();
if let Some(m) = cli.backend_model {
bcfg.model = m;
}
if let Some(b) = cli.backend_bin {
bcfg.bin = b;
}
if let Some(c) = cli.backend_ctx {
bcfg.ctx = c;
}
bcfg.extra_args = cli.backend_args;
backend_ctx_fallback = Some(bcfg.ctx);
Some(Backend::ensure(&bcfg, &config.base_url).await?)
};
if config.context_window.is_none() {
let probe = hrdr_llm::Client::new(
config.base_url.clone(),
config.api_key.clone(),
config.model.clone(),
);
config.context_window = probe.context_window().await.or(backend_ctx_fallback);
}
match cli.command {
Some(Command::Run {
json,
quiet,
max_steps,
prompt,
}) => {
if let Some(n) = max_steps {
config.max_steps = n;
}
run_headless(config, prompt.join(" "), json, quiet).await
}
Some(Command::Models) => list_models(config).await,
None => hrdr_tui::run(config, ui).await,
}
}
async fn run_headless(config: AgentConfig, prompt: String, json: bool, quiet: bool) -> Result<()> {
let mut agent = Agent::new(config)?;
let result = agent
.run(prompt, |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,
} if !quiet => {
eprintln!(
"\x1b[90m[usage] ctx {prompt_tokens} · out {completion_tokens}\x1b[0m"
);
}
AgentEvent::TurnDone => println!(),
_ => {}
}
})
.await;
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::Notice(text) => json!({"type": "notice", "text": text}),
AgentEvent::Usage {
prompt_tokens,
completion_tokens,
} => {
json!({"type": "usage", "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens})
}
AgentEvent::TurnDone => json!({"type": "done"}),
};
v.to_string()
}
async fn list_models(config: AgentConfig) -> Result<()> {
let client = Client::new(config.base_url, config.api_key, config.model);
let models = client.list_models().await?;
for m in models {
println!("{m}");
}
Ok(())
}