- #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
317 lines
10 KiB
Rust
317 lines
10 KiB
Rust
//! doctor: environment checks — tools, config validity, paths,
|
|
//! permissions — with an optional --fix mode.
|
|
|
|
use crate::app::App;
|
|
use crate::runner::Runner;
|
|
use anyhow::{Context, Result};
|
|
use serde::Serialize;
|
|
use std::collections::BTreeMap;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum Status {
|
|
Ok,
|
|
Warn,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct Check {
|
|
pub name: String,
|
|
pub status: Status,
|
|
pub detail: String,
|
|
}
|
|
|
|
pub struct DoctorReport {
|
|
pub checks: Vec<Check>,
|
|
}
|
|
|
|
impl DoctorReport {
|
|
pub fn ok(&self) -> bool {
|
|
!self.checks.iter().any(|c| c.status == Status::Error)
|
|
}
|
|
|
|
pub fn problems(&self) -> usize {
|
|
self.checks.iter().filter(|c| c.status != Status::Ok).count()
|
|
}
|
|
}
|
|
|
|
/// Args for probing a tool's version.
|
|
fn probe_args(tool: &str) -> Vec<String> {
|
|
match tool {
|
|
"go" => vec!["version".to_string()],
|
|
_ => vec!["--version".to_string()],
|
|
}
|
|
}
|
|
|
|
pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
|
|
let mut checks = Vec::new();
|
|
|
|
// --- tools ----------------------------------------------------------
|
|
// (name, required)
|
|
let tools: &[(&str, bool)] = &[
|
|
("git", true),
|
|
("sh", cfg!(unix)),
|
|
("curl", true),
|
|
("node", false),
|
|
("npm", false),
|
|
("python3", false),
|
|
("python", false),
|
|
("cargo", false),
|
|
("go", false),
|
|
("bun", false),
|
|
("uv", false),
|
|
("docker", false),
|
|
];
|
|
let os = crate::toolchain::OsInfo::detect();
|
|
for (tool, required) in tools {
|
|
let found = if *tool == "python" {
|
|
runner.which("python3").or_else(|| runner.which("python"))
|
|
} else {
|
|
runner.which(tool)
|
|
};
|
|
match found {
|
|
Some(path) => {
|
|
let mut detail = path.display().to_string();
|
|
let empty = BTreeMap::new();
|
|
if let Ok(out) = runner.run(tool, &probe_args(tool), &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{v})", path.display());
|
|
}
|
|
}
|
|
}
|
|
checks.push(Check {
|
|
name: tool.to_string(),
|
|
status: Status::Ok,
|
|
detail,
|
|
});
|
|
}
|
|
None => {
|
|
let mut detail = if *required {
|
|
"required but not found on PATH".to_string()
|
|
} else {
|
|
"not installed".to_string()
|
|
};
|
|
let cmds = crate::toolchain::install_commands(tool, &os);
|
|
if !cmds.is_empty() {
|
|
detail.push_str(&format!(
|
|
" — on {}: {}",
|
|
os.name,
|
|
cmds.join(" | ")
|
|
));
|
|
}
|
|
let status = if *required { Status::Error } else { Status::Ok };
|
|
checks.push(Check {
|
|
name: tool.to_string(),
|
|
status,
|
|
detail,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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 {
|
|
name: "config".to_string(),
|
|
status: Status::Ok,
|
|
detail: path.display().to_string(),
|
|
}),
|
|
None => checks.push(Check {
|
|
name: "config".to_string(),
|
|
status: Status::Warn,
|
|
detail: "no user config file found; using the embedded default catalog".to_string(),
|
|
}),
|
|
}
|
|
let problems = crate::config::validate(&app.config);
|
|
if problems.is_empty() {
|
|
checks.push(Check {
|
|
name: "config-schema".to_string(),
|
|
status: Status::Ok,
|
|
detail: format!("{} agents defined", app.catalog.len()),
|
|
});
|
|
} else {
|
|
checks.push(Check {
|
|
name: "config-schema".to_string(),
|
|
status: Status::Error,
|
|
detail: problems.join("; "),
|
|
});
|
|
}
|
|
|
|
// --- state file ------------------------------------------------------
|
|
match app.state.load() {
|
|
Ok(sf) => checks.push(Check {
|
|
name: "state".to_string(),
|
|
status: Status::Ok,
|
|
detail: format!("{} installed agent(s)", sf.installed.len()),
|
|
}),
|
|
Err(e) => checks.push(Check {
|
|
name: "state".to_string(),
|
|
status: Status::Error,
|
|
detail: format!("{e:#}"),
|
|
}),
|
|
}
|
|
|
|
// --- directories -----------------------------------------------------
|
|
for (name, dir) in [
|
|
("install-dir", app.paths.install_dir.clone()),
|
|
("bin-dir", app.paths.bin_dir.clone()),
|
|
("log-dir", app.paths.log_dir.clone()),
|
|
] {
|
|
let status = if dir.is_dir() {
|
|
Status::Ok
|
|
} else if app.paths.install_dir.parent().map(|p| p.is_dir()).unwrap_or(false) {
|
|
Status::Warn
|
|
} else {
|
|
Status::Warn
|
|
};
|
|
let detail = if dir.is_dir() {
|
|
dir.display().to_string()
|
|
} else {
|
|
format!("{} (missing; 'am doctor --fix' creates it)", dir.display())
|
|
};
|
|
checks.push(Check {
|
|
name: name.to_string(),
|
|
status,
|
|
detail,
|
|
});
|
|
}
|
|
|
|
// --- bin dir on PATH --------------------------------------------------
|
|
let on_path = is_on_path(&app.paths.bin_dir);
|
|
checks.push(Check {
|
|
name: "bin-on-path".to_string(),
|
|
status: if on_path { Status::Ok } else { Status::Warn },
|
|
detail: if on_path {
|
|
format!("{} is on PATH", app.paths.bin_dir.display())
|
|
} else {
|
|
format!(
|
|
"{} is not on PATH — add it so agents are found from your shell",
|
|
app.paths.bin_dir.display()
|
|
)
|
|
},
|
|
});
|
|
|
|
DoctorReport { checks }
|
|
}
|
|
|
|
fn is_on_path(dir: &std::path::Path) -> bool {
|
|
let Some(path) = std::env::var_os("PATH") else {
|
|
return false;
|
|
};
|
|
std::env::split_paths(&path).any(|p| {
|
|
if cfg!(windows) {
|
|
p.to_string_lossy().to_lowercase() == dir.to_string_lossy().to_lowercase()
|
|
} else {
|
|
p == dir
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Offer to install the missing optional tools (node, python, ...) with
|
|
/// the command detected for the current OS. Returns what was repaired.
|
|
pub fn install_missing_tools(app: &App, runner: &dyn Runner) -> Result<Vec<String>> {
|
|
let mut installed = Vec::new();
|
|
let os = crate::toolchain::OsInfo::detect();
|
|
for tool in ["git", "node", "python", "cargo", "go", "bun", "uv", "docker"] {
|
|
if crate::toolchain::tool_is_present(tool, runner) {
|
|
continue;
|
|
}
|
|
let cmds = crate::toolchain::install_commands(tool, &os);
|
|
let Some(cmd) = cmds.first() else {
|
|
continue;
|
|
};
|
|
if !app.confirm(&format!(
|
|
"'{tool}' is missing — run the detected installer for {}? -> {cmd}",
|
|
os.name
|
|
))? {
|
|
continue;
|
|
}
|
|
let code = crate::toolchain::run_install_command(runner, cmd)?;
|
|
crate::toolchain::refresh_path();
|
|
installed.push(if code == 0 {
|
|
format!("installed '{tool}' ({cmd})")
|
|
} else {
|
|
format!("failed to install '{tool}' (exit code {code})")
|
|
});
|
|
}
|
|
Ok(installed)
|
|
}
|
|
|
|
/// Repair what can be repaired: create directories, fix a corrupt state file.
|
|
pub fn fix(app: &App) -> Result<Vec<String>> {
|
|
let mut repaired = Vec::new();
|
|
app.paths.ensure().context("creating directories")?;
|
|
repaired.push(format!("created {}", app.paths.install_dir.display()));
|
|
repaired.push(format!("created {}", app.paths.log_dir.display()));
|
|
|
|
// Project cache: always refreshed (it derives from the journal).
|
|
let cache = crate::projects::cache_file(app);
|
|
if cache.exists() {
|
|
std::fs::remove_file(&cache).ok();
|
|
repaired.push(format!("project cache {} removed (rebuilt on next 'am projects')", cache.display()));
|
|
}
|
|
|
|
// Corrupt state file: back it up and start fresh.
|
|
if app.state.path().exists() && app.state.load().is_err() {
|
|
let backup = app
|
|
.state
|
|
.path()
|
|
.with_extension("json.corrupt.bak");
|
|
std::fs::rename(app.state.path(), &backup).with_context(|| {
|
|
format!(
|
|
"cannot back up corrupt state file {} to {}",
|
|
app.state.path().display(),
|
|
backup.display()
|
|
)
|
|
})?;
|
|
app.state.save(&crate::state::StateFile::default())?;
|
|
repaired.push(format!(
|
|
"corrupt state file moved to {} and recreated empty",
|
|
backup.display()
|
|
));
|
|
}
|
|
Ok(repaired)
|
|
}
|