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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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(", ")
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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, ¤t) {
|
||||
(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)
|
||||
}
|
||||
Reference in New Issue
Block a user