agent-manager v0.2.0 : gestionnaire d'agents IA locaux

- CLI Rust (am) : list/install/uninstall/update/start/stop/restart/run/status/search/info/config/doctor/completion/export/import/self-update + shell interactif (Tab, historique)
- 9 methodes d'installation (npm, pip, uv, cargo, go, bun, curl, binary, git)
- catalogue de 72 agents, dependances, processus, securite (dry-run, checksums)
- sondes de version paralleles + cache (am list ~25 ms a chaud)
- installateurs one-liner : install.sh, install.ps1
This commit is contained in:
2026-08-15 11:26:34 -04:00
commit 8ee3ddf3db
79 changed files with 15724 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
//! completion: generate shell completion scripts (bash, zsh, fish,
//! powershell, elvish) via clap_complete.
use crate::cli::Cli;
use anyhow::Result;
use clap::CommandFactory;
use clap_complete::{generate, Shell};
pub fn run(shell: Shell) -> Result<i32> {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "am", &mut std::io::stdout());
Ok(0)
}
+182
View File
@@ -0,0 +1,182 @@
//! config subcommands: show, path, edit, validate, add.
use super::*;
use crate::cli::ConfigCmd;
use crate::config;
use crate::output::print_json;
use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
const USER_CONFIG_TEMPLATE: &str = "# User configuration for agent-manager.
# This file overrides/completes the embedded default catalog.
# Run 'am config show' to see the effective configuration.
version: \"1.0\"
agents: []
";
pub fn run(app: &App, cmd: &ConfigCmd) -> Result<i32> {
match cmd {
ConfigCmd::Show => {
if app.json() {
print_json(&app.config);
} else {
println!("{}", serde_yaml::to_string(&app.config)?);
}
Ok(0)
}
ConfigCmd::Path => {
if app.json() {
print_json(&serde_json::json!({
"origin": app.config_origin.as_ref().map(|p| p.display().to_string()),
"user_dir": app.paths.config_dir.as_ref().map(|p| p.display().to_string()),
"user_file": user_config_path_if_any(app).map(|p| p.display().to_string()),
"local_file": PathBuf::from(config::LOCAL_CONFIG_NAME).display().to_string(),
}));
} else {
match &app.config_origin {
Some(p) => println!("active config: {}", p.display()),
None => println!("no user config found — using the embedded default catalog"),
}
if let Some(d) = &app.paths.config_dir {
println!("user config directory: {}", d.display());
println!(
"user config file: {}",
d.join(config::CONFIG_FILE_NAME).display()
);
}
println!(
"local config file: {} (current directory)",
PathBuf::from(config::LOCAL_CONFIG_NAME).display()
);
}
Ok(0)
}
ConfigCmd::Validate => {
let problems = config::validate(&app.config);
if app.json() {
print_json(&serde_json::json!({
"valid": problems.is_empty(),
"problems": problems,
}));
return Ok(if problems.is_empty() { 0 } else { 1 });
}
if problems.is_empty() {
app.log
.success(&format!("configuration is valid ({} agents)", app.catalog.len()));
Ok(0)
} else {
app.log.error(&format!(
"configuration problems:
- {}",
problems.join("
- ")
));
Ok(1)
}
}
ConfigCmd::Edit => {
let path = user_config_path(app)?;
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| {
if cfg!(windows) {
"notepad".to_string()
} else {
"vi".to_string()
}
});
let status = Command::new(&editor)
.arg(&path)
.status()
.with_context(|| format!("cannot launch editor '{editor}'"))?;
Ok(status.code().unwrap_or(1))
}
ConfigCmd::Add { file } => add_file(app, file),
}
}
fn user_config_path_if_any(app: &App) -> Option<PathBuf> {
app.paths
.config_dir
.as_ref()
.map(|d| d.join(config::CONFIG_FILE_NAME))
}
/// Ensure the user config file exists (creating a template when needed).
fn user_config_path(app: &App) -> Result<PathBuf> {
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)
.with_context(|| format!("cannot create {}", dir.display()))?;
let path = dir.join(config::CONFIG_FILE_NAME);
if !path.exists() {
std::fs::write(&path, USER_CONFIG_TEMPLATE)
.with_context(|| format!("cannot create {}", path.display()))?;
}
Ok(path)
}
/// config add: copy an agent definition file into the config dir and
/// register it via an include entry in the user config file.
fn add_file(app: &App, file: &Path) -> Result<i32> {
config::parse_file(file)?;
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)?;
let base = file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("agents.yaml")
.to_string();
let dest = dir.join(&base);
std::fs::copy(file, &dest)
.with_context(|| format!("cannot copy {} to {}", file.display(), dest.display()))?;
let cfg_path = user_config_path(app)?;
let text = std::fs::read_to_string(&cfg_path)?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.unwrap_or_else(|_| serde_yaml::Value::Null);
match &mut value {
serde_yaml::Value::Mapping(map) => {
let key = serde_yaml::Value::String("include".to_string());
match map.get_mut(&key) {
Some(serde_yaml::Value::Sequence(seq)) => {
let entry = serde_yaml::Value::String(base.clone());
if !seq.contains(&entry) {
seq.push(entry);
}
}
_ => {
map.insert(
key,
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
base.clone(),
)]),
);
}
}
std::fs::write(&cfg_path, serde_yaml::to_string(&value)?)
.with_context(|| format!("cannot write {}", cfg_path.display()))?;
}
_ => {
std::fs::write(
&cfg_path,
format!("version: \"1.0\"\ninclude:\n - {base}\n"),
)?;
}
}
app.log.success(&format!(
"added {} and registered it in {}",
dest.display(),
cfg_path.display()
));
Ok(0)
}
+43
View File
@@ -0,0 +1,43 @@
//! doctor command: render the environment report, optionally with --fix.
use super::*;
use crate::doctor::{self, Status};
use crate::output::{print_json, status_style, Table};
use crate::runner::SystemRunner;
use anyhow::Result;
pub fn run(app: &App, fix: bool) -> Result<i32> {
if fix {
let repaired = doctor::fix(app)?;
for r in &repaired {
app.log.success(r);
}
app.log.info("run 'am doctor' again to verify");
return Ok(0);
}
// The doctor report probes real tools, even under --dry-run.
let runner = SystemRunner::new(false, false, &app.log);
let report = doctor::run(app, &runner);
if app.json() {
print_json(&report.checks);
return Ok(if report.ok() { 0 } else { 1 });
}
let color = app.color();
let mut table = Table::new(vec!["CHECK", "STATUS", "DETAIL"]);
for c in &report.checks {
let status = match c.status {
Status::Ok => status_style("ok", color),
Status::Warn => status_style("warn", color),
Status::Error => status_style("error", color),
};
table.row(vec![c.name.clone(), status, c.detail.clone()]);
}
print!("{}", table.render());
if report.problems() > 0 {
app.log
.warn(&format!("{} problem(s) found — 'am doctor --fix' repairs what it can", report.problems()));
} else {
app.log.success("environment looks good");
}
Ok(if report.ok() { 0 } else { 1 })
}
+135
View File
@@ -0,0 +1,135 @@
//! export/import: back up the configuration and installation state.
use super::*;
use crate::config::{Config, Settings};
use crate::state::StateFile;
use crate::output::print_json;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Serialize, Deserialize)]
struct ExportFile {
tool: String,
version: String,
generated_at: String,
config: Config,
state: StateFile,
}
pub fn export_cmd(app: &App, output: &Path) -> Result<i32> {
let export = ExportFile {
tool: "agent-manager".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
generated_at: crate::installers::now_rfc3339(),
config: app.config.clone(),
state: app.state.load()?,
};
if app.dry_run() {
app.log
.dry(format!("would write {}", output.display()));
return Ok(0);
}
let text = serde_json::to_string_pretty(&export)?;
std::fs::write(output, text)
.with_context(|| format!("cannot write {}", output.display()))?;
app.log
.success(&format!("exported config + state to {}", output.display()));
Ok(0)
}
pub fn import_cmd(app: &App, file: &Path) -> Result<i32> {
let text = std::fs::read_to_string(file)
.with_context(|| format!("cannot read {}", file.display()))?;
let export: ExportFile = serde_json::from_str(&text)
.with_context(|| format!("{} is not a valid agent-manager export", file.display()))?;
if app.dry_run() {
app.log
.dry(format!("would import from {}", file.display()));
return Ok(0);
}
// State: merge entries (existing ones win).
let mut sf = app.state.load()?;
let mut imported_state = 0;
for (name, entry) in export.state.installed {
if !sf.installed.contains_key(&name) {
sf.installed.insert(name, entry);
imported_state += 1;
}
}
app.state.save(&sf)?;
// Config: write the exported agents to a file and include it.
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)?;
let ts = chrono::Local::now().format("%Y%m%d-%H%M%S");
let fname = format!("imported-{ts}.yaml");
let dest = dir.join(&fname);
let cfg = Config {
version: "1.0".to_string(),
settings: Settings::default(),
aliases: Default::default(),
groups: Default::default(),
include: vec![],
agents: export.config.agents.clone(),
};
std::fs::write(&dest, serde_yaml::to_string(&cfg)?)
.with_context(|| format!("cannot write {}", dest.display()))?;
let cfg_path = dir.join(crate::config::CONFIG_FILE_NAME);
if !cfg_path.exists() {
std::fs::write(
&cfg_path,
format!("version: \"1.0\"\ninclude:\n - {fname}\n"),
)?;
} else {
let text = std::fs::read_to_string(&cfg_path)?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.unwrap_or_else(|_| serde_yaml::Value::Null);
if let serde_yaml::Value::Mapping(map) = &mut value {
let key = serde_yaml::Value::String("include".to_string());
match map.get_mut(&key) {
Some(serde_yaml::Value::Sequence(seq)) => {
let entry = serde_yaml::Value::String(fname.clone());
if !seq.contains(&entry) {
seq.push(entry);
}
}
_ => {
map.insert(
key,
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
fname.clone(),
)]),
);
}
}
std::fs::write(&cfg_path, serde_yaml::to_string(&value)?)?;
}
}
if app.json() {
print_json(&serde_json::json!({
"agents_imported": export.config.agents.len(),
"state_entries_imported": imported_state,
"config_file": dest.display().to_string(),
}));
} else {
app.log.success(&format!(
"imported {} agent definition(s) and {} state entrie(s) from {}",
export.config.agents.len(),
imported_state,
file.display()
));
app.log.info(&format!(
"definitions written to {}",
dest.display()
));
}
Ok(0)
}
+197
View File
@@ -0,0 +1,197 @@
//! info: everything known about one agent.
use super::*;
use crate::output::print_json;
use serde::Serialize;
#[derive(Serialize)]
struct InfoJson {
name: String,
display_name: String,
description: Option<String>,
category: Option<String>,
website: Option<String>,
tags: Vec<String>,
status: String,
version: Option<String>,
managed: bool,
running: bool,
pid: Option<u32>,
installable: bool,
note: Option<String>,
platforms: Vec<String>,
run: Option<String>,
args: Vec<String>,
env: std::collections::BTreeMap<String, String>,
install_methods: Vec<String>,
post_install: Vec<String>,
dependencies: Vec<DepJson>,
path: Option<String>,
}
#[derive(Serialize)]
struct DepJson {
name: String,
min_version: Option<String>,
install_hint: Option<String>,
}
pub fn run(app: &App, name: &str) -> Result<i32> {
let agent = require_agent(app, name)?;
let entry = app.state.get(&agent.name).ok().flatten();
let running = entry
.as_ref()
.and_then(|e| e.pid)
.map(crate::process::is_running)
.unwrap_or(false);
let methods: Vec<String> = agent
.install
.as_ref()
.map(|spec| spec.all_methods().iter().map(|m| describe_method(m)).collect())
.unwrap_or_default();
let deps: Vec<DepJson> = agent
.dependencies
.iter()
.map(|d| DepJson {
name: d.name.clone(),
min_version: d.min_version.clone(),
install_hint: d.install_hint.clone(),
})
.collect();
if app.json() {
print_json(&InfoJson {
name: agent.name.clone(),
display_name: agent.title().to_string(),
description: agent.description.clone(),
category: agent.category.clone(),
website: agent.website.clone(),
tags: agent.tags.clone(),
status: if running {
"running".to_string()
} else if entry.is_some() {
"installed".to_string()
} else if !agent.installable {
"not-installable".to_string()
} else {
"not-installed".to_string()
},
version: entry.as_ref().and_then(|e| e.version.clone()),
managed: entry.is_some(),
running,
pid: if running { entry.as_ref().and_then(|e| e.pid) } else { None },
installable: agent.installable,
note: agent.note.clone(),
platforms: agent.platforms.clone(),
run: agent.run.clone(),
args: agent.args.clone(),
env: agent.env.clone(),
install_methods: methods,
post_install: agent
.install
.as_ref()
.map(|s| s.post_install.clone())
.unwrap_or_default(),
dependencies: deps,
path: entry
.as_ref()
.map(|e| e.install_dir.clone())
.or_else(|| external_path(agent).map(|p| p.display().to_string())),
});
return Ok(0);
}
println!("{}", agent.title());
if agent.name != agent.title() {
println!(" slug: {}", agent.name);
}
if let Some(d) = &agent.description {
println!(" description: {d}");
}
if let Some(c) = &agent.category {
println!(" category: {c}");
}
if let Some(w) = &agent.website {
println!(" website: {w}");
}
if !agent.tags.is_empty() {
println!(" tags: {}", agent.tags.join(", "));
}
if !agent.platforms.is_empty() {
println!(" platforms: {}", agent.platforms.join(", "));
}
// Status block
let status = if running {
format!("running (pid {})", entry.as_ref().and_then(|e| e.pid).unwrap_or(0))
} else if entry.is_some() {
format!("installed (version {})", entry.as_ref().and_then(|e| e.version.clone()).unwrap_or_else(|| "unknown".to_string()))
} else if !agent.installable {
"not installable locally".to_string()
} else if external_path(agent).is_some() {
"found on PATH (external — not managed)".to_string()
} else {
"not installed".to_string()
};
println!(" status: {status}");
if let Some(run) = &agent.run {
let args = if agent.args.is_empty() {
String::new()
} else {
format!(" {}", agent.args.join(" "))
};
println!(" run: {run}{args}");
}
if !agent.env.is_empty() {
println!(
" env: {}",
agent
.env
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(" ")
);
}
if let Some(spec) = &agent.install {
println!(" install methods:");
for (i, m) in spec.all_methods().iter().enumerate() {
let note = m
.note
.as_deref()
.map(|n| format!(" ({n})"))
.unwrap_or_default();
println!(" {}) {}{}", i + 1, describe_method(m), note);
}
if !spec.post_install.is_empty() {
println!(" post-install: {}", spec.post_install.join(" && "));
}
}
if !agent.dependencies.is_empty() {
println!(" dependencies:");
for d in &agent.dependencies {
let min = d
.min_version
.as_deref()
.map(|m| format!(" >= {m}"))
.unwrap_or_default();
let hint = d
.install_hint
.as_deref()
.map(|h| format!(" — {h}"))
.unwrap_or_default();
println!(" - {}{}{}", d.name, min, hint);
}
}
if let Some(n) = &agent.note {
println!(" note: {n}");
}
Ok(0)
}
fn external_path(agent: &AgentDef) -> Option<std::path::PathBuf> {
let first = agent.first_token()?;
which::which(first).ok()
}
+152
View File
@@ -0,0 +1,152 @@
//! install: resolve the agent, check dependencies, confirm, run the right
//! installer, and record the installation in the state database.
use super::*;
use crate::installers;
use crate::runner::SystemRunner;
use anyhow::{anyhow, bail, Result};
use std::collections::BTreeMap;
pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -> Result<i32> {
let agent = require_agent(app, agent_name)?;
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
if !agent.supports_current_platform() {
bail!(
"agent '{}' is not available on {} (supported: {})",
agent.name,
crate::config::current_platform(),
agent.platforms.join(", ")
);
}
if !agent.installable {
bail!(
"agent '{}' is not installable locally{}",
agent.name,
agent
.note
.as_deref()
.map(|n| format!(": {n}"))
.unwrap_or_default()
);
}
let spec = agent
.install
.as_ref()
.ok_or_else(|| anyhow!("agent '{}' has no install definition", agent.name))?;
let methods = spec.all_methods();
let method = select_method(&methods, method_sel)?;
// Already installed?
let existing = app.state.get(&agent.name).ok().flatten();
if existing.is_some() && !force && !app.dry_run() {
let version = existing
.as_ref()
.and_then(|e| e.version.clone())
.unwrap_or_else(|| "version unknown".to_string());
bail!(
"agent '{}' is already installed ({version}); use --force to reinstall, or 'am update {}'",
agent.name,
agent.name
);
}
if let Some(e) = &existing {
if let Some(pid) = e.pid {
if crate::process::is_running(pid) {
bail!(
"agent '{}' is currently running (pid {pid}); stop it first with 'am stop {}'",
agent.name,
agent.name
);
}
}
}
// Dependencies (checked against real tools unless dry-run).
crate::deps::ensure_deps(app, agent, &runner)?;
// Confirmation summary for scripts and destructive actions.
if !app.dry_run()
&& !app.yes()
&& app.config.settings.confirm_before_run()
&& method.kind == crate::config::InstallType::Curl
{
app.log.info(&format!(
"About to download and run the install script for {}",
agent.title()
));
app.log
.info(&format!(" script: {}", method.script.as_deref().unwrap_or("?")));
if !app.confirm("Run this install script?")? {
app.log.info("aborted");
return Ok(0);
}
} else if !app.dry_run() && !app.yes() && existing.is_some() {
app.log.info(&format!(
"Reinstalling {} via {}",
agent.title(),
describe_method(method)
));
if !app.confirm("Proceed with reinstall?")? {
app.log.info("aborted");
return Ok(0);
}
}
app.log.info(&format!(
"installing {} ({})…",
agent.title(),
describe_method(method)
));
let mut outcome = installers::run_install(app, agent, method, &runner)?;
if app.dry_run() {
app.log.info("dry run complete — nothing was changed");
return Ok(0);
}
// Post-install commands (bin dir first on PATH).
if !spec.post_install.is_empty() {
let bin_str = app.paths.bin_dir.display().to_string();
let mut env = BTreeMap::new();
if let Ok(path) = std::env::var("PATH") {
env.insert(
"PATH".to_string(),
format!("{bin_str}{}{path}", std::path::MAIN_SEPARATOR),
);
}
for cmd in &spec.post_install {
let tokens = shell_words::split(cmd)
.map_err(|e| anyhow!("cannot parse post-install command '{cmd}': {e}"))?;
if tokens.is_empty() {
continue;
}
let prog = tokens[0].clone();
let args: Vec<String> = tokens[1..].to_vec();
app.log.info(&format!("post-install: {cmd}"));
installers::sh(&runner, &prog, &args, &env, None)?;
}
}
// Fallback version detection.
if outcome.version.is_none() {
if let Some(bin) = outcome.bins.first() {
outcome.version = installers::detect_version(&runner, bin);
}
}
let entry = installers::make_entry(agent, method, &outcome, app);
let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string());
app.state.set(&entry)?;
app.log.success(&format!(
"installed {} (v{version}) in {}",
agent.title(),
entry.install_dir
));
if existing.is_none() {
app.log.info(&format!(
"start it with: am start {}",
agent.name
));
}
Ok(0)
}
+151
View File
@@ -0,0 +1,151 @@
//! list: installed agents by default (managed, running and external), or
//! the whole catalog with --all.
use super::*;
use crate::output::{print_json, status_style, Table};
use crate::probe::ExternalInfo;
use crate::state::InstalledEntry;
use serde::Serialize;
#[derive(Serialize)]
struct AgentRow {
name: String,
display_name: String,
category: Option<String>,
description: Option<String>,
status: String,
version: Option<String>,
source: String,
method: Option<String>,
path: Option<String>,
running: bool,
pid: Option<u32>,
installable: bool,
note: Option<String>,
}
/// run: installed agents only, unless all is set (whole catalog).
pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
let entries = app.state.all().unwrap_or_default();
let probe = crate::probe::detect_externals(app);
let mut rows: Vec<AgentRow> = Vec::new();
for agent in app.catalog.agents() {
if agent.hidden {
continue;
}
if let Some(cat) = category {
if agent.category.as_deref() != Some(cat) {
continue;
}
}
rows.push(row_for(agent, entries.get(&agent.name), &probe));
}
if !all {
rows.retain(|r| r.status == "running" || r.status == "installed" || r.status == "external");
}
rows.sort_by(|a, b| a.name.cmp(&b.name));
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "SOURCE", "PATH"]);
for r in &rows {
table.row(vec![
r.display_name.clone(),
r.version.clone().unwrap_or_else(|| "—".to_string()),
status_style(&r.status, color),
r.source.clone(),
r.path.clone().unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
if rows.is_empty() {
app.log.info(if all {
"no agents found"
} else {
"no installed agents found — run 'am list --all' for the full catalog"
});
}
Ok(0)
}
fn row_for(agent: &AgentDef, entry: Option<&InstalledEntry>, probe: &ExternalInfo) -> AgentRow {
let (status, source, path, version, running, pid, method) = if let Some(e) = entry {
let running = e.pid.map(crate::process::is_running).unwrap_or(false);
let status = if running { "running" } else { "installed" };
let path = if e
.bins
.first()
.map(|b| std::path::Path::new(b).exists())
.unwrap_or(false)
{
Some(e.bins[0].clone())
} else {
Some(e.install_dir.clone())
};
(
status.to_string(),
"managed".to_string(),
path,
e.version.clone(),
running,
e.pid,
Some(e.method.clone()),
)
} else if !agent.installable {
(
"not-installable".to_string(),
"-".to_string(),
None,
None,
false,
None,
None,
)
} else {
match probe.path_of(&agent.name) {
Some(p) => {
let version = probe
.version_of(&agent.name)
.cloned()
.flatten()
.or_else(|| agent.version.clone());
(
"external".to_string(),
"external".to_string(),
Some(p.display().to_string()),
version,
false,
None,
None,
)
}
None => (
"not-installed".to_string(),
"-".to_string(),
None,
None,
false,
None,
None,
),
}
};
AgentRow {
name: agent.name.clone(),
display_name: agent.title().to_string(),
category: agent.category.clone(),
description: agent.description.clone(),
status,
version,
source,
method,
path,
running,
pid,
installable: agent.installable,
note: agent.note.clone(),
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Command implementations and dispatch.
pub mod completion_cmd;
pub mod config_cmd;
pub mod doctor_cmd;
pub mod export_import;
pub mod info_cmd;
pub mod install_cmd;
pub mod list_cmd;
pub mod run_cmd;
pub mod search_cmd;
pub mod self_update;
pub mod status_cmd;
pub mod uninstall_cmd;
pub mod update_cmd;
use crate::app::App;
use crate::cli::Command;
use crate::config::{AgentDef, MethodSpec};
use crate::state::InstalledEntry;
use anyhow::{anyhow, bail, Result};
use std::collections::BTreeMap;
use std::path::Path;
pub fn execute(app: &App) -> Result<i32> {
match &app.cli.command {
None => crate::repl::run(app),
Some(cmd) => execute_command(app, cmd),
}
}
/// Execute one parsed command (also used by the interactive shell).
pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
match cmd {
Command::List { all, category } => list_cmd::run(app, *all, category.as_deref()),
Command::Start(a) => run_cmd::start(app, a),
Command::Stop {
agent,
force,
timeout,
} => run_cmd::stop(app, agent, *force, *timeout),
Command::Restart {
start,
force,
timeout,
} => run_cmd::restart(app, start, *force, *timeout),
Command::Status { agent } => status_cmd::run(app, agent.as_deref()),
Command::Install {
agent,
method,
force,
} => install_cmd::run(app, agent, method.as_deref(), *force),
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()),
Command::Info { agent } => info_cmd::run(app, agent),
Command::Config(sub) => config_cmd::run(app, sub),
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
Command::Run { agent, args } => run_cmd::run(app, agent, args),
Command::Completion { shell } => completion_cmd::run(*shell),
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
Command::Export { output } => export_import::export_cmd(app, output),
Command::Import { file } => export_import::import_cmd(app, file),
}
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
pub struct ResolvedExec {
pub program: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
}
/// Resolve the program, arguments and environment for running an agent.
/// Preference order: recorded binary > run command first token (resolved on
/// PATH when possible).
pub fn resolve_exec(
app: &App,
agent: &AgentDef,
extra_args: &[String],
extra_env: &BTreeMap<String, String>,
) -> Result<ResolvedExec> {
let entry: Option<InstalledEntry> = app.state.get(&agent.name).ok().flatten();
let mut tokens = agent.run_tokens();
if tokens.is_empty() {
bail!("agent '{}' has no run command", agent.name);
}
let program = match &entry {
Some(e) if !e.bins.is_empty() => {
let p = std::path::PathBuf::from(&e.bins[0]);
if p.exists() || app.dry_run() {
p.display().to_string()
} else {
tokens.remove(0)
}
}
_ => {
let first = tokens.remove(0);
if first.contains(std::path::MAIN_SEPARATOR) || Path::new(&first).exists() {
first
} else if let Ok(p) = which::which(&first) {
p.display().to_string()
} else {
first
}
}
};
let mut args: Vec<String> = tokens;
args.extend(agent.args.iter().cloned());
args.extend(extra_args.iter().cloned());
let mut env = agent.env.clone();
env.extend(extra_env.clone());
Ok(ResolvedExec { program, args, env })
}
/// Parse --env KEY=VALUE entries.
pub fn parse_env_list(items: &[String]) -> Result<BTreeMap<String, String>> {
let mut map = BTreeMap::new();
for item in items {
let Some((k, v)) = item.split_once('=') else {
bail!("invalid --env '{item}': expected KEY=VALUE");
};
if k.trim().is_empty() {
bail!("invalid --env '{item}': empty key");
}
map.insert(k.to_string(), v.to_string());
}
Ok(map)
}
/// Parse --args entries; quoted strings are split on whitespace.
pub fn parse_extra_args(items: &[String]) -> Vec<String> {
let mut out = Vec::new();
for item in items {
if item.contains(' ') || item.contains('\t') {
if let Ok(parts) = shell_words::split(item) {
out.extend(parts);
continue;
}
}
out.push(item.clone());
}
out
}
/// Resolve an agent by name or alias, with a helpful error message.
pub fn require_agent<'a>(app: &'a App, name_or_alias: &str) -> Result<&'a AgentDef> {
app.catalog.resolve(name_or_alias).ok_or_else(|| {
anyhow!(
"unknown agent '{name_or_alias}' — run 'am search <keyword>' or 'am list' to see available agents"
)
})
}
/// One-line description of an install method.
pub fn describe_method(m: &MethodSpec) -> String {
let mut parts = vec![format!("type={}", m.kind)];
if let Some(p) = &m.package {
parts.push(format!("package={p}"));
}
if let Some(u) = &m.url {
parts.push(format!("url={u}"));
}
if let Some(s) = &m.script {
parts.push(format!("script={s}"));
}
if let Some(r) = &m.repo {
parts.push(format!("repo={r}"));
}
if let Some(b) = &m.binary_path {
parts.push(format!("binary_path={b}"));
}
parts.join(" ")
}
/// Select an install method by index (1-based) or type name.
pub fn select_method<'a>(
methods: &[&'a MethodSpec],
sel: Option<&str>,
) -> Result<&'a MethodSpec> {
if methods.is_empty() {
bail!("no install methods defined");
}
match sel {
None => Ok(methods[0]),
Some(s) => {
if let Ok(idx) = s.parse::<usize>() {
if (1..=methods.len()).contains(&idx) {
return Ok(methods[idx - 1]);
}
bail!("method index {idx} out of range (1..={})", methods.len());
}
let lower = s.trim_start_matches("type:").to_lowercase();
methods
.iter()
.find(|m| m.kind.as_str() == lower)
.copied()
.ok_or_else(|| {
anyhow!(
"unknown method '{s}'; available: {}",
methods
.iter()
.map(|m| m.kind.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})
}
}
}
+187
View File
@@ -0,0 +1,187 @@
//! start, stop, restart and run: process management commands.
use super::*;
use crate::cli::StartArgs;
use crate::process;
use anyhow::{bail, Context, Result};
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Command;
pub fn start(app: &App, opts: &StartArgs) -> Result<i32> {
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&opts.agent) {
let members = app.catalog.group_members(group);
if members.is_empty() {
bail!("unknown group '{group}'");
}
for m in members {
start_one(app, m, &extra_args, &extra_env, opts.notify, true)?;
}
return Ok(0);
}
let agent = require_agent(app, &opts.agent)?;
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background)
}
fn start_one(
app: &App,
agent: &AgentDef,
extra_args: &[String],
extra_env: &BTreeMap<String, String>,
notify: bool,
background: bool,
) -> Result<i32> {
let exec = resolve_exec(app, agent, extra_args, extra_env)?;
let bin = PathBuf::from(&exec.program);
let is_managed = app.state.get(&agent.name).ok().flatten().is_some();
if background {
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env)?;
if app.dry_run() {
return Ok(0);
}
if is_managed {
app.state
.update_pid(&agent.name, Some(pid), Some(crate::installers::now_rfc3339()))?;
} else {
app.log.warn(&format!(
"agent '{}' is external (not installed by agent-manager); PID {pid} is not recorded",
agent.name
));
}
let log = process::agent_log_path(app, &agent.name);
app.log.success(&format!(
"{} started (pid {pid}); log: {}",
agent.title(),
log.display()
));
if notify {
process::desktop_notify("agent-manager", &format!("{} started", agent.title()));
}
Ok(0)
} else {
if app.dry_run() {
app.log.dry(format!(
"would run {} {} (foreground)",
exec.program,
exec.args.join(" ")
));
return Ok(0);
}
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
let status = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
.status()
.with_context(|| format!("failed to run {}", exec.program))?;
Ok(status.code().unwrap_or(1))
}
}
pub fn stop(app: &App, target: &str, force: bool, timeout: Option<u64>) -> Result<i32> {
let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout());
if let Some(group) = crate::catalog::Catalog::parse_group_selector(target) {
let members = app.catalog.group_members(group);
if members.is_empty() {
bail!("unknown group '{group}'");
}
for m in members.iter().rev() {
stop_one(app, m, force, timeout)?;
}
return Ok(0);
}
let agent = require_agent(app, target)?;
stop_one(app, agent, force, timeout)
}
fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i32> {
let Some(entry) = app.state.get(&agent.name).ok().flatten() else {
app.log.info(&format!(
"agent '{}' is not managed by agent-manager; nothing to stop",
agent.name
));
return Ok(0);
};
let Some(pid) = entry.pid else {
app.log.info(&format!("agent '{}' is not running", agent.name));
return Ok(0);
};
if app.dry_run() {
app.log.dry(format!(
"would stop pid {pid} ({}{})",
agent.name,
if force { "force" } else { "graceful" }
));
return Ok(0);
}
if !process::is_running(pid) {
app.log.warn(&format!(
"stale PID {pid} for '{}' (process no longer running); cleaning up",
agent.name
));
app.state.update_pid(&agent.name, None, None)?;
return Ok(0);
}
app.log.info(&format!("stopping '{}' (pid {pid})", agent.name));
let stopped = process::stop_pid(pid, force, timeout);
app.state.update_pid(&agent.name, None, None)?;
if stopped {
app.log.success(&format!("{} stopped", agent.title()));
Ok(0)
} else {
app.log.error(&format!("failed to stop pid {pid}"));
Ok(1)
}
}
pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -> Result<i32> {
let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout());
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&opts.agent) {
let members = app.catalog.group_members(group);
if members.is_empty() {
bail!("unknown group '{group}'");
}
for m in members.iter().rev() {
stop_one(app, m, force, timeout)?;
}
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
for m in members {
start_one(app, m, &extra_args, &extra_env, opts.notify, true)?;
}
return Ok(0);
}
let agent = require_agent(app, &opts.agent)?;
stop_one(app, agent, force, timeout)?;
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background)
}
/// run: execute the agent command directly, no process management.
pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
let agent = require_agent(app, target)?;
let extra_args: Vec<String> = extra.iter().map(|o| o.to_string_lossy().to_string()).collect();
let exec = resolve_exec(app, agent, &extra_args, &BTreeMap::new())?;
if app.dry_run() {
app.log.dry(format!(
"would run {} {}",
exec.program,
exec.args.join(" ")
));
return Ok(0);
}
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
let status = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
.status()
.with_context(|| format!("failed to run {}", exec.program))?;
Ok(status.code().unwrap_or(1))
}
+62
View File
@@ -0,0 +1,62 @@
//! search: keyword search over the catalog.
use super::*;
use crate::output::{print_json, Table};
use serde::Serialize;
#[derive(Serialize)]
struct SearchRow {
name: String,
display_name: String,
category: Option<String>,
installable: bool,
description: Option<String>,
website: Option<String>,
tags: Vec<String>,
}
pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result<i32> {
let hits = app.catalog.search(keyword, category);
if app.json() {
let rows: Vec<SearchRow> = hits
.iter()
.map(|a| SearchRow {
name: a.name.clone(),
display_name: a.title().to_string(),
category: a.category.clone(),
installable: a.installable,
description: a.description.clone(),
website: a.website.clone(),
tags: a.tags.clone(),
})
.collect();
print_json(&rows);
return Ok(0);
}
let mut table = Table::new(vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"]);
for a in hits {
let desc = truncate(a.description.as_deref().unwrap_or(""), 56);
table.row(vec![
a.title().to_string(),
a.category.clone().unwrap_or_else(|| "—".to_string()),
if a.installable { "yes" } else { "no" }.to_string(),
desc,
]);
}
print!("{}", table.render());
if app.catalog.search(keyword, category).is_empty() {
app.log
.info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", ")));
}
Ok(0)
}
pub fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut cut: String = s.chars().take(max - 1).collect();
cut.push('…');
cut
}
}
+78
View File
@@ -0,0 +1,78 @@
//! self-update: download a newer agent-manager binary from GitHub Releases.
use super::*;
use crate::download;
use anyhow::{anyhow, bail, Context, Result};
use std::cmp::Ordering;
use std::path::Path;
pub fn run(app: &App, check: bool, to: Option<&Path>) -> Result<i32> {
let Some(repo) = app.config.settings.self_update_repo.clone() else {
bail!(
"self-update is not configured: set 'settings.self_update_repo: \"owner/repo\"' in your config"
);
};
let current = env!("CARGO_PKG_VERSION");
let release = download::latest_release(&repo)?;
let tag = release.tag_name.trim_start_matches('v').to_string();
if check {
match crate::version::compare(&tag, current) {
Some(Ordering::Greater) => {
println!("update available: {current} -> {tag}");
Ok(0)
}
_ => {
println!("agent-manager {current} is up to date");
Ok(0)
}
}
} else {
if crate::version::compare(&tag, current) != Some(Ordering::Greater) {
app.log.success(&format!(
"agent-manager {current} is already the latest version"
));
return Ok(0);
}
if app.dry_run() {
app.log
.dry(format!("would download agent-manager {tag}"));
return Ok(0);
}
let picked = download::pick_asset(&release, &[], &[], None)
.ok_or_else(|| anyhow!("no release asset of '{repo}' matches this platform"))?;
app.log.info(&format!(
"downloading agent-manager {tag} ({})",
picked.name
));
let current_exe = std::env::current_exe()?;
let dest = to.map(|p| p.to_path_buf()).unwrap_or_else(|| current_exe.clone());
let tmpdir = tempfile::tempdir()?;
let tmp = tmpdir.path().join("am.new");
download::download(&picked.url, &tmp, None, true)?;
if dest == current_exe && cfg!(windows) {
// The running executable is locked on Windows.
let new_path = current_exe.with_extension("exe.new");
std::fs::copy(&tmp, &new_path)
.with_context(|| format!("cannot write {}", new_path.display()))?;
app.log.success(&format!(
"downloaded {tag} to {} — replace the running binary manually",
new_path.display()
));
} else {
std::fs::copy(&tmp, &dest)
.with_context(|| format!("cannot write {}", dest.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
&dest,
std::fs::Permissions::from_mode(0o755),
);
}
app.log
.success(&format!("updated agent-manager to {tag} ({})", dest.display()));
}
Ok(0)
}
}
+179
View File
@@ -0,0 +1,179 @@
//! status: detailed state of one agent, or of every installed agent.
use super::*;
use crate::output::{print_json, status_style, Table};
use crate::probe::ExternalInfo;
use crate::state::InstalledEntry;
use serde::Serialize;
#[derive(Serialize)]
struct StatusDetail {
name: String,
display_name: String,
status: String,
version: Option<String>,
method: Option<String>,
path: Option<String>,
run: Option<String>,
pid: Option<u32>,
started_at: Option<String>,
installed_at: Option<String>,
log: Option<String>,
managed: bool,
}
pub fn run(app: &App, agent_name: Option<&str>) -> Result<i32> {
let probe = crate::probe::detect_externals(app);
match agent_name {
Some(name) => status_one(app, name, &probe),
None => status_all(app, &probe),
}
}
fn status_one(app: &App, name: &str, probe: &ExternalInfo) -> Result<i32> {
let agent = require_agent(app, name)?;
let entry: Option<InstalledEntry> = app.state.get(&agent.name).ok().flatten();
let detail = detail_for(app, agent, entry.as_ref(), probe);
if app.json() {
print_json(&detail);
return Ok(0);
}
let color = app.color();
println!("{} ({})", agent.title(), agent.name);
let status_line = status_style(&detail.status, color);
println!(" status: {status_line}");
println!(
" version: {}",
detail.version.as_deref().unwrap_or("unknown")
);
println!(" method: {}", detail.method.as_deref().unwrap_or("—"));
println!(" path: {}", detail.path.as_deref().unwrap_or("—"));
println!(" run: {}", detail.run.as_deref().unwrap_or("—"));
if let Some(pid) = detail.pid {
println!(" pid: {pid}");
}
if let Some(t) = &detail.started_at {
println!(" started: {t}");
}
if let Some(t) = &detail.installed_at {
println!(" installed: {t}");
}
if let Some(l) = &detail.log {
println!(" log: {l}");
}
Ok(0)
}
fn status_all(app: &App, probe: &ExternalInfo) -> Result<i32> {
let entries = app.state.all().unwrap_or_default();
let mut rows: Vec<AgentRow> = Vec::new();
for agent in app.catalog.agents() {
if agent.hidden {
continue;
}
let entry = entries.get(&agent.name);
let d = detail_for(app, agent, entry, probe);
if d.status == "running" || d.status == "installed" || d.status == "external" {
rows.push(AgentRow {
name: agent.name.clone(),
display_name: agent.title().to_string(),
status: d.status,
version: d.version,
pid: d.pid,
path: d.path,
});
}
}
rows.sort_by(|a, b| a.name.cmp(&b.name));
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "PID", "PATH"]);
for r in &rows {
table.row(vec![
r.display_name.clone(),
r.version.clone().unwrap_or_else(|| "—".to_string()),
status_style(&r.status, color),
r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()),
r.path.clone().unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
if rows.is_empty() {
app.log.info("no installed or running agents");
}
Ok(0)
}
#[derive(Serialize)]
struct AgentRow {
name: String,
display_name: String,
status: String,
version: Option<String>,
pid: Option<u32>,
path: Option<String>,
}
fn detail_for(
app: &App,
agent: &AgentDef,
entry: Option<&InstalledEntry>,
probe: &ExternalInfo,
) -> StatusDetail {
match entry {
Some(e) => {
let running = e.pid.map(crate::process::is_running).unwrap_or(false);
let status = if running { "running" } else { "installed" };
let path = e
.bins
.first()
.filter(|b| std::path::Path::new(b).exists())
.cloned()
.or_else(|| Some(e.install_dir.clone()));
StatusDetail {
name: agent.name.clone(),
display_name: agent.title().to_string(),
status: status.to_string(),
version: e.version.clone(),
method: Some(e.method.clone()),
path,
run: Some(e.run.clone()),
pid: if running { e.pid } else { None },
started_at: if running { e.started_at.clone() } else { None },
installed_at: Some(e.installed_at.clone()),
log: Some(crate::process::agent_log_path(app, &agent.name).display().to_string()),
managed: true,
}
}
None => {
let external = probe.path_of(&agent.name).cloned();
let version = external
.as_ref()
.and_then(|_p| probe.version_of(&agent.name).cloned().flatten())
.or_else(|| agent.version.clone());
StatusDetail {
name: agent.name.clone(),
display_name: agent.title().to_string(),
status: if external.is_some() {
"external".to_string()
} else if agent.installable {
"not-installed".to_string()
} else {
"not-installable".to_string()
},
version,
method: None,
path: external.map(|p| p.display().to_string()),
run: agent.run.clone(),
pid: None,
started_at: None,
installed_at: None,
log: None,
managed: false,
}
}
}
}
+113
View File
@@ -0,0 +1,113 @@
//! uninstall: stop the agent if needed, remove its files, bins and state
//! entry; --purge also removes logs and the user-config definition.
use super::*;
use anyhow::{Context, Result};
use std::path::PathBuf;
pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
let agent = require_agent(app, agent_name)?;
let entry = app.state.get(&agent.name).ok().flatten();
let Some(entry) = entry else {
app.log.info(&format!(
"agent '{}' is not managed by agent-manager — nothing to uninstall",
agent.name
));
return Ok(0);
};
// Stop it first when running.
if let Some(pid) = entry.pid {
if crate::process::is_running(pid) {
app.log.info(&format!("stopping running agent (pid {pid})"));
if app.dry_run() {
app.log.dry(format!("would stop pid {pid}"));
} else {
crate::process::stop_pid(pid, false, app.config.settings.stop_timeout());
app.state.update_pid(&agent.name, None, None)?;
}
}
}
let dir = PathBuf::from(&entry.install_dir);
if app.dry_run() {
app.log.dry(format!("would remove {}", dir.display()));
} else {
if dir.exists() {
std::fs::remove_dir_all(&dir)
.with_context(|| format!("cannot remove {}", dir.display()))?;
}
for b in &entry.bins {
let p = PathBuf::from(b);
if p.is_file() {
std::fs::remove_file(&p)
.with_context(|| format!("cannot remove {}", p.display()))?;
}
}
app.state.remove(&agent.name)?;
}
if purge {
let log = crate::process::agent_log_path(app, &agent.name);
if app.dry_run() {
app.log.dry(format!("would remove log {}", log.display()));
} else if log.exists() {
std::fs::remove_file(&log).ok();
}
remove_from_user_config(app, &agent.name)?;
}
app.log.success(&format!(
"uninstalled {} (files removed{})",
agent.title(),
if purge { ", config purged" } else { "" }
));
Ok(0)
}
/// Remove the agent entry from the user config file (--purge).
fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
let Some(path) = &app.config_origin else {
app.log.verbose("no user config file — nothing to purge");
return Ok(());
};
let is_json = path.extension().map(|e| e == "json").unwrap_or(false);
if is_json {
app.log
.warn("config purge is not supported for JSON config files");
return Ok(());
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("cannot parse {}", path.display()))?;
let mut removed = false;
if let serde_yaml::Value::Mapping(map) = &mut value {
if let Some(serde_yaml::Value::Sequence(seq)) = map.get_mut("agents") {
let before = seq.len();
seq.retain(|v| {
v.get("name")
.and_then(|n| n.as_str())
.map(|n| n != name)
.unwrap_or(true)
});
removed = seq.len() != before;
}
}
if !removed {
app.log.verbose(&format!("'{name}' not defined in {}", path.display()));
return Ok(());
}
let backup = path.with_extension("yaml.bak");
std::fs::copy(path, &backup)
.with_context(|| format!("cannot create backup {}", backup.display()))?;
std::fs::write(path, serde_yaml::to_string(&value)?)
.with_context(|| format!("cannot write {}", path.display()))?;
app.log.info(&format!(
"removed '{name}' from {} (backup: {})",
path.display(),
backup.display()
));
Ok(())
}
+104
View File
@@ -0,0 +1,104 @@
//! update: compare the installed version with the latest available one and
//! reinstall when an update exists.
use super::*;
use crate::installers;
use crate::runner::{Runner, SystemRunner};
use anyhow::{bail, Result};
use std::cmp::Ordering;
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);
if let Some(name) = agent_name {
return update_one(app, &runner, name);
}
if all {
let entries = app.state.all()?;
let names: Vec<String> = entries.keys().cloned().collect();
let mut failed = 0;
for name in names {
if let Err(e) = update_one(app, &runner, &name) {
failed += 1;
app.log.error(&format!("{name}: {e:#}"));
}
}
return Ok(if failed > 0 { 1 } else { 0 });
}
bail!("update requires an agent name, or --all");
}
fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
let agent = require_agent(app, name)?;
let Some(entry) = app.state.get(&agent.name).ok().flatten() else {
bail!(
"agent '{}' is not installed by agent-manager — use 'am install {}'",
agent.name,
agent.name
);
};
let spec = agent
.install
.as_ref()
.ok_or_else(|| anyhow::anyhow!("agent '{}' has no install definition", agent.name))?;
let methods = spec.all_methods();
// Prefer the method that was originally used.
let method = methods
.iter()
.find(|m| m.kind.as_str() == entry.method)
.copied()
.unwrap_or(methods[0]);
let latest = if app.dry_run() {
None
} else {
installers::latest_version(app, agent, method, runner)?
};
let current = entry.version.clone().or_else(|| {
entry
.bins
.first()
.and_then(|b| installers::detect_version(runner, &PathBuf::from(b)))
});
match (&latest, &current) {
(Some(l), Some(c)) => match crate::version::compare(l, c) {
Some(Ordering::Greater) => {
app.log.info(&format!("update available: {c} -> {l}"));
}
Some(Ordering::Equal) | Some(Ordering::Less) => {
app.log
.success(&format!("{} is up to date (v{c})", agent.title()));
return Ok(0);
}
None => {
app.log.warn(&format!("cannot compare versions ({c} vs {l}); reinstalling"));
}
},
(Some(l), None) => app.log.info(&format!(
"latest available: {l} (installed version unknown); reinstalling"
)),
(None, Some(c)) => app.log.warn(&format!(
"cannot determine the latest version (installed: {c}); reinstalling"
)),
(None, None) => app.log.warn("cannot determine versions; reinstalling"),
}
if app.dry_run() {
app.log
.dry(format!("would reinstall {} via {}", agent.name, method.kind));
return Ok(0);
}
crate::deps::ensure_deps(app, agent, runner)?;
let outcome = installers::run_install(app, agent, method, runner)?;
let mut new_entry = installers::make_entry(agent, method, &outcome, app);
new_entry.installed_at = entry.installed_at.clone();
new_entry.updated_at = Some(installers::now_rfc3339());
let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string());
app.state.set(&new_entry)?;
app.log.success(&format!(
"updated {} to v{version}",
agent.title()
));
Ok(0)
}