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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ native = ["dep:ureq", "dep:ratatui", "dep:crossterm"]

[dependencies]
serde_json = "1"
libc = "0.2"
ureq = { version = "2.12", optional = true }
ratatui = { version = "=0.29.0", default-features = false, features = ["crossterm"], optional = true }
crossterm = { version = "=0.28.1", optional = true }
Expand Down
34 changes: 34 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
fn main() {
println!("cargo:rustc-env=ANYR_BUILD_TIME_UTC={}", utc_now());
}

/// Build time as `YYYY-MM-DDTHH:MM:SSZ` (UTC), std-only — civil-from-days per
/// Howard Hinnant's algorithm. Stored in UTC; the CLI renders it in the
/// viewer's local timezone at runtime.
fn utc_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let (y, mo, d, h, mi, s) = civil_from_unix(secs);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}

/// Split a unix timestamp into `(year, month, day, hour, minute, second)` in UTC.
pub fn civil_from_unix(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
// Shift the civil epoch so March is month 3; the year then starts in March.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
(y, mo as u32, d as u32, h as u32, mi as u32, s as u32)
}
89 changes: 89 additions & 0 deletions src/buildinfo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! Build metadata: embeds the compile timestamp (UTC, stamped by build.rs)
//! and renders it in the viewer's local timezone.

pub const BUILD_TIME: &str = match option_env!("ANYR_BUILD_TIME_UTC") {
Some(t) => t,
None => "unknown",
};

/// Build time formatted for display: local timezone when available,
/// otherwise the embedded UTC stamp.
pub fn display_time() -> String {
#[cfg(unix)]
if let Some(local) = local_time_from_utc(BUILD_TIME) {
return local;
}
BUILD_TIME.to_string()
}

/// Parse `YYYY-MM-DDTHH:MM:SSZ` and render it in the machine's local
/// timezone via `localtime_r`. Returns `None` when parsing fails.
#[cfg(unix)]
fn local_time_from_utc(stamp: &str) -> Option<String> {
let b = stamp.as_bytes();
if b.len() != 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != 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)?);
let epoch = 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;
if unsafe { libc::localtime_r(&t, &mut tm) }.is_null() {
return None;
}
Some(format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
tm.tm_year + 1900,
tm.tm_mon + 1,
tm.tm_mday,
tm.tm_hour,
tm.tm_min,
tm.tm_sec
))
}

/// 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::*;

#[test]
fn build_time_is_stamped() {
assert_ne!(BUILD_TIME, "unknown", "build.rs must stamp ANYR_BUILD_TIME_UTC");
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.
let _ = display_time();
}
}
81 changes: 77 additions & 4 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const LAUNCH_FLAGS: &[&str] = &[
"haiku",
"sonnet",
"opus",
"fable",
"profile",
"preset",
"key",
Expand Down Expand Up @@ -158,6 +159,7 @@ fn allowed_flags(command: &str) -> Option<&'static [&'static str]> {
],
"models" => &[
"profile", "config", "json", "key", "base-url", "pick", "haiku", "sonnet", "opus",
"fable",
],
"usage" => &["profile", "base-url", "config", "json", "key", "no-detail"],
"whoami" => &["profile", "config", "json"],
Expand Down Expand Up @@ -405,7 +407,7 @@ pub fn run(argv: Vec<String>, env: HashMap<String, String>) -> i32 {

let command = parsed.command.as_str();
if command == "--version" || command == "-v" {
println!("{VERSION}");
println!("{VERSION} (built {})", crate::buildinfo::display_time());
return 0;
}

Expand Down Expand Up @@ -550,6 +552,7 @@ fn persist_login(
profile.claude_haiku = prev.claude_haiku.clone();
profile.claude_sonnet = prev.claude_sonnet.clone();
profile.claude_opus = prev.claude_opus.clone();
profile.claude_fable = prev.claude_fable.clone();
}
let mut cfg = upsert_profile(existing.unwrap_or_default(), &name, profile);
cfg.active_profile = name.clone();
Expand Down Expand Up @@ -651,6 +654,7 @@ fn set_model_slot(profile: &mut Profile, slot: &str, id: String) {
"haiku" => profile.claude_haiku = Some(id),
"sonnet" => profile.claude_sonnet = Some(id),
"opus" => profile.claude_opus = Some(id),
"fable" => profile.claude_fable = Some(id),
_ => profile.default_model = Some(id),
}
}
Expand All @@ -661,11 +665,13 @@ fn pick_claude_slot(profile: &Profile) -> Result<&'static str, String> {
format!("Haiku · {}", profile.claude_haiku()),
format!("Sonnet · {}", profile.claude_sonnet()),
format!("Opus · {}", profile.claude_opus()),
format!("Fable · {}", profile.claude_fable()),
];
Ok(match term::pick("Which Claude model?", &items, Some(0))? {
1 => "haiku",
2 => "sonnet",
3 => "opus",
4 => "fable",
_ => "default",
})
}
Expand All @@ -675,6 +681,7 @@ fn slot_title(slot: &str) -> &'static str {
"haiku" => "Haiku model",
"sonnet" => "Sonnet model",
"opus" => "Opus model",
"fable" => "Fable model",
_ => "Default model",
}
}
Expand All @@ -684,6 +691,7 @@ fn slot_current<'a>(profile: &'a Profile, slot: &str) -> &'a str {
"haiku" => profile.claude_haiku(),
"sonnet" => profile.claude_sonnet(),
"opus" => profile.claude_opus(),
"fable" => profile.claude_fable(),
_ => profile.default_model(),
}
}
Expand All @@ -702,6 +710,10 @@ fn apply_claude_alias_flags(profile: &mut Profile, parsed: &ParsedArgs) -> bool
profile.claude_opus = Some(v);
changed = true;
}
if let Some(v) = get_string_flag(&parsed.flags, "fable") {
profile.claude_fable = Some(v);
changed = true;
}
changed
}

Expand All @@ -726,6 +738,7 @@ fn save_model_slot(
"haiku" => "haiku",
"sonnet" => "sonnet",
"opus" => "opus",
"fable" => "fable",
_ => "default model",
};
println!("{} {} {}", term::ok("Saved"), label, term::model_id(id));
Expand All @@ -746,6 +759,7 @@ fn run_models(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> Result<i32
("haiku", get_string_flag(&parsed.flags, "haiku")),
("sonnet", get_string_flag(&parsed.flags, "sonnet")),
("opus", get_string_flag(&parsed.flags, "opus")),
("fable", get_string_flag(&parsed.flags, "fable")),
];
let has_alias_flags = flag_slots.iter().any(|(_, v)| v.is_some());
if sub == Some("use") || has_alias_flags {
Expand Down Expand Up @@ -867,6 +881,7 @@ fn run_whoami(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> Result<i32
"claude_haiku": profile.claude_haiku(),
"claude_sonnet": profile.claude_sonnet(),
"claude_opus": profile.claude_opus(),
"claude_fable": profile.claude_fable(),
"default_tool": profile.default_tool,
"base_url": profile.base_url(),
});
Expand Down Expand Up @@ -958,6 +973,11 @@ fn print_config_status(
term::dim("opus "),
term::model_id(p.claude_opus())
);
println!(
"{} {}",
term::dim("fable "),
term::model_id(p.claude_fable())
);
}
if let Some(tool) = profile.and_then(|p| p.default_tool.as_deref()) {
println!(
Expand Down Expand Up @@ -1185,6 +1205,7 @@ fn run_launch(
p.claude_haiku = profile.claude_haiku.clone();
p.claude_sonnet = profile.claude_sonnet.clone();
p.claude_opus = profile.claude_opus.clone();
p.claude_fable = profile.claude_fable.clone();
}
}
let _ = write_config(&cfg, &path);
Expand Down Expand Up @@ -1599,20 +1620,21 @@ fn run_menu(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> Result<i32,
let dumping = tui_wants_dump(parsed, env);

if dumping {
let (header, items) = launcher_frame(&path, parsed, env);
let (header, items) = launcher_frame(&path, parsed, env, &mut CreditsCache::fresh());
print!("{}", tui_dump_menu("AnyRouter", header, items, env));
return Ok(0);
}

if !term::is_interactive() {
let (_, items) = launcher_frame(&path, parsed, env);
let (_, items) = launcher_frame(&path, parsed, env, &mut CreditsCache::fresh());
println!("{}", items.join("\n"));
return Ok(0);
}

// Loop until Quit or a coding-agent launch takes over the process.
let mut credits = CreditsCache::fresh();
loop {
let (header, items) = launcher_frame(&path, parsed, env);
let (header, items) = launcher_frame(&path, parsed, env, &mut credits);
let Some(idx) = tui_menu_select("AnyRouter", header, items.clone())? else {
return Ok(0);
};
Expand Down Expand Up @@ -1656,15 +1678,65 @@ fn launcher_signed_in(path: &PathBuf, parsed: &ParsedArgs, env: &BTreeMap<String
stored_api_key(parsed, env, path).is_some()
}

/// Credits cache for the launcher loop: one fetch per TTL instead of one per
/// frame render. `None` = never fetched / fetch failed (shown as unknown).
struct CreditsCache {
value: Option<Result<String, ()>>,
fetched_at: Option<std::time::Instant>,
}

const CREDITS_TTL: std::time::Duration = std::time::Duration::from_secs(300);

impl CreditsCache {
fn fresh() -> Self {
Self {
value: None,
fetched_at: None,
}
}

/// Return the cached display string, refreshing when stale.
fn get(&mut self, base_url: &str, api_key: Option<&str>) -> String {
let expired = self
.fetched_at
.map(|t| t.elapsed() > CREDITS_TTL)
.unwrap_or(true);
if expired {
self.value = match api_key {
Some(key) => Some(
fetch_credits(base_url, key)
.map(|c| crate::http::format_usd(c["balance"].as_f64().unwrap_or(0.0)))
.map_err(|_| ()),
),
None => Some(Err(())),
};
self.fetched_at = Some(std::time::Instant::now());
}
match &self.value {
Some(Ok(s)) => s.clone(),
_ => "(unknown)".into(),
}
}
}

fn launcher_frame(
path: &PathBuf,
parsed: &ParsedArgs,
env: &BTreeMap<String, String>,
credits: &mut CreditsCache,
) -> (Vec<String>, Vec<String>) {
let cfg = load_config_if_present(path).unwrap_or_default();
let profile = cfg.profiles.get(&cfg.active_profile);
let signed_in = launcher_signed_in(path, parsed, env);
let last = launcher_last_tool(path, parsed, env);
let base = resolve_base_url(&parsed.flags, profile);
let key = resolve_api_key(&parsed.flags, env, profile);
let credits_line = if tui_wants_dump(parsed, env) || !term::is_interactive() {
// Dump mode and pipes must stay offline-deterministic.
"credits -".to_string()
} else {
format!("credits {}", credits.get(&base, key.as_deref()))
};
let header = vec![
format!(
"account {} {}",
Expand All @@ -1680,6 +1752,7 @@ fn launcher_frame(
display_model_id(profile.map(|p| p.default_model()).unwrap_or("auto"))
),
format!("agent {last}"),
credits_line,
];

let mut items = Vec::new();
Expand Down
Loading
Loading