diff --git a/src/exit.rs b/src/exit.rs index f97899e..ce66026 100644 --- a/src/exit.rs +++ b/src/exit.rs @@ -97,6 +97,7 @@ fn name(code: i32) -> &'static str { /// `{"error":{"code","exit","message","hint"}}` in JSON mode. pub fn print(code: i32, message: &str) { let (_, message) = classify(message); + let message = with_next_step(&message); let message = message.as_str(); if message.trim().is_empty() { return; @@ -105,7 +106,7 @@ pub fn print(code: i32, message: &str) { eprintln!("{}", json_line(code, message)); } else { let message = crate::key::mask_secrets(message); - if code == USAGE || has_prefix(&message) { + if has_prefix(&message) || (code == USAGE && !is_single_sentence(&message)) { eprintln!("{message}"); } else { eprintln!("{} {message}", crate::term::err("error:")); @@ -113,6 +114,33 @@ pub fn print(code: i32, message: &str) { } } +/// A one-line message that ends in `Run: x`, `Try: x` or `Use: x` becomes the +/// message plus a `hint:` line, the shape `anyr bogus` already prints. +fn with_next_step(message: &str) -> String { + let t = message.trim_end(); + if t.contains('\n') || has_prefix(t) { + return message.to_string(); + } + for (marker, hint) in [ + (". Run: ", "run `{}`"), + (". Try: ", "one of {}"), + (". Use: ", "usage: {}"), + ] { + if let Some((head, rest)) = t.split_once(marker) { + let hint = hint.replace("{}", rest.trim()); + return format!("{head}\n{} {hint}", crate::term::dim("hint:")); + } + } + message.to_string() +} + +/// Plain sentences get the `error:` prefix. Usage lines and multi-line help +/// text print as they are. +fn is_single_sentence(message: &str) -> bool { + let first = message.trim_start(); + !message.trim_end().contains('\n') && !first.starts_with("Usage") && !first.starts_with("usage") +} + fn has_prefix(message: &str) -> bool { message.find("error:").is_some_and(|i| i < 12) } @@ -217,6 +245,32 @@ mod tests { assert_eq!(v["error"]["hint"], "sign in again: anyr login"); } + #[test] + fn next_step_sentences_become_a_hint_line() { + let out = strip_ansi(&with_next_step("Unknown model \"x\". Run: anyr models")); + assert_eq!(out, "Unknown model \"x\"\nhint: run `anyr models`"); + let out = strip_ansi(&with_next_step( + "Unknown keys subcommand \"x\". Try: list | show", + )); + assert_eq!( + out, + "Unknown keys subcommand \"x\"\nhint: one of list | show" + ); + assert_eq!(with_next_step("plain failure"), "plain failure"); + assert_eq!( + with_next_step("error: a\nhint: b"), + "error: a\nhint: b", + "an already formed error is left alone" + ); + } + + #[test] + fn only_plain_sentences_get_the_error_prefix() { + assert!(is_single_sentence("Unknown model \"x\"")); + assert!(!is_single_sentence("Usage: anyr x [flags]")); + assert!(!is_single_sentence("line one\nline two")); + } + #[test] fn strip_ansi_removes_osc_hyperlinks() { assert_eq!( diff --git a/src/help.rs b/src/help.rs index 7baeb5c..de210c8 100644 --- a/src/help.rs +++ b/src/help.rs @@ -307,24 +307,24 @@ Print a key with `{bin} auth token` and the base URL with `{bin} onboard impl`.\ const AUTH: &str = "\ Authenticate with AnyRouter. -USAGE +Usage: {bin} auth [flags] -AVAILABLE COMMANDS +Commands: login: Log in to an AnyRouter account logout: Log out of an AnyRouter account status: View authentication status switch: Switch the active account token: Print the API key -FLAGS +Options: -h, --help Show help for command "; const LOGIN: &str = "\ Log in to an AnyRouter account. -USAGE +Usage: {bin} auth login [flags] Interactive (TTY): opens a login URL with the code already in it (browser @@ -337,7 +337,7 @@ shell history. After login, `{bin} claude` starts Claude Code (the default agent). -FLAGS +Options: --key - Read the API key from stdin (safest non-interactive) --key sk-ar-v1-... AnyRouter API key as an argument (warns: visible in argv) --device, --device-code Force the device-code flow (headless / SSH) @@ -349,12 +349,12 @@ Also available as `{bin} login`. const TOKEN: &str = "\ Print the API key stored for the active account. -USAGE +Usage: {bin} auth token [flags] Prints the full secret to stdout (for scripts). Use --masked in a log. -FLAGS +Options: --masked Print a redacted key --json Print as JSON --profile Use a named account @@ -363,7 +363,7 @@ FLAGS const SWITCH: &str = "\ Switch the active AnyRouter account. -USAGE +Usage: {bin} auth switch [] With no account on a TTY, pick from stored accounts. @@ -386,7 +386,7 @@ Options: const DECISION: &str = "\ {bin} decision — call the typed Decisions API (not chat) -USAGE +Usage: {bin} decision --model --state --questions [flags] cat request.json | {bin} decision [flags] @@ -395,7 +395,7 @@ question object map. Responses contain structured `answers`, not chat text. Decision models such as `typesafe/jev`, `fastino/gliner2.5-multi-v1`, and `anyrouter/decision` cannot be launched through the chat-agent commands. -FLAGS +Options: --model Decision model id (required unless stdin supplies it) --state Application state; plain text or JSON object/array --questions Non-empty question map (noul / choice / score) @@ -406,26 +406,26 @@ FLAGS --base-url Override the configured API base URL --config Override the config file path -EXAMPLE +Examples: {bin} decision --model typesafe/jev \\ --state 'Help! My payouts have been failing for 3 days.' \\ --questions '{\"is_urgent\":{\"type\":\"noul\",\"instructions\":\"Is this urgent?\"}}' -ALIASES +Aliases: decisions, systemone "; const WHOAMI: &str = "\ View authentication status. -USAGE +Usage: {bin} auth status [flags] Shows who is signed in (name and email), the account, the masked key and the default model. In a terminal it then offers to switch account, change the default model, log out or open the dashboard. -FLAGS +Options: --json Print as JSON: account, user, key_masked, default_model, config_path, base_url (identity_error if the lookup failed) --profile Use a named account @@ -495,7 +495,7 @@ Options: const CONFIG: &str = "\ Print current settings. Interactive picker only when asked. -USAGE +Usage: {bin} config Print account / model / agent / credits {bin} config --pick Open the settings TUI (TTY) {bin} config get [--json] Same dump; `--json` for scripts @@ -505,7 +505,7 @@ USAGE {bin} config set Save one setting {bin} config unset Clear one setting -SETTINGS +Settings: launcher.agent Agent a bare `{bin}` starts (else the last one) launcher..model Model that agent launches with launcher..args Flags passed to that agent on every launch @@ -556,7 +556,7 @@ this machine's key, the CLI asks before revoking the old one. const LOGOUT: &str = "\ Log out of an AnyRouter account. -USAGE +Usage: {bin} auth logout [flags] Removes stored keys for the active account (or --profile). diff --git a/src/key.rs b/src/key.rs index b8eca23..51e2686 100644 --- a/src/key.rs +++ b/src/key.rs @@ -319,11 +319,14 @@ pub fn mask_secrets(text: &str) -> String { /// Exits 4 (auth) through `commands::run`. pub fn no_key_error() -> String { + let bin = crate::help::invoked_bin(); crate::exit::tag( crate::exit::AUTH, - "No AnyRouter config and no key. Pass --key sk-ar-... or set ANYROUTER_API_KEY, \ -or use --device/--device-code for headless environments, \ -or run in an interactive terminal to log in.", + format!( + "not signed in, no AnyRouter key found\n{} run `{bin} login` (add --device over SSH), \ +or set ANYROUTER_API_KEY", + crate::term::dim("hint:") + ), ) } diff --git a/src/onboard.rs b/src/onboard.rs index e06bb40..0e309d2 100644 --- a/src/onboard.rs +++ b/src/onboard.rs @@ -401,13 +401,13 @@ pub fn usage_hint(bin: &str) -> String { "\ {bin} onboard — paste-ready prompts for coding agents -USAGE +Usage: {bin} onboard Interactive pick (TTY) {bin} onboard Print the prompt {bin} onboard cp [mode] Copy to clipboard (or print + hint) {bin} impl | plan | fix | deploy | cp -MODES +Modes: impl Set up AnyRouter (alias: implement) plan Plan a migration (no code changes) fix Repair LLM wiring through AnyRouter @@ -415,7 +415,7 @@ MODES discover Recommend models for this project openai | anthropic | openrouter | claude-code | hermes -FLAGS +Options: --json Emit {{\"mode\",\"label\",\"prompt\"}} --copy Copy instead of printing (same as `cp`) -h, --help Show this help diff --git a/tests/cli.rs b/tests/cli.rs index cdc0f04..310a00f 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2941,7 +2941,7 @@ fn whoami_and_keys_fail_loud_without_config() { let stderr = String::from_utf8_lossy(&out.stderr); assert_ne!(out.status.code().unwrap_or(1), 0, "{stderr}"); assert!( - stderr.contains("No AnyRouter config") || stderr.contains("no key"), + stderr.contains("not signed in") && stderr.contains("hint: run `anyr login`"), "{stderr}" ); let _ = std::fs::remove_dir_all(&dir);