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
14 changes: 10 additions & 4 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ pub fn start_device_flow(base_url: &str, tool: Option<&str>) -> Result<DeviceSta

/// What the poll loop should do next, derived from one server response or
/// transport outcome plus the running clock budget. Pure — unit-testable.
#[derive(Debug)]
enum PollAction {
Continue,
SlowDown,
Expand Down Expand Up @@ -544,10 +545,15 @@ mod tests {
poll_action(&Ok(DevicePoll::Denied), 10, 0, 600),
PollAction::GiveUp(_)
));
assert!(matches!(
poll_action(&Ok(DevicePoll::Expired), 10, 0, 600),
PollAction::GiveUp(_)
));
match poll_action(&Ok(DevicePoll::Expired), 10, 0, 600) {
PollAction::GiveUp(msg) => {
assert!(
msg.contains("expired"),
"user-visible expiry text missing: {msg}"
);
}
other => panic!("expected GiveUp, got {other:?}"),
}
assert!(matches!(
poll_action(&Ok(DevicePoll::SlowDown), 10, 0, 600),
PollAction::SlowDown
Expand Down
39 changes: 37 additions & 2 deletions src/cmd/config_tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,9 @@ pub(crate) fn run_config_tui(
#[cfg(feature = "native")]
{
if tui_wants_dump(parsed, env) {
let tab = settings_tab_index(env);
let (state, _) =
config_settings_frame(parsed, env, &path, false, &mut CreditsCache::fresh(), 0);
config_settings_frame(parsed, env, &path, false, &mut CreditsCache::fresh(), tab);
print!("{}", tui_dump_settings(state, env));
return Ok(0);
}
Expand All @@ -173,6 +174,24 @@ pub(crate) fn run_config_tui(
config_menu_loop_legacy(parsed, env, &path)
}

#[cfg(feature = "native")]
pub(crate) fn settings_tab_index(env: &BTreeMap<String, String>) -> usize {
let Some(raw) = env
.get("ANYR_TUI_TAB")
.map(|s| s.trim())
.filter(|s| !s.is_empty())
else {
return 0;
};
if let Ok(i) = raw.parse::<usize>() {
return i;
}
settings_tab_names()
.iter()
.position(|t| t.eq_ignore_ascii_case(raw))
.unwrap_or(0)
}

#[cfg(feature = "native")]
pub(crate) fn settings_tab_names() -> Vec<String> {
let mut tabs = vec!["general".to_string()];
Expand Down Expand Up @@ -440,7 +459,7 @@ pub(crate) fn fill_agent_settings(
"not installed".into()
},
if present { Tone::Good } else { Tone::Warn },
SettingKind::Install(id),
SettingKind::Mapping,
);
entry(
rows,
Expand Down Expand Up @@ -1232,3 +1251,19 @@ pub(crate) fn run_config(
))),
}
}

#[cfg(test)]
mod settings_tab_tests {
use super::settings_tab_index;
use std::collections::BTreeMap;

#[test]
fn settings_tab_index_reads_name_and_number() {
let mut env = BTreeMap::new();
assert_eq!(settings_tab_index(&env), 0);
env.insert("ANYR_TUI_TAB".into(), "claude".into());
assert_eq!(settings_tab_index(&env), 1);
env.insert("ANYR_TUI_TAB".into(), "2".into());
assert_eq!(settings_tab_index(&env), 2);
}
}
25 changes: 18 additions & 7 deletions src/cmd/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,12 @@ pub(crate) fn tui_palette_select(
}

#[cfg(feature = "native")]
pub(crate) fn launcher_uses_palette() -> bool {
let tui = std::env::var("ANYR_TUI").unwrap_or_default();
let t = tui.trim();
(t == "1" || t.eq_ignore_ascii_case("true") || t.eq_ignore_ascii_case("yes"))
&& crate::tui::can_use_fullscreen()
pub(crate) fn launcher_uses_palette(env: &BTreeMap<String, String>) -> bool {
crate::tui::env_flag(env, "ANYR_TUI") && crate::tui::can_use_fullscreen()
}

#[cfg(not(feature = "native"))]
pub(crate) fn launcher_uses_palette() -> bool {
pub(crate) fn launcher_uses_palette(_env: &BTreeMap<String, String>) -> bool {
false
}

Expand Down Expand Up @@ -196,7 +193,6 @@ pub(crate) const LAUNCH_FLAGS: &[&str] = &[
"dry-run",
"yes",
"ok",
"no-check",
"device",
"device-code",
"paste",
Expand Down Expand Up @@ -425,6 +421,12 @@ pub(crate) fn catalog_lookup_enabled(env: &BTreeMap<String, String>) -> bool {
}

pub(crate) fn persist_tool_command(path: &PathBuf, id: &str, command: &str) -> Result<(), String> {
let builtin = resolve_tool(None, id)
.map(|t| t.command)
.unwrap_or_else(|_| id.to_string());
if !crate::install::should_persist_command(command, &builtin) {
return Ok(());
}
let mut cfg = load_config_if_present(path).unwrap_or_default();
let mut tool = resolve_tool(Some(&cfg), id)?;
tool.command = command.to_string();
Expand Down Expand Up @@ -578,6 +580,15 @@ pub(crate) fn should_open_launcher(raw: &[String], interactive: bool, dump: bool
mod tests {
use super::should_open_launcher;

#[test]
fn persist_tool_command_skips_bare_builtin() {
assert!(!crate::install::should_persist_command("claude", "claude"));
assert!(crate::install::should_persist_command(
"/opt/claude",
"claude"
));
}

#[test]
fn bare_tty_opens_launcher_not_help() {
assert!(should_open_launcher(&[], true, false));
Expand Down
4 changes: 2 additions & 2 deletions src/cmd/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ pub(crate) fn run_keys(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> R
.unwrap_or_else(default_key_name);
let key = create_key(&base, &cred, &name)?;
println!("Created \"{name}\":\n\n {key}\n\nShown once — store it now.");
let save = parsed.flag_true("yes")
let save = parsed.skip_confirm()
|| (term::is_interactive()
&& term::confirm("Use this key for the current profile?"));
if save {
Expand Down Expand Up @@ -218,7 +218,7 @@ pub(crate) fn run_keys(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> R
))
}
};
if !parsed.flag_true("yes") {
if !parsed.skip_confirm() {
if !term::is_interactive() {
return Err(
"Revoking a key is destructive; pass --yes to run non-interactively."
Expand Down
2 changes: 1 addition & 1 deletion src/cmd/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ pub(crate) fn run_launch(
args.extend(model_args_for(tool_name, &model, model_mode));
// --yolo is shorthand for Claude Code's full-permission flag; other tools
// don't have an equivalent, so it only maps there.
if tool_name == "claude" && parsed.flag_true("yolo") {
if tool_name == "claude" && (parsed.flag_true("yolo") || tool.extra_flag("yolo")) {
args.push("--dangerously-skip-permissions".into());
}
args.extend(parsed.passthrough.clone());
Expand Down
4 changes: 2 additions & 2 deletions src/cmd/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ pub(crate) fn run_menu(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> R
let dumping = tui_wants_dump(parsed, env);

if dumping {
if launcher_uses_palette() {
if launcher_uses_palette(env) {
let (header, entries) =
launcher_palette(&path, parsed, env, &mut CreditsCache::fresh());
print!("{}", tui_dump_palette(entries, header, env));
Expand Down Expand Up @@ -326,7 +326,7 @@ pub(crate) fn run_menu(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> R
let key = resolve_api_key(&parsed.flags, env, profile);
kick_credits_refresh(&cache, base, key);
}
let inline = !launcher_uses_palette();
let inline = !launcher_uses_palette(env);
loop {
if inline {
let (status, actions) = {
Expand Down
5 changes: 5 additions & 0 deletions src/cmd/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ pub(crate) fn run_whoami(
term::dim("claude_opus "),
term::model_id(profile.claude_opus())
);
println!(
"{} {}",
term::dim("claude_fable "),
term::model_id(profile.claude_fable())
);
if let Some(tool) = &profile.default_tool {
println!(
"{} {}",
Expand Down
5 changes: 1 addition & 4 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,7 @@ fn dispatch(
stub("relay")
}
}
"cursor" | "cline" | "windsurf" => {
print!("{}", command_help(command).unwrap_or_default());
Ok(0)
}
"cursor" | "cline" | "windsurf" => stub(command),
"upgrade" | "update" => crate::upgrade::run(parsed, env),
"onboard" | "impl" | "plan" | "fix" | "deploy" | "cp" => {
crate::onboard::run(command, parsed)
Expand Down
33 changes: 32 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ pub fn parse_config(source: &str) -> Config {
.get(name)
.cloned()
.unwrap_or_else(ToolConfig::default);
tool.merge(&ToolConfig::from_yaml(tm));
tool.apply_yaml(tm);
config.tools.insert(name.clone(), tool);
}
}
Expand Down Expand Up @@ -987,6 +987,37 @@ agents:
assert_eq!(claude2.routing.min_context, Some(1_000_000));
}

#[test]
fn partial_tools_yaml_keeps_builtin_gateway_discovery() {
// WHY: `tools.claude.command` alone must not flip discovery off.
let cfg = parse_config(
"\
active_profile: default
profiles:
default:
api_key: x
tools:
claude:
command: /opt/claude
",
);
let claude = cfg.tools.get("claude").expect("claude tool");
assert_eq!(claude.command, "/opt/claude");
assert!(
claude.enable_gateway_model_discovery,
"partial overlay wiped gateway discovery"
);
let yaml = serialize_config(&cfg);
let again = parse_config(&yaml);
assert!(
again
.tools
.get("claude")
.expect("roundtrip")
.enable_gateway_model_discovery
);
}

#[test]
fn set_active_profile_rejects_unknown() {
let cfg = parse_config("active_profile: default\nprofiles:\n default:\n api_key: x\n");
Expand Down
16 changes: 11 additions & 5 deletions src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@ thread_local! {
}

const LAUNCH_HELP_BODY: &str = "\
Starts this coding agent through AnyRouter. First run signs in if needed
and, on a TTY, installs the agent if it is missing.
Launches the coding agent through AnyRouter (signs in first if needed).

Options:
--ok, --yes Non-interactive; launch does not open a picker
--no-check Skip the pre-launch reachability probe
--yes, --ok Skip confirmation prompts (login / install)
--model auto|<id> Session model. \"auto\" picks the most-used catalog model
--haiku <id> Claude /model haiku and subagents
--sonnet <id> Claude /model sonnet
Expand Down Expand Up @@ -240,7 +238,8 @@ pub fn command_help(command: &str) -> Option<String> {
"pi" => launch_help(&bin, "pi", "Pi"),
"pool" => launch_help(&bin, "pool", "Poolside"),
"cursor" | "cline" | "windsurf" => format!(
"{bin} {canonical} — print the AnyRouter base URL + key to paste into the editor\n"
"{bin} {canonical} — not a launch target yet.\n\
Print a key with `{bin} auth token` and the base URL with `{bin} onboard impl`.\n"
),
_ => return None,
})
Expand Down Expand Up @@ -550,6 +549,13 @@ mod tests {
assert!(auth.contains("ar auth <command>"), "{auth}");
let claude = command_help("claude").unwrap();
assert!(claude.contains("ar claude"), "{claude}");
assert!(!claude.contains("--no-check"), "{claude}");
assert!(!claude.contains("opens the launcher"), "{claude}");
let cursor = command_help("cursor").unwrap();
assert!(
cursor.contains("not a launch target") || cursor.contains("auth token"),
"{cursor}"
);
set_invoked_bin("anyr");
}

Expand Down
20 changes: 19 additions & 1 deletion src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ Already installed somewhere else? Point AnyRouter at it:\n\
)
}

/// Persist `tools[id].command` only for a real path override, not a PATH hit
/// that resolved the builtin name to `/usr/bin/claude`.
pub fn should_persist_command(resolved: &str, builtin: &str) -> bool {
let trimmed = resolved.trim();
if trimmed.is_empty() || trimmed == builtin {
return false;
}
trimmed.contains('/') || trimmed.contains('\\') || trimmed.starts_with('.')
}

pub fn resolve_executable(command: &str) -> Option<String> {
if command.contains('/') || command.contains('\\') || command.starts_with('.') {
return Some(command.to_string());
Expand All @@ -158,7 +168,7 @@ pub fn resolve_executable(command: &str) -> Option<String> {
return cached.clone();
}
let found = find_on_path(command);
// Cache hits only. A miss must be retried after install
// Cache hits only. A miss must be retried after `--install`
// (PATH changes in-process; a cached None would always fail).
if found.is_some() {
hits.borrow_mut().insert(command.to_string(), found.clone());
Expand Down Expand Up @@ -348,6 +358,14 @@ mod tests {
assert!(agent_available("codex", "codex", &env));
}

#[test]
fn should_persist_only_path_overrides() {
assert!(!should_persist_command("claude", "claude"));
assert!(!should_persist_command("", "claude"));
assert!(should_persist_command("/usr/bin/claude", "claude"));
assert!(should_persist_command("./bin/claude", "claude"));
}

#[test]
fn misses_are_not_cached_so_install_can_retry() {
assert!(resolve_executable("anyr-definitely-not-on-path-xyz").is_none());
Expand Down
6 changes: 5 additions & 1 deletion src/onboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ pub const VARIANTS: &[PromptVariant] = &[
pub fn resolve_mode(raw: &str) -> Option<&'static PromptVariant> {
let lower = raw.trim().to_ascii_lowercase();
let key = match lower.as_str() {
"implement" | "implementation" | "setup" => "impl",
"implement" | "implementation" => "impl",
"migrate" => "plan",
other => other,
};
Expand Down Expand Up @@ -563,6 +563,10 @@ mod tests {
assert_eq!(resolve_mode("fix").unwrap().id, "fix");
assert_eq!(resolve_mode("deploy").unwrap().id, "deploy");
assert_eq!(resolve_mode("claude-code").unwrap().id, "claude-code");
assert!(
resolve_mode("setup").is_none(),
"setup is login at the top level, not an onboard alias"
);
assert!(resolve_mode("nope").is_none());
}

Expand Down
Loading
Loading