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
This commit is contained in:
2026-08-17 22:24:45 -04:00
parent 98c0b30f68
commit 890ad953e5
12 changed files with 229 additions and 10 deletions
+5
View File
@@ -157,6 +157,11 @@ pub enum Command {
#[arg(long, value_name = "DIR")]
output: Option<PathBuf>,
},
/// Set or show the active color theme (persisted for the next run)
Theme {
/// Theme name (omit to list available themes)
name: Option<String>,
},
/// Internal: list agent names for shell completions (hidden)
#[command(name = "_agents", hide = true)]
Agents {
+3 -1
View File
@@ -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<i32> {
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),
+41
View File
@@ -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<i32> {
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 <name>' to switch");
}
Ok(0)
}
+34
View File
@@ -11,7 +11,17 @@ use std::path::PathBuf;
pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
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<i32> {
fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
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 {}'",
+32
View File
@@ -102,6 +102,35 @@ pub struct Settings {
/// lines (global; project profiles can add their own).
#[serde(default)]
pub hooks: BTreeMap<String, Vec<String>>,
/// Update policy: how aggressively 'am update' should act (issue #65).
#[serde(default)]
pub update_policy: Option<UpdatePolicy>,
}
/// 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<String>,
/// Pinned version: 'am update' will refuse to install a different one (issue #65).
#[serde(default)]
pub pin_version: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
/// When false, the agent is listed but cannot be installed locally.
+40 -2
View File
@@ -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 {