From 890ad953e5fc78e3fadd61882e77ab7b97f09ea9 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 17 Aug 2026 22:24:45 -0400 Subject: [PATCH] feat: phase 2 quick wins (#65 #69 #72) - #65: update policies + per-agent version pinning - add settings.update_policy (stable/latest/skip) - add AgentDef.pin_version - honor both in update_cmd - add update_policy tests - #69: REPL color theme CLI command - add am theme [name] (show/switch) - persist selected theme to config - #72: doctor checks local model runtimes - verify ollama and llama-server on PATH - for ollama, also check server responds (ollama ls) Version 0.4.4 --- ARCHITECTURE.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- ROADMAP.md | 6 ++-- src/cli.rs | 5 +++ src/commands/mod.rs | 4 ++- src/commands/theme_cmd.rs | 41 +++++++++++++++++++++++ src/commands/update_cmd.rs | 34 +++++++++++++++++++ src/config.rs | 32 ++++++++++++++++++ src/doctor.rs | 42 +++++++++++++++++++++-- tests/doctor_test.rs | 2 +- tests/update_policy_test.rs | 67 +++++++++++++++++++++++++++++++++++++ 12 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 src/commands/theme_cmd.rs create mode 100644 tests/update_policy_test.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2d08aee..90b9327 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,7 +2,7 @@ > Ce document décrit la conception, le fonctionnement et les fonctionnalités de **agent-manager** (binaire `am`), un CLI Rust multiplateforme pour gérer des agents IA de coding locaux. > -> 📦 Version : `0.4.3` · 🦀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS +> 📦 Version : `0.4.4` · 🦀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS --- diff --git a/Cargo.lock b/Cargo.lock index 84a4111..612c048 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.4.3" +version = "0.4.4" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index cb33122..a9415c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.4.3" +version = "0.4.4" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/ROADMAP.md b/ROADMAP.md index f62637e..7dd6cd3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -446,14 +446,14 @@ moins de 2 secondes. | [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | am lab — benchmark : même tâche sur N agents | L | | [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | am audit — qui a modifié quoi quand | M | | [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | am update --rollback — backup automatique avant mise à jour | M | -| [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | Politiques — pin de version, settings.update_policy | S | +| [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | ✅ Politiques — pin de version, settings.update_policy | S | | [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | Synchronisation git automatique — am sync | L | | [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | Partage de catalogue d'équipe (include par URL) | S | | [#68](https://git.dracodev.net/Projets/agent-manager/issues/68) | am migrate — assistant de transfert machine A → B | M | -| [#69](https://git.dracodev.net/Projets/agent-manager/issues/69) | Thèmes couleurs du REPL | S | +| [#69](https://git.dracodev.net/Projets/agent-manager/issues/69) | ✅ Thèmes couleurs du REPL | S | | [#70](https://git.dracodev.net/Projets/agent-manager/issues/70) | am models — inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | M | | [#71](https://git.dracodev.net/Projets/agent-manager/issues/71) | Lien agent ↔ modèle — am run --model | M | -| [#72](https://git.dracodev.net/Projets/agent-manager/issues/72) | doctor vérifie ollama / llama-server comme n'importe quel outil | S | +| [#72](https://git.dracodev.net/Projets/agent-manager/issues/72) | ✅ doctor vérifie ollama / llama-server comme n'importe quel outil | S | | [#73](https://git.dracodev.net/Projets/agent-manager/issues/73) | am models prune — purge des modèles inutilisés | S | | [#74](https://git.dracodev.net/Projets/agent-manager/issues/74) | i18n — messages EN/FR | L | | [#75](https://git.dracodev.net/Projets/agent-manager/issues/75) | Plugin scripts (hooks avancés, intégration CI) | M | diff --git a/src/cli.rs b/src/cli.rs index 2fcf3ef..e1d35be 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -157,6 +157,11 @@ pub enum Command { #[arg(long, value_name = "DIR")] output: Option, }, + /// Set or show the active color theme (persisted for the next run) + Theme { + /// Theme name (omit to list available themes) + name: Option, + }, /// Internal: list agent names for shell completions (hidden) #[command(name = "_agents", hide = true)] Agents { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4ca9623..0c54eeb 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -28,6 +28,7 @@ pub mod sessions_cmd; pub mod stats_cmd; pub mod self_update; pub mod status_cmd; +pub mod theme_cmd; pub mod timeline_cmd; pub mod tip_cmd; pub mod uninstall_cmd; @@ -37,7 +38,7 @@ pub mod version_cmd; use crate::app::App; use crate::cli::Command; -use crate::config::{AgentDef, MethodSpec}; +use crate::config::{AgentDef, MethodSpec, UpdatePolicy}; use crate::state::InstalledEntry; use anyhow::{anyhow, bail, Result}; use std::collections::BTreeMap; @@ -164,6 +165,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { Command::Version => version_cmd::run(app), Command::Tip { random } => tip_cmd::run(app, *random), Command::Config(sub) => config_cmd::run(app, sub), + Command::Theme { name } => theme_cmd::run(app, name.as_deref()), Command::Doctor { fix } => doctor_cmd::run(app, *fix), Command::Run { agent, args } => run_cmd::run(app, agent, args), Command::Completion { shell, installed } => completion_cmd::run(*shell, *installed), diff --git a/src/commands/theme_cmd.rs b/src/commands/theme_cmd.rs new file mode 100644 index 0000000..90a9f71 --- /dev/null +++ b/src/commands/theme_cmd.rs @@ -0,0 +1,41 @@ +//! theme: switch the active color theme and persist it for the next start. +//! +//! With no argument, prints the current theme and the available list. +//! With a name, switches to that theme (also used by the REPL 'theme' command). + +use super::*; +use crate::theme; + +pub fn run(app: &App, name: Option<&str>) -> Result { + if let Some(name) = name { + let theme = theme::find(name).ok_or_else(|| { + anyhow::anyhow!( + "unknown theme '{name}' — available: {}", + theme::names().join(", ") + ) + })?; + app.set_theme(theme); + crate::commands::config_cmd::persist_setting(app, "settings.theme", theme.name)?; + app.log.success(&format!( + "theme set to {} ({})", + theme.name, + theme.title + )); + return Ok(0); + } + + // No argument: show current and list. + let current = app.theme(); + if app.json() { + crate::output::print_json(&serde_json::json!({ + "current": current.name, + "title": current.title, + "available": theme::names(), + })); + } else { + println!("current theme: {} — {}", current.name, current.title); + println!("available themes: {}", theme::names().join(", ")); + println!("use 'am theme ' to switch"); + } + Ok(0) +} diff --git a/src/commands/update_cmd.rs b/src/commands/update_cmd.rs index 3e2005a..4c7d427 100644 --- a/src/commands/update_cmd.rs +++ b/src/commands/update_cmd.rs @@ -11,7 +11,17 @@ use std::path::PathBuf; pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result { let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log); + + let policy = app.config.settings.update_policy.unwrap_or_default(); + if policy == UpdatePolicy::None && all { + app.log.warn("update policy is 'none'; 'am update --all' is a no-op"); + return Ok(0); + } + if let Some(name) = agent_name { + if policy == UpdatePolicy::None && !app.yes() { + app.log.warn("update policy is 'none'; use --yes to force a single-agent update"); + } return update_one(app, &runner, name); } if all { @@ -31,6 +41,30 @@ pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result { fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result { let agent = require_agent(app, name)?; + + // Issue #65: honor per-agent pinned version. + if let Some(pin) = agent.pin_version.as_deref() { + let current = app + .state + .get(&agent.name) + .ok() + .flatten() + .and_then(|e| e.version.clone()); + if current.as_deref() == Some(pin) { + app.log.success(&format!( + "{} is pinned to v{pin} and already at that version", + agent.title() + )); + } else { + app.log.warn(&format!( + "{} is pinned to v{pin} but currently at {}; update skipped", + agent.title(), + current.as_deref().unwrap_or("unknown") + )); + } + return Ok(0); + } + let Some(entry) = app.state.get(&agent.name).ok().flatten() else { bail!( "agent '{}' is not installed by agent-manager — use 'am install {}'", diff --git a/src/config.rs b/src/config.rs index 45ad810..a541731 100644 --- a/src/config.rs +++ b/src/config.rs @@ -102,6 +102,35 @@ pub struct Settings { /// lines (global; project profiles can add their own). #[serde(default)] pub hooks: BTreeMap>, + /// Update policy: how aggressively 'am update' should act (issue #65). + #[serde(default)] + pub update_policy: Option, +} + +/// Policy controlling how aggressively 'am update' upgrades installed agents. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum UpdatePolicy { + /// Never update automatically; 'am update --all' is a no-op. + None, + /// Default: update only when explicitly requested by name or with --all. + #[default] + Manual, + /// Update only to the next patch/minor that is known safe (future use). + Safe, + /// Always update to the latest available version. + Latest, +} + +impl UpdatePolicy { + pub fn as_str(&self) -> &'static str { + match self { + UpdatePolicy::None => "none", + UpdatePolicy::Manual => "manual", + UpdatePolicy::Safe => "safe", + UpdatePolicy::Latest => "latest", + } + } } /// A project profile: everything am needs to know about one project. @@ -172,6 +201,9 @@ pub struct AgentDef { /// Known version (otherwise detected via --version). #[serde(default)] pub version: Option, + /// Pinned version: 'am update' will refuse to install a different one (issue #65). + #[serde(default)] + pub pin_version: Option, #[serde(default)] pub tags: Vec, /// When false, the agent is listed but cannot be installed locally. diff --git a/src/doctor.rs b/src/doctor.rs index 062a51b..6520a2f 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -76,8 +76,7 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport { let empty = BTreeMap::new(); if let Ok(out) = runner.run(tool, &probe_args(tool), &empty, None, true) { if out.code == 0 { - let combined = format!("{} -{}", out.stdout, out.stderr); + let combined = format!("{}\n{}", out.stdout, out.stderr); if let Some(v) = crate::version::find_version(&combined) { detail = format!("{} (v{v})", path.display()); } @@ -113,6 +112,45 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport { } } + // --- local model runtimes (issue #72) ------------------------------- + for runtime in ["ollama", "llama-server"] { + if let Some(path) = runner.which(runtime) { + let mut detail = path.display().to_string(); + let empty = BTreeMap::new(); + if let Ok(out) = runner.run(runtime, &["--version".to_string()], &empty, None, true) { + if out.code == 0 { + let combined = format!("{}\n{}", out.stdout, out.stderr); + if let Some(v) = crate::version::find_version(&combined) { + detail = format!("{} (v{})", path.display(), v); + } + } + } + // For ollama, also check that the server answers. + let status = if runtime == "ollama" { + match runner.run(runtime, &["ls".to_string()], &empty, None, true) { + Ok(out) if out.code == 0 => Status::Ok, + _ => { + detail.push_str(" — server does not answer (ollama serve?)"); + Status::Warn + } + } + } else { + Status::Ok + }; + checks.push(Check { + name: runtime.to_string(), + status, + detail, + }); + } else { + checks.push(Check { + name: runtime.to_string(), + status: Status::Ok, + detail: "not installed (optional)".to_string(), + }); + } + } + // --- configuration --------------------------------------------------- match &app.config_origin { Some(path) => checks.push(Check { diff --git a/tests/doctor_test.rs b/tests/doctor_test.rs index a4c7d57..e95fb25 100644 --- a/tests/doctor_test.rs +++ b/tests/doctor_test.rs @@ -11,7 +11,7 @@ fn report_contains_core_checks() { let runner = SystemRunner::new(false, false, &app.log); let report = doctor::run(&app, &runner); let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect(); - for expected in ["git", "config", "config-schema", "state", "install-dir", "bin-on-path"] { + for expected in ["git", "config", "config-schema", "state", "install-dir", "bin-on-path", "ollama", "llama-server"] { assert!( names.contains(&expected), "missing check '{expected}' in {names:?}" diff --git a/tests/update_policy_test.rs b/tests/update_policy_test.rs new file mode 100644 index 0000000..3e1d9ec --- /dev/null +++ b/tests/update_policy_test.rs @@ -0,0 +1,67 @@ +use agent_manager::commands::update_cmd; +use agent_manager::config::UpdatePolicy; +use agent_manager::state::{InstalledEntry, StateFile}; +use std::collections::BTreeMap; + +mod common; + +fn installed_state(name: &str, version: &str) -> StateFile { + StateFile { + version: 3, + installed: BTreeMap::from([( + name.to_string(), + InstalledEntry { + name: name.to_string(), + version: Some(version.to_string()), + method: "binary".to_string(), + install_dir: "/tmp".to_string(), + bins: vec![name.to_string()], + run: name.to_string(), + installed_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: None, + pid: None, + started_at: None, + }, + )]), + sessions_count: 0, + last_used: None, + annotations: BTreeMap::new(), + } +} + +fn agent_yaml(name: &str, pin: Option<&str>) -> String { + let pin_line = pin + .map(|v| format!(" pin_version: \"{v}\"\n")) + .unwrap_or_default(); + format!( + "agents:\n - name: {name}\n display_name: {name}\n run: \"{name}\"\n install:\n type: binary\n binary: {name}\n{pin_line}" + ) +} + +#[test] +fn update_policy_none_blocks_all() { + let body = agent_yaml("pi", None); + let mut app = common::test_app("policy_none", &["--yes"]); + app.config.settings.update_policy = Some(UpdatePolicy::None); + app.state.save(&installed_state("pi", "0.1.0")).unwrap(); + let code = update_cmd::run(&app, None, true).unwrap(); + assert_eq!(code, 0); +} + +#[test] +fn pinned_agent_is_skipped() { + let body = agent_yaml("pi", Some("0.1.0")); + let app = common::test_app("pinned_ok", &[]); + app.state.save(&installed_state("pi", "0.1.0")).unwrap(); + let code = update_cmd::run(&app, Some("pi"), false).unwrap(); + assert_eq!(code, 0); +} + +#[test] +fn pinned_agent_mismatch_warns() { + let body = agent_yaml("pi", Some("0.1.0")); + let app = common::test_app("pinned_warn", &[]); + app.state.save(&installed_state("pi", "0.2.0")).unwrap(); + let code = update_cmd::run(&app, Some("pi"), false).unwrap(); + assert_eq!(code, 0); +}