Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion src/exit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -105,14 +106,41 @@ 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:"));
}
}
}

/// 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)
}
Expand Down Expand Up @@ -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!(
Expand Down
34 changes: 17 additions & 17 deletions src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> [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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -363,7 +363,7 @@ FLAGS
const SWITCH: &str = "\
Switch the active AnyRouter account.

USAGE
Usage:
{bin} auth switch [<account>]

With no account on a TTY, pick from stored accounts.
Expand All @@ -386,7 +386,7 @@ Options:
const DECISION: &str = "\
{bin} decision — call the typed Decisions API (not chat)

USAGE
Usage:
{bin} decision --model <id> --state <text|json> --questions <json> [flags]
cat request.json | {bin} decision [flags]

Expand All @@ -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 <id> Decision model id (required unless stdin supplies it)
--state <text|json> Application state; plain text or JSON object/array
--questions <json> Non-empty question map (noul / choice / score)
Expand All @@ -406,26 +406,26 @@ FLAGS
--base-url <url> Override the configured API base URL
--config <path> 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
Expand Down Expand Up @@ -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
Expand All @@ -505,7 +505,7 @@ USAGE
{bin} config set <key> <v> Save one setting
{bin} config unset <key> Clear one setting

SETTINGS
Settings:
launcher.agent Agent a bare `{bin}` starts (else the last one)
launcher.<agent>.model Model that agent launches with
launcher.<agent>.args Flags passed to that agent on every launch
Expand Down Expand Up @@ -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).
Expand Down
9 changes: 6 additions & 3 deletions src/key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:")
),
)
}

Expand Down
6 changes: 3 additions & 3 deletions src/onboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,21 +401,21 @@ pub fn usage_hint(bin: &str) -> String {
"\
{bin} onboard — paste-ready prompts for coding agents

USAGE
Usage:
{bin} onboard Interactive pick (TTY)
{bin} onboard <mode> 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
deploy Wire secrets into deploy / CI
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
Expand Down
2 changes: 1 addition & 1 deletion tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading