use argonauts_conductor::cli_versions::{
CliVersions, Installed, out_line, startup_warnings, version_check,
};
use argonauts_conductor::doctor::newest::{NEWEST_TIMEOUT, install_source, newest_version};
use std::path::Path;
use std::sync::Arc;
use argonauts_conductor::{AgentCheck, AgentStatus, DoctorReport};
use argonauts_core::Adapter;
use argonauts_core::log::NoteLevel;
use time::OffsetDateTime;
use super::Ctx;
pub(crate) fn start_lines(ctx: &Ctx, report: &DoctorReport) -> Vec<(NoteLevel, String)> {
if !ctx.settings().ui.newer_cli_notice {
return Vec::new();
}
let Ok(adapters) = crate::commands::adapters_here(Some(&ctx.env), ctx.settings.resolved())
else {
return Vec::new();
};
lines_once(&adapters, &ctx.env.paths.cli_versions(), report)
}
fn lines_once(
adapters: &[Arc<dyn Adapter>],
seen_at: &Path,
report: &DoctorReport,
) -> Vec<(NoteLevel, String)> {
let mut seen = CliVersions::load(seen_at);
let mut lines = Vec::new();
for check in report.agents.iter().filter(|check| in_use(check)) {
let Some(adapter) = adapters.iter().find(|adapter| adapter.id() == check.id) else {
continue;
};
let found = version_check(adapter.as_ref(), Installed::of(check), &seen);
if !found.standing.needs_update()
&& let Some(line) = out_line(&check.display_name, &found)
&& let Some(newest) = &found.newest
&& seen.first_reminder(&check.id, newest)
{
lines.push((NoteLevel::Quiet, line));
}
let Some(version) = &check.version else {
continue;
};
let missing = seen.startup_missing(&check.id, version).unwrap_or_default();
for (field, warning) in startup_warnings(adapter.as_ref(), version, &missing) {
if seen.first_warning(&check.id, version, field) {
lines.push((NoteLevel::Warn, warning));
}
}
}
if lines.is_empty() || seen.save(seen_at).is_err() {
return Vec::new();
}
lines
}
pub(crate) fn ask_newest(ctx: &Ctx, report: &DoctorReport) {
let Ok(adapters) = crate::commands::adapters_here(Some(&ctx.env), ctx.settings.resolved())
else {
return;
};
let path = ctx.env.paths.cli_versions();
let seen = CliVersions::load(&path);
let now = OffsetDateTime::now_utc();
let due: Vec<_> = report
.agents
.iter()
.filter(|check| in_use(check) && seen.newest_due(&check.id, now))
.filter_map(|check| {
let adapter = adapters.iter().find(|adapter| adapter.id() == check.id)?;
let source = install_source(check.path.as_deref()?, &adapter.install_sources())?;
Some((check.id.clone(), source))
})
.collect();
if due.is_empty() {
return;
}
tokio::spawn(async move {
let asked = due
.into_iter()
.map(|(id, source)| async move { (id, newest_version(&source, NEWEST_TIMEOUT).await) });
let answers = futures::future::join_all(asked).await;
let mut seen = CliVersions::load(&path);
for (id, version) in answers {
seen.asked_newest(&id, version, now);
}
if let Err(err) = seen.save(&path) {
tracing::debug!("couldn't keep the CLIs' newest versions: {err}");
}
});
}
pub(crate) fn a_run_is_going(ctx: &Ctx) -> bool {
!crate::live_registry::live_run_hosts(&ctx.env.paths).is_empty()
}
pub(crate) const UPDATE_WAITS: &str = "A run is going, and CLIs are updated between runs so that \
none switches versions halfway. /update once it has ended.";
fn in_use(check: &AgentCheck) -> bool {
check.enabled && check.status != AgentStatus::NotInstalled
}
#[cfg(test)]
mod tests {
use argonauts_adapter_claude_code::ClaudeCodeAdapter;
use argonauts_conductor::GitCheck;
use super::*;
fn report(version: &str) -> DoctorReport {
DoctorReport {
agents: vec![AgentCheck {
id: "claude-code".to_owned(),
display_name: "Claude Code".to_owned(),
binary: "claude".to_owned(),
path: None,
version: Some(version.to_owned()),
status: AgentStatus::Tested,
tested_versions: Vec::new(),
cheapest_model: String::new(),
cheapest_effort: None,
problem: None,
enabled: true,
}],
git: GitCheck {
version: None,
problem: None,
},
}
}
fn claude() -> Vec<Arc<dyn Adapter>> {
vec![Arc::new(ClaudeCodeAdapter)]
}
fn texts(lines: &[(NoteLevel, String)]) -> Vec<&str> {
lines.iter().map(|(_, text)| text.as_str()).collect()
}
fn newest_is(path: &Path, version: &str) {
let mut seen = CliVersions::load(path);
seen.asked_newest(
"claude-code",
Some(version.to_owned()),
OffsetDateTime::now_utc(),
);
seen.save(path).unwrap();
}
#[test]
fn a_newer_version_is_named_once_at_start_up_then_never_again() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cli-versions.json");
let installed = report("2.1.296");
assert!(lines_once(&claude(), &path, &installed).is_empty());
newest_is(&path, "2.1.297");
let said = lines_once(&claude(), &path, &installed);
let picture = include_str!("../../../tui/tests/golden/overview/versions.txt");
let drawn = picture.lines().nth(9).unwrap();
assert_eq!(format!(" {}", said[0].1), drawn);
assert_eq!(said[0].0, NoteLevel::Quiet);
assert!(lines_once(&claude(), &path, &installed).is_empty());
assert!(lines_once(&claude(), &path, &installed).is_empty());
newest_is(&path, "2.1.298");
assert_eq!(
texts(&lines_once(&claude(), &path, &installed)),
["Claude Code 2.1.298 is out · claude update · shown once for this version"]
);
assert!(lines_once(&claude(), &path, &installed).is_empty());
assert!(lines_once(&claude(), &path, &report("2.1.298")).is_empty());
}
#[test]
fn what_a_version_stopped_reporting_is_warned_once() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cli-versions.json");
let mut seen = CliVersions::load(&path);
seen.saw_startup("claude-code", "2.1.297", &["/model"]);
seen.save(&path).unwrap();
let installed = report("2.1.297");
let said = lines_once(&claude(), &path, &installed);
assert_eq!(
texts(&said),
[
"Claude Code 2.1.297 no longer reports its model, so /context can't size its \
window until Argonauts Code is updated"
]
);
assert_eq!(said[0].0, NoteLevel::Warn);
assert!(lines_once(&claude(), &path, &installed).is_empty());
}
#[test]
fn a_cli_that_is_off_or_missing_is_never_named() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cli-versions.json");
newest_is(&path, "2.1.297");
let mut off = report("2.1.296");
off.agents[0].enabled = false;
assert!(lines_once(&claude(), &path, &off).is_empty());
let mut missing = report("2.1.296");
missing.agents[0].status = AgentStatus::NotInstalled;
assert!(lines_once(&claude(), &path, &missing).is_empty());
assert_eq!(lines_once(&claude(), &path, &report("2.1.296")).len(), 1);
}
}