use anyhow::{Context, Result, bail};
use claude_wrapper::{Claude, QueryCommand};
use std::io::IsTerminal;
pub mod agent_check;
pub mod aliases;
pub mod cli;
pub mod cost;
pub mod doctor;
pub mod env;
pub mod error;
pub mod history;
pub mod output;
pub mod profile;
pub mod prompt;
pub mod rates;
pub mod render;
pub mod session;
pub mod stream;
use crate::cli::{AskArgs, Cli, SubCommand};
use crate::history::{pick_session_interactive, run_history, run_last};
use crate::output::{
extract_code_blocks, format_footer, looks_like_refusal, path_is_json, should_show_footer,
};
use crate::prompt::{
apply_vars, collect_attachments, collect_git_context, compose_prompt, merge_optional,
resolve_main_prompt,
};
use crate::session::apply_session;
use crate::stream::run_streaming;
pub async fn dispatch(cli: Cli) -> Result<()> {
if let Some(path) = cli.cwd.as_deref() {
std::env::set_current_dir(path)
.with_context(|| format!("--cwd: cannot change directory to {}", path.display()))?;
}
match cli.command {
Some(SubCommand::History(args)) => run_history(args),
Some(SubCommand::Last(args)) => run_last(args),
Some(SubCommand::Profile { action }) => profile::run(action),
Some(SubCommand::Cost(args)) => cost::run(args),
Some(SubCommand::Doctor) => {
let code = doctor::run()?;
std::process::exit(code);
}
Some(SubCommand::Alias { action }) => aliases::run(action),
Some(SubCommand::Completions { shell }) => {
use clap::CommandFactory;
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "roba", &mut std::io::stdout());
Ok(())
}
Some(SubCommand::External(rest)) => {
let name = cli
.ask
.prompt
.clone()
.ok_or_else(|| anyhow::anyhow!("could not determine alias name"))?;
aliases::dispatch_alias(&name, &rest).await
}
None => {
if let Some(name) = aliases::bare_alias_candidate(&cli.ask)? {
let trailing = aliases::trailing_args_from_env(&name);
aliases::dispatch_alias(&name, &trailing).await
} else {
run_ask(cli.ask).await
}
}
}
}
#[derive(serde::Serialize)]
struct SuccessEnvelope<'a> {
version: u32,
result: &'a claude_wrapper::types::QueryResult,
refusal: bool,
}
pub async fn run_ask(mut args: AskArgs) -> Result<()> {
env::apply_env_overrides(&mut args);
let pool = profile::load_pool()?;
if let Some(chosen) = profile::resolve(&args, &pool)? {
let source = profile::profile_source_label(&args, &pool);
profile::merge_into_args(&mut args, chosen, &source);
}
if args.dispatch {
if !args.full_auto && !args.writable && !args.readonly && args.permission_mode.is_none() {
args.full_auto = true;
args.full_auto_source = Some("--dispatch".to_string());
}
if args.worktree.is_none() {
args.worktree = Some(None);
}
if !args.fresh && args.continue_session.is_none() {
args.fresh = true;
}
if args.agent.is_none() {
eprintln!(
"warning: --dispatch without --agent; consider --agent for unattended dispatch"
);
}
}
if args.show_permissions {
eprintln!("{}", output::format_permissions(&args));
return Ok(());
}
if args.fresh {
args.continue_session = None;
}
if args.fork {
match &args.continue_session {
Some(Some(_)) => {} Some(None) => bail!("--fork requires an explicit session id; use `-c=ID --fork`"),
None => bail!("--fork requires `-c=ID`"),
}
}
ensure_interactive_for_flags(&args)?;
if args.pick {
let id = pick_session_interactive()?;
eprintln!("resuming session {}", id.get(..8).unwrap_or(&id));
args.continue_session = Some(Some(id));
}
let cwd = std::env::current_dir().unwrap_or_default();
agent_check::maybe_warn(&args, &cwd);
let explicit_prompt = args.prompt_flag.as_deref().or(args.prompt.as_deref());
let main = resolve_main_prompt(
explicit_prompt,
args.file.as_deref(),
args.editor,
args.editor_history,
)?;
let attachments = collect_attachments(&args.attach)?;
let git_context = collect_git_context(&args)?;
let context = merge_optional(attachments, git_context);
let prompt = compose_prompt(main, &args.prepend, context, &args.append)?;
let prompt = apply_vars(prompt, &args.var);
if prompt.trim().is_empty() && std::io::stdin().is_terminal() {
eprintln!("roba: no prompt given. Try `roba \"your question\"` or `roba --help`.");
return Ok(());
}
if args.echo && !args.quiet {
eprintln!("{prompt}");
eprintln!();
eprintln!("---");
eprintln!();
}
let claude = Claude::builder().build()?;
if args.stream {
run_streaming(&claude, prompt, &args, stream::DisplayMode::Live).await?;
return Ok(());
}
let pre_style = render::Style::detect(&args);
let spinner = pre_style.spinner.then(render::spinner);
let result = if args.trace.is_some() {
match run_streaming(&claude, prompt, &args, stream::DisplayMode::Silent).await? {
Some(r) => r,
None => bail!("streaming completed without a result event"),
}
} else {
let name = session::derive_session_name(&prompt);
apply_session(
QueryCommand::new(prompt).name(name).prompt_via_stdin(true),
&args,
)
.execute_json(&claude)
.await?
};
if let Some(pb) = spinner {
pb.finish_and_clear();
}
let file_path = args.out.as_deref();
let want_json = args.json || file_path.is_some_and(path_is_json);
let body = if want_json {
let envelope = SuccessEnvelope {
version: 1,
result: &result,
refusal: looks_like_refusal(&result.result),
};
serde_json::to_string_pretty(&envelope)?
} else if let Some(filter) = args.code.as_deref() {
let lang = if filter.is_empty() {
None
} else {
Some(filter)
};
extract_code_blocks(&result.result, lang)
} else {
result.result.clone()
};
let style = render::Style::detect(&args);
render::print_body(&body, &style);
if let Some(path) = file_path {
std::fs::write(path, format!("{body}\n"))
.with_context(|| format!("writing result to {}", path.display()))?;
}
if should_show_footer(&args) {
render::print_meta_blank();
if looks_like_refusal(&result.result) {
render::print_warning("response looks like a refusal", &style);
}
let rates = if args.no_dollars {
None
} else {
rates::Rates::resolve(args.rates_file.as_deref()).ok()
};
render::print_meta(
&format_footer(&result, rates.as_ref(), args.no_dollars),
&style,
);
}
Ok(())
}
fn ensure_interactive_for_flags(args: &AskArgs) -> Result<()> {
if args.editor && !std::io::stdin().is_terminal() {
bail!("--editor requires an interactive terminal (stdin not a TTY)");
}
if args.pick && !std::io::stdin().is_terminal() {
bail!("--pick requires an interactive terminal (stdin not a TTY)");
}
Ok(())
}
pub fn classify_exit_code(err: &anyhow::Error) -> i32 {
if let Some(wrapper_err) = err.downcast_ref::<claude_wrapper::Error>() {
match wrapper_err {
claude_wrapper::Error::Auth { .. } => 2,
claude_wrapper::Error::BudgetExceeded { .. } => 3,
claude_wrapper::Error::Timeout { .. } => 4,
_ => 1,
}
} else {
1
}
}
#[cfg(test)]
mod tests {
use super::*;
use claude_wrapper::auth::AuthErrorKind;
#[test]
fn classify_auth_returns_2() {
let err = claude_wrapper::Error::Auth {
kind: AuthErrorKind::NotAuthenticated,
command: "claude -p hi".to_string(),
exit_code: 1,
message: "not logged in".to_string(),
};
assert_eq!(classify_exit_code(&anyhow::Error::new(err)), 2);
}
#[test]
fn classify_budget_returns_3() {
let err = claude_wrapper::Error::BudgetExceeded {
total_usd: 5.0,
max_usd: 4.0,
};
assert_eq!(classify_exit_code(&anyhow::Error::new(err)), 3);
}
#[test]
fn classify_timeout_returns_4() {
let err = claude_wrapper::Error::Timeout {
timeout_seconds: 30,
};
assert_eq!(classify_exit_code(&anyhow::Error::new(err)), 4);
}
#[test]
fn classify_other_wrapper_error_returns_1() {
let err = claude_wrapper::Error::History {
message: "no such project".to_string(),
};
assert_eq!(classify_exit_code(&anyhow::Error::new(err)), 1);
}
#[test]
fn classify_non_wrapper_error_returns_1() {
let err = anyhow::anyhow!("something else broke");
assert_eq!(classify_exit_code(&err), 1);
}
#[test]
fn success_envelope_has_version_and_result() {
let result = claude_wrapper::types::QueryResult {
result: "hello".to_string(),
session_id: "abc123".to_string(),
cost_usd: None,
duration_ms: None,
num_turns: None,
is_error: false,
extra: std::collections::HashMap::new(),
};
let envelope = SuccessEnvelope {
version: 1,
result: &result,
refusal: looks_like_refusal(&result.result),
};
let json = serde_json::to_string_pretty(&envelope).expect("serializes");
let value: serde_json::Value = serde_json::from_str(&json).expect("round-trips");
assert_eq!(value["version"], 1, "top-level version must be 1");
assert!(
value.get("result").is_some(),
"result field must be present"
);
assert!(value.get("error").is_none(), "error field must be absent");
assert_eq!(value["result"]["result"], "hello");
assert_eq!(value["result"]["session_id"], "abc123");
}
fn query_result_with_body(body: &str) -> claude_wrapper::types::QueryResult {
claude_wrapper::types::QueryResult {
result: body.to_string(),
session_id: "abc123".to_string(),
cost_usd: None,
duration_ms: None,
num_turns: None,
is_error: false,
extra: std::collections::HashMap::new(),
}
}
#[test]
fn success_envelope_includes_refusal_field() {
let refused = query_result_with_body("I can't help with that request.");
let envelope = SuccessEnvelope {
version: 1,
result: &refused,
refusal: looks_like_refusal(&refused.result),
};
let value: serde_json::Value =
serde_json::from_str(&serde_json::to_string_pretty(&envelope).expect("serializes"))
.expect("round-trips");
assert_eq!(value["refusal"], true, "refusal body must flag refusal");
let answered = query_result_with_body("Here is the answer you asked for.");
let envelope = SuccessEnvelope {
version: 1,
result: &answered,
refusal: looks_like_refusal(&answered.result),
};
let value: serde_json::Value =
serde_json::from_str(&serde_json::to_string_pretty(&envelope).expect("serializes"))
.expect("round-trips");
assert_eq!(value["refusal"], false, "normal body must not flag refusal");
}
}