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 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,
};
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);
}
},
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 {
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;
}
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!();
}
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 {
default_body(&result)
};
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,
);
}
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(())
}
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 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 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 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}");
}
#[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");
}
}