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 config;
pub mod cost;
pub mod detach;
pub mod doctor;
pub mod draft;
pub mod env;
pub mod error;
pub mod history;
pub mod lint;
pub mod output;
pub mod profile;
pub mod prompt;
pub mod rates;
pub mod render;
pub mod session;
pub mod show;
pub mod stdin_probe;
pub mod stream;
pub mod style;
pub mod worktree;
use crate::cli::{AskArgs, Cli, SubCommand};
use crate::history::{pick_session_interactive, run_history, run_last};
use crate::output::{
default_body, extract_code_blocks, format_footer, looks_like_refusal, path_is_json,
should_show_footer, surface_structured_output,
};
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 }) => match action {
crate::cli::ProfileAction::Draft(args) => profile::run_draft(args).await,
other => profile::run(other),
},
Some(SubCommand::Cost(args)) => cost::run(args),
Some(SubCommand::Doctor(args)) => {
let code = doctor::run(args)?;
std::process::exit(code);
}
Some(SubCommand::Alias { action }) => match action {
crate::cli::AliasAction::Draft(args) => aliases::run_draft(args).await,
other => aliases::run(other),
},
Some(SubCommand::Config { cmd }) => match cmd {
crate::cli::ConfigCmd::Init(args) => config::run_init(args).await,
crate::cli::ConfigCmd::Lint(args) => {
let code = lint::run(args)?;
std::process::exit(code);
}
crate::cli::ConfigCmd::Show(args) => config::run_show(args),
crate::cli::ConfigCmd::Explain(args) => config::run_explain(args),
},
Some(SubCommand::Worktree { cmd }) => worktree::run(cmd),
Some(SubCommand::Show(args)) => show::run(&args),
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 if let Some(msg) = aliases::bare_subcommand_typo(&cli.ask) {
anyhow::bail!(msg)
} else {
run_ask(cli.ask).await
}
}
}
}
#[derive(serde::Serialize)]
pub(crate) struct SuccessEnvelope<'a> {
pub(crate) version: u32,
pub(crate) result: &'a claude_wrapper::types::QueryResult,
pub(crate) refusal: bool,
}
#[derive(serde::Serialize)]
pub(crate) struct VersionedResult<'a, T: serde::Serialize> {
pub(crate) version: u32,
pub(crate) result: &'a T,
}
impl<'a, T: serde::Serialize> VersionedResult<'a, T> {
pub(crate) fn new(result: &'a T) -> Self {
Self { version: 1, result }
}
}
fn swallow_note(args: &AskArgs) -> Option<String> {
fn looks_like_prompt(v: &str) -> bool {
v.chars().any(char::is_whitespace)
}
if let Some(Some(v)) = &args.continue_session
&& looks_like_prompt(v)
{
return Some(format!(
"note: -c consumed \"{v}\" as its value; pass the prompt with -p, or -c alone to continue"
));
}
if let Some(Some(v)) = &args.worktree
&& looks_like_prompt(v)
{
return Some(format!(
"note: -w consumed \"{v}\" as its value; pass the prompt with -p"
));
}
if let Some(v) = &args.code
&& looks_like_prompt(v)
{
return Some(format!(
"note: --code consumed \"{v}\" as its value; pass the prompt with -p"
));
}
None
}
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 let Some(name) = args.session.clone() {
let uuid = resolve_session(&name, &pool.sessions)?;
args.continue_session = Some(Some(uuid));
}
if args.show_permissions {
eprintln!("{}", output::format_permissions(&args));
return Ok(());
}
if args.fresh {
args.continue_session = None;
}
apply_no_worktree(&mut args);
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`"),
}
}
if args.detach {
return detach::run_detached(&args);
}
if let Some(path) = args.json_schema.clone() {
let contents = std::fs::read_to_string(&path)
.with_context(|| format!("reading --json-schema file `{path}`"))?;
serde_json::from_str::<serde_json::Value>(&contents)
.with_context(|| format!("--json-schema file `{path}` is not valid JSON"))?;
args.json_schema = Some(contents);
}
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));
}
expand_continue_prefix(&mut args)?;
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 resolved = match resolve_main_prompt(
explicit_prompt,
args.file.as_deref(),
args.editor,
args.editor_history,
) {
Ok(r) => r,
Err(e) => {
if let Some(note) = swallow_note(&args) {
eprintln!("{note}");
}
return Err(e);
}
};
let attachments = collect_attachments(&args.attach)?;
let git_context = collect_git_context(&args)?;
let context = merge_optional(attachments, git_context);
let prompt = match compose_prompt(
resolved.main,
&args.prepend,
resolved.piped_context,
context,
&args.append,
)? {
Some(p) => p,
None => {
if std::io::stdin().is_terminal() {
eprintln!("{}", crate::cli::no_prompt_blurb());
return Ok(());
}
if let Some(note) = swallow_note(&args) {
eprintln!("{note}");
}
anyhow::bail!(
"no prompt: pass one as an argument, use -f / -e, --prepend / --append / --attach, pipe via stdin, or use `-` for stdin"
);
}
};
let prompt = apply_vars(prompt, &args.var);
prompt::warn_unsubstituted_placeholders(&prompt);
if args.echo && !args.quiet {
eprintln!("{prompt}");
eprintln!();
eprintln!("---");
eprintln!();
}
if args.worktree.is_some() {
let dir = std::env::current_dir().unwrap_or_default();
if !is_in_git_repo(&dir) {
bail!(
"--worktree needs a git repository, but {} is not one; run `git init` or drop --worktree",
dir.display()
);
}
}
let mut builder = Claude::builder();
if let Some(secs) = args.timeout
&& secs > 0
{
builder = builder.timeout_secs(secs);
}
let 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 mut result = if args.trace.is_some() {
match run_streaming(&claude, prompt, &args, stream::DisplayMode::Silent).await? {
Some(r) => r,
None => exit_unusable(
EXIT_UNUSABLE_RESULT,
"no result event: the streaming run produced no usable output",
),
}
} 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();
}
if args.json_schema.is_some() {
surface_structured_output(&mut result);
}
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 {
default_body(&result, args.json_schema.is_some())
};
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,
args.model.as_deref(),
args.effort.map(|e| e.as_str()),
),
&style,
);
}
if let Some((code, note)) = classify_result(&result) {
exit_unusable(code, note);
}
Ok(())
}
fn is_in_git_repo(dir: &std::path::Path) -> bool {
let mut cur = Some(dir);
while let Some(d) = cur {
if d.join(".git").exists() {
return true;
}
cur = d.parent();
}
false
}
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(())
}
fn resolve_session(
name: &str,
sessions: &std::collections::HashMap<String, String>,
) -> Result<String> {
match sessions.get(name) {
Some(uuid) => Ok(uuid.clone()),
None => {
let known = if sessions.is_empty() {
"(none configured)".to_string()
} else {
let mut names: Vec<&str> = sessions.keys().map(String::as_str).collect();
names.sort_unstable();
names.join(", ")
};
bail!("no session named '{name}' in config (known: {known})")
}
}
}
fn apply_no_worktree(args: &mut AskArgs) {
if args.no_worktree {
args.worktree = None;
}
}
fn expand_continue_prefix(args: &mut AskArgs) -> Result<()> {
let Some(Some(value)) = &args.continue_session else {
return Ok(());
};
let value = value.clone();
let Ok(ids) = history::current_project_session_ids() else {
return Ok(());
};
match session::resolve_session_prefix(&value, &ids) {
session::Resolution::Unique(full) => {
args.continue_session = Some(Some(full));
}
session::Resolution::Ambiguous(candidates) => {
let listed = candidates.join("\n ");
bail!(
"session id prefix `{value}` is ambiguous; it matches:\n {listed}\npass more characters to disambiguate"
);
}
session::Resolution::NoMatch => {} }
Ok(())
}
pub const EXIT_UNUSABLE_RESULT: i32 = 6;
pub(crate) fn classify_result(
result: &claude_wrapper::types::QueryResult,
) -> Option<(i32, &'static str)> {
if result.is_error {
return Some((
EXIT_UNUSABLE_RESULT,
"claude returned an error result (is_error: true)",
));
}
let has_text = !result.result.trim().is_empty();
let has_structured = result
.extra
.get("structured_output")
.is_some_and(|v| !v.is_null());
if !has_text && !has_structured {
return Some((
EXIT_UNUSABLE_RESULT,
"empty result: the run produced no usable output",
));
}
None
}
pub(crate) fn exit_unusable(code: i32, note: &str) -> ! {
use std::io::Write;
let _ = std::io::stdout().flush();
eprintln!("roba: {note} (exit {code})");
std::process::exit(code);
}
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,
claude_wrapper::Error::MaxTurnsExceeded { .. } => 5,
claude_wrapper::Error::MaxBudgetExceeded { .. } => 7,
_ => 1,
}
} else {
1
}
}
#[cfg(test)]
mod tests {
use super::*;
use claude_wrapper::auth::AuthErrorKind;
#[test]
fn resolve_session_known_name_returns_uuid() {
let mut sessions = std::collections::HashMap::new();
sessions.insert("meta".to_string(), "0199-uuid".to_string());
let uuid = resolve_session("meta", &sessions).unwrap();
assert_eq!(uuid, "0199-uuid");
}
#[test]
fn resolve_session_unknown_name_errors_and_lists_known() {
let mut sessions = std::collections::HashMap::new();
sessions.insert("beta".to_string(), "b".to_string());
sessions.insert("alpha".to_string(), "a".to_string());
let err = resolve_session("nope", &sessions).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no session named 'nope'"), "got: {msg}");
assert!(msg.contains("alpha, beta"), "got: {msg}");
}
#[test]
fn resolve_session_unknown_with_empty_map_says_none_configured() {
let sessions = std::collections::HashMap::new();
let err = resolve_session("meta", &sessions).unwrap_err();
assert!(err.to_string().contains("(none configured)"), "got: {err}");
}
fn ask_args() -> AskArgs {
use clap::Parser;
Cli::try_parse_from(["roba", "placeholder"]).unwrap().ask
}
#[test]
fn apply_no_worktree_nulls_config_set_worktree() {
let mut args = ask_args();
args.worktree = Some(Some("mybranch".to_string()));
args.no_worktree = true;
apply_no_worktree(&mut args);
assert!(args.worktree.is_none());
let mut anon = ask_args();
anon.worktree = Some(None); anon.no_worktree = true;
apply_no_worktree(&mut anon);
assert!(anon.worktree.is_none());
}
#[test]
fn apply_no_worktree_leaves_worktree_when_flag_unset() {
let mut args = ask_args();
args.worktree = Some(Some("keep".to_string()));
args.no_worktree = false;
apply_no_worktree(&mut args);
assert_eq!(args.worktree, Some(Some("keep".to_string())));
}
#[test]
fn is_in_git_repo_false_without_dot_git() {
let dir = tempfile::tempdir().unwrap();
assert!(!is_in_git_repo(dir.path()));
}
#[test]
fn is_in_git_repo_true_with_dot_git_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
assert!(is_in_git_repo(dir.path()));
}
#[test]
fn is_in_git_repo_walks_up_to_parent() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let nested = dir.path().join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
assert!(is_in_git_repo(&nested));
}
#[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_max_turns_returns_5() {
let err = claude_wrapper::Error::MaxTurnsExceeded {
command: "claude --print".to_string(),
exit_code: 1,
max_turns: Some(40),
};
assert_eq!(classify_exit_code(&anyhow::Error::new(err)), 5);
}
#[test]
fn classify_max_budget_returns_7() {
let err = claude_wrapper::Error::MaxBudgetExceeded {
command: "claude --print".to_string(),
exit_code: 1,
max_usd: Some(0.01),
};
assert_eq!(classify_exit_code(&anyhow::Error::new(err)), 7);
}
#[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 classify_result_usable_body_is_none() {
let r = query_result_with_body("Here is the answer.");
assert!(classify_result(&r).is_none());
}
#[test]
fn classify_result_refusal_stays_usable() {
let r = query_result_with_body("I can't help with that request.");
assert!(
looks_like_refusal(&r.result),
"fixture must read as refusal"
);
assert!(classify_result(&r).is_none());
}
#[test]
fn classify_result_empty_body_is_unusable() {
let r = query_result_with_body("");
let (code, note) = classify_result(&r).expect("empty result is unusable");
assert_eq!(code, EXIT_UNUSABLE_RESULT);
assert!(note.contains("empty"), "got: {note}");
}
#[test]
fn classify_result_whitespace_only_body_is_unusable() {
let r = query_result_with_body(" \n\t ");
assert_eq!(
classify_result(&r).map(|(c, _)| c),
Some(EXIT_UNUSABLE_RESULT)
);
}
#[test]
fn classify_result_is_error_is_unusable_even_with_body() {
let mut r = query_result_with_body("partial text before the error");
r.is_error = true;
let (code, note) = classify_result(&r).expect("is_error is unusable");
assert_eq!(code, EXIT_UNUSABLE_RESULT);
assert!(note.contains("is_error"), "got: {note}");
}
#[test]
fn classify_result_empty_body_with_structured_output_is_usable() {
let mut r = query_result_with_body("");
r.extra.insert(
"structured_output".to_string(),
serde_json::json!({"answer": "Paris"}),
);
assert!(classify_result(&r).is_none());
}
#[test]
fn classify_result_empty_body_with_null_structured_output_is_unusable() {
let mut r = query_result_with_body("");
r.extra
.insert("structured_output".to_string(), serde_json::Value::Null);
assert_eq!(
classify_result(&r).map(|(c, _)| c),
Some(EXIT_UNUSABLE_RESULT)
);
}
#[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");
}
}