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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ anyr login # browser / device code / paste; key saved on this m
anyr login --device # headless / SSH
anyr usage # credit balance
anyr models use <id> # persist default model
anyr keys list # switch with: anyr keys use
anyr keys audit # keys that look unused, with the reason
anyr account use work # switch saved profiles
anyr onboard impl # paste-ready prompt for a coding agent
anyr plan # migration plan prompt (no code changes)
Expand Down
31 changes: 27 additions & 4 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,17 +208,28 @@ pub fn start_device_flow(_base_url: &str, _tool: Option<&str>) -> Result<DeviceS
#[cfg(feature = "native")]
pub fn start_device_flow(base_url: &str, tool: Option<&str>) -> Result<DeviceStart, String> {
let url = join_api(base_url, "/v1/auth/cli/device/start");
let body = match tool {
Some(t) if !t.is_empty() => serde_json::json!({ "tool": t }).to_string(),
_ => "{}".into(),
};
let env: BTreeMap<String, String> = std::env::vars().collect();
let label = crate::cmd::key_audit::device_key_label(
&crate::cmd::key_audit::machine_host(&env),
crate::civil::now_secs(),
);
let body = device_start_body(tool, &label);
let (status, resp) = http_post(&url, None, Some(&body))?;
if !(200..300).contains(&status) {
return Err(format!("Failed to start device login (HTTP {status})."));
}
parse_device_start(&resp)
}

/// The device-start request: the launching tool and the name for the new key.
pub fn device_start_body(tool: Option<&str>, key_label: &str) -> String {
let mut body = serde_json::json!({ "key_label": key_label });
if let Some(t) = tool.filter(|t| !t.is_empty()) {
body["tool"] = serde_json::Value::String(t.to_string());
}
body.to_string()
}

/// 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)]
Expand Down Expand Up @@ -439,6 +450,18 @@ pub fn acquire_api_key(
mod tests {
use super::*;

#[test]
fn device_start_names_the_key_after_the_device() {
let body: serde_json::Value =
serde_json::from_str(&device_start_body(Some("cli"), "studio anyr 2026-10-27"))
.unwrap();
assert_eq!(body["key_label"], "studio anyr 2026-10-27");
assert_eq!(body["tool"], "cli");
let bare: serde_json::Value =
serde_json::from_str(&device_start_body(None, "studio anyr 2026-10-27")).unwrap();
assert!(bare.get("tool").is_none());
}

#[test]
fn parse_device_start_reads_fields() {
let start = parse_device_start(
Expand Down
28 changes: 1 addition & 27 deletions src/buildinfo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ fn local_time_from_utc(stamp: &str) -> Option<String> {
let num = |r: std::ops::Range<usize>| stamp.get(r)?.parse::<i64>().ok();
let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?);
let epoch = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s;
let epoch = crate::civil::days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s;

let mut tm: libc::tm = unsafe { std::mem::zeroed() };
let t: libc::time_t = epoch;
Expand All @@ -51,18 +51,6 @@ fn local_time_from_utc(stamp: &str) -> Option<String> {
))
}

/// Days since 1970-01-01 from a civil date (Howard Hinnant's algorithm).
#[cfg(unix)]
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = y - i64::from(m <= 2);
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -76,20 +64,6 @@ mod tests {
assert!(BUILD_TIME.ends_with('Z'), "{BUILD_TIME}");
}

#[test]
#[cfg(unix)] // days_from_civil is unix-only (its only caller is)
fn civil_roundtrip_known_dates() {
// (civil date, expected epoch seconds) pairs verified with `date -u`.
for (y, mo, d, h, mi, s, epoch) in [
(1970, 1, 1, 0, 0, 0, 0),
(2026, 8, 21, 9, 2, 36, 1_787_302_956),
(2000, 2, 29, 12, 0, 0, 951_825_600),
] {
let computed = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s;
assert_eq!(computed, epoch, "{y}-{mo}-{d} {h}:{mi}:{s}");
}
}

#[test]
fn display_time_falls_back_to_raw_stamp_on_bad_input() {
// display_time never panics even if the stamp were malformed.
Expand Down
79 changes: 79 additions & 0 deletions src/civil.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//! UTC calendar math without a date crate (Howard Hinnant's algorithms).

/// Days since 1970-01-01 from a civil date.
pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = y - i64::from(m <= 2);
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}

/// Civil `(year, month, day)` for a count of days since 1970-01-01.
pub fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(yoe + era * 400 + i64::from(m <= 2), m, d)
}

/// `YYYY-MM-DD` (UTC) for unix seconds.
pub fn ymd(epoch_secs: i64) -> String {
let (y, m, d) = civil_from_days(epoch_secs.div_euclid(86_400));
format!("{y:04}-{m:02}-{d:02}")
}

/// Unix seconds for `YYYY-MM-DDTHH:MM:SS` (a `T` or a space), read as UTC.
/// Any fraction or zone suffix after the seconds is ignored; the API sends UTC.
pub fn parse_utc(stamp: &str) -> Option<i64> {
let b = stamp.as_bytes();
if b.len() < 19 || b[4] != b'-' || b[7] != b'-' || !matches!(b[10], b'T' | b' ') {
return None;
}
let num = |r: std::ops::Range<usize>| stamp.get(r)?.parse::<i64>().ok();
let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?);
Some(days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s)
}

/// Seconds since the unix epoch, now.
pub fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn civil_roundtrip_known_dates() {
// (civil date, expected epoch seconds) pairs verified with `date -u`.
for (y, mo, d, h, mi, s, epoch) in [
(1970, 1, 1, 0, 0, 0, 0),
(2026, 8, 21, 9, 2, 36, 1_787_302_956),
(2000, 2, 29, 12, 0, 0, 951_825_600),
] {
let computed = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s;
assert_eq!(computed, epoch, "{y}-{mo}-{d} {h}:{mi}:{s}");
assert_eq!(civil_from_days(epoch.div_euclid(86_400)), (y, mo, d));
}
}

#[test]
fn parse_utc_reads_iso_and_sqlite_stamps() {
assert_eq!(parse_utc("2026-08-21T09:02:36Z"), Some(1_787_302_956));
assert_eq!(parse_utc("2026-08-21 09:02:36"), Some(1_787_302_956));
assert_eq!(parse_utc("2026-08-21T09:02:36.123Z"), Some(1_787_302_956));
assert_eq!(parse_utc("yesterday"), None);
assert_eq!(ymd(1_787_302_956), "2026-08-21");
}
}
50 changes: 21 additions & 29 deletions src/cmd/config_tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use crate::cmd::dispatch::{
};
#[cfg(feature = "native")]
use crate::cmd::dispatch::{tui_dump_settings, tui_settings_select};
use crate::cmd::keys::{run_keys, stored_api_key};
use crate::cmd::keys::stored_api_key;
use crate::cmd::login::run_login;
use crate::cmd::models::{
flag_agent, models_for_picker, pick_list, pick_model, run_models, save_model_slot,
Expand Down Expand Up @@ -164,6 +164,17 @@ pub(crate) enum SettingKind {
AgentRouting(&'static str, RoutingField),
}

/// The CLI's own key cannot read another key's secret (reveal takes a browser
/// session), so a different key means a fresh login or a pasted key.
fn print_new_key_hint() {
println!(
"{}",
term::dim(&crate::help::with_bin(
"New key for this machine: {bin} login · paste one: {bin} login --key - · unused keys: {bin} keys audit"
))
);
}

pub(crate) fn run_config_tui(
parsed: &ParsedArgs,
env: &BTreeMap<String, String>,
Expand Down Expand Up @@ -778,13 +789,8 @@ pub(crate) fn config_edit_row(
match kind {
SettingKind::Account => config_account_actions(parsed, env),
SettingKind::ApiKey => {
let mut next = parsed.clone();
next.command = "keys".into();
next.passthrough = vec!["use".into()];
match run_keys(&next, env) {
Err(err) if err == "Cancelled." => Ok(0),
other => other,
}
print_new_key_hint();
Ok(0)
}
SettingKind::Model(slot) => {
let existing = load_config_if_present(path);
Expand Down Expand Up @@ -886,16 +892,9 @@ pub(crate) fn config_edit_row(
.insert("agent".into(), FlagValue::Value(id.to_string()));
config_account_actions(&next, env)
}
SettingKind::AgentKey(id) => {
let mut next = parsed.clone();
next.command = "keys".into();
next.passthrough = vec!["use".into()];
next.flags
.insert("agent".into(), FlagValue::Value(id.to_string()));
match run_keys(&next, env) {
Err(err) if err == "Cancelled." => Ok(0),
other => other,
}
SettingKind::AgentKey(_) => {
print_new_key_hint();
Ok(0)
}
SettingKind::AgentModel(id) => {
let mut next = parsed.clone();
Expand Down Expand Up @@ -1151,7 +1150,6 @@ pub(crate) fn config_menu_loop_legacy(
path: &Path,
) -> Result<i32, String> {
let items = vec![
"Switch key".into(),
"Switch account".into(),
"Switch model".into(),
"Credits".into(),
Expand All @@ -1166,26 +1164,20 @@ pub(crate) fn config_menu_loop_legacy(
};
let result = match idx {
0 => {
let mut next = parsed.clone();
next.command = "keys".into();
next.passthrough = vec!["use".into()];
run_keys(&next, env)
}
1 => {
let mut next = parsed.clone();
next.passthrough = Vec::new();
run_auth_switch(&next, env)
}
2 => {
1 => {
let mut next = parsed.clone();
next.command = "models".into();
next.flags.insert("pick".into(), FlagValue::Bool(true));
next.passthrough = Vec::new();
run_models(&next, env)
}
3 => run_usage(parsed, env),
4 => run_login(parsed, env),
5 => run_logout(parsed, env),
2 => run_usage(parsed, env),
3 => run_login(parsed, env),
4 => run_logout(parsed, env),
_ => return Ok(0),
};
if let Err(err) = result {
Expand Down
Loading
Loading