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
+162
View File
@@ -0,0 +1,162 @@
//! Application context: configuration, catalog, state, paths, logger and
//! shared flags.
use crate::catalog::Catalog;
use crate::cli::Cli;
use crate::config::{self, Config, Settings};
use crate::output::Logger;
use crate::state::StateStore;
use anyhow::{Context, Result};
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
#[derive(Debug)]
pub struct App {
pub cli: Cli,
pub config: Config,
pub config_origin: Option<PathBuf>,
pub paths: Paths,
pub catalog: Catalog,
pub state: StateStore,
pub log: Logger,
}
#[derive(Debug, Clone)]
pub struct Paths {
pub install_dir: PathBuf,
pub bin_dir: PathBuf,
pub log_dir: PathBuf,
pub state_file: PathBuf,
pub probe_cache_file: PathBuf,
pub config_dir: Option<PathBuf>,
}
impl Paths {
pub fn from_settings(settings: &Settings) -> Self {
let install_dir = settings
.install_dir
.as_deref()
.map(config::expand_path)
.unwrap_or_else(|| data_dir().join("agents"));
let log_dir = settings
.log_dir
.as_deref()
.map(config::expand_path)
.unwrap_or_else(|| state_dir().join("logs"));
let bin_dir = install_dir.join("bin");
let state_file = state_dir().join("state.json");
let probe_cache_file = state_dir().join("probe-cache.json");
let config_dir = config::user_config_dir();
Self {
install_dir,
bin_dir,
log_dir,
state_file,
probe_cache_file,
config_dir,
}
}
/// Create every directory the manager needs.
pub fn ensure(&self) -> Result<()> {
std::fs::create_dir_all(&self.install_dir)
.with_context(|| format!("cannot create {}", self.install_dir.display()))?;
std::fs::create_dir_all(&self.bin_dir)?;
std::fs::create_dir_all(&self.log_dir)?;
Ok(())
}
}
impl App {
pub fn from_cli(cli: Cli) -> Result<Self> {
let loaded = config::load(&cli)?;
let paths = Paths::from_settings(&loaded.config.settings);
let log = Logger::new(paths.log_dir.clone(), cli.verbose, cli.quiet, cli.no_color);
let state = StateStore::new(paths.state_file.clone());
let catalog = Catalog::new(
loaded.config.agents.clone(),
loaded.config.aliases.clone(),
loaded.config.groups.clone(),
);
Ok(Self {
cli,
config: loaded.config,
config_origin: loaded.origin,
paths,
catalog,
state,
log,
})
}
pub fn dry_run(&self) -> bool {
self.cli.dry_run
}
pub fn json(&self) -> bool {
self.cli.json
}
pub fn yes(&self) -> bool {
self.cli.yes
}
pub fn color(&self) -> bool {
!self.cli.no_color && std::io::stdout().is_terminal()
}
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
pub fn confirm(&self, question: &str) -> Result<bool> {
if self.cli.yes {
return Ok(true);
}
eprint!("{question} [y/N] ");
std::io::stderr().flush().ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line).ok();
let answer = line.trim().to_lowercase();
Ok(matches!(answer.as_str(), "y" | "yes" | "o" | "oui" | "true"))
}
}
/// Root directory for agent data (overridable with AGENT_MANAGER_DATA,
/// mostly used by tests).
pub fn data_dir() -> PathBuf {
if let Some(d) = std::env::var_os("AGENT_MANAGER_DATA") {
return PathBuf::from(d);
}
if cfg!(windows) {
let base = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.or_else(|| config::home_dir().map(|h| h.join(".local").join("share")));
return base
.map(|b| b.join("agent-manager"))
.unwrap_or_else(|| PathBuf::from(".agent-manager"));
}
if cfg!(target_os = "macos") {
return config::home_dir()
.map(|h| h.join("Library").join("Application Support").join("agent-manager"))
.unwrap_or_else(|| PathBuf::from(".agent-manager"));
}
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| config::home_dir().map(|h| h.join(".local").join("share")));
base.map(|b| b.join("agent-manager"))
.unwrap_or_else(|| PathBuf::from(".agent-manager"))
}
/// Root directory for runtime state (logs, state file).
pub fn state_dir() -> PathBuf {
if let Some(d) = std::env::var_os("AGENT_MANAGER_STATE") {
return PathBuf::from(d);
}
if cfg!(target_os = "linux") {
let base = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| config::home_dir().map(|h| h.join(".local").join("state")));
if let Some(b) = base {
return b.join("agent-manager");
}
}
data_dir()
}
+159
View File
@@ -0,0 +1,159 @@
//! The merged agent catalog: lookup, aliases, groups and search.
use crate::config::AgentDef;
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, Clone, Default)]
pub struct Catalog {
agents: Vec<AgentDef>,
by_name: HashMap<String, usize>,
aliases: BTreeMap<String, String>,
groups: BTreeMap<String, Vec<String>>,
}
impl Catalog {
pub fn new(
agents: Vec<AgentDef>,
aliases: BTreeMap<String, String>,
groups: BTreeMap<String, Vec<String>>,
) -> Self {
let mut by_name = HashMap::new();
for (i, a) in agents.iter().enumerate() {
by_name.insert(a.name.clone(), i);
}
Self {
agents,
by_name,
aliases,
groups,
}
}
pub fn agents(&self) -> &[AgentDef] {
&self.agents
}
pub fn len(&self) -> usize {
self.agents.len()
}
pub fn is_empty(&self) -> bool {
self.agents.is_empty()
}
pub fn get(&self, name: &str) -> Option<&AgentDef> {
self.by_name.get(name).map(|&i| &self.agents[i])
}
/// Resolve an agent name or an alias to the agent definition.
pub fn resolve(&self, name_or_alias: &str) -> Option<&AgentDef> {
if let Some(target) = self.aliases.get(name_or_alias) {
return self.get(target);
}
self.get(name_or_alias)
}
pub fn alias_target(&self, alias: &str) -> Option<&str> {
self.aliases.get(alias).map(|s| s.as_str())
}
/// Members of a group, in definition order (missing names are skipped).
pub fn group_members(&self, group: &str) -> Vec<&AgentDef> {
let mut out = Vec::new();
if let Some(names) = self.groups.get(group) {
for n in names {
if let Some(a) = self.get(n) {
out.push(a);
}
}
}
out
}
pub fn has_group(&self, group: &str) -> bool {
self.groups.contains_key(group)
}
/// Is this token a group selector ("group:<name>")?
pub fn parse_group_selector(s: &str) -> Option<&str> {
s.strip_prefix("group:")
}
/// Case-insensitive search over name, display name, description,
/// category, tags and note.
pub fn search(&self, keyword: &str, category: Option<&str>) -> Vec<&AgentDef> {
let kw = keyword.to_lowercase();
self.agents
.iter()
.filter(|a| {
if a.hidden {
return false;
}
if let Some(cat) = category {
if a.category.as_deref() != Some(cat) {
return false;
}
}
let hay = format!(
"{} {} {} {} {} {}",
a.name,
a.title(),
a.description.as_deref().unwrap_or(""),
a.category.as_deref().unwrap_or(""),
a.tags.join(" "),
a.note.as_deref().unwrap_or("")
)
.to_lowercase();
hay.contains(&kw)
})
.collect()
}
/// Sorted list of all categories present in the catalog.
pub fn categories(&self) -> Vec<String> {
let mut cats: Vec<String> = self
.agents
.iter()
.filter_map(|a| a.category.clone())
.collect();
cats.sort();
cats.dedup();
cats
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn catalog() -> Catalog {
let cfg: Config = serde_yaml::from_str(crate::config::DEFAULT_CONFIG).unwrap();
Catalog::new(cfg.agents, cfg.aliases, cfg.groups)
}
#[test]
fn resolves_aliases() {
let cat = catalog();
let via_alias = cat.resolve("cc").map(|a| a.name.clone());
assert_eq!(via_alias.as_deref(), Some("claude-code"));
}
#[test]
fn resolves_groups() {
let cat = catalog();
assert!(cat.has_group("dev"));
let members = cat.group_members("dev");
assert!(!members.is_empty());
assert!(members.iter().any(|a| a.name == "claude-code"));
}
#[test]
fn searches() {
let cat = catalog();
let hits = cat.search("anthropic", None);
assert!(hits.iter().any(|a| a.name == "claude-code"));
let cats = cat.categories();
assert!(cats.contains(&"coding-agent".to_string()));
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Command line interface definition (clap v4, derive API).
use clap::{ArgAction, Args, Parser, Subcommand};
use clap_complete::Shell;
use std::path::PathBuf;
/// Manage local AI coding agents: install, start, stop, update.
#[derive(Parser, Debug, Clone)]
#[command(
name = "am",
bin_name = "am",
version,
about = "agent-manager (am) — manage local AI coding agents",
long_about = "agent-manager (am) — a CLI to manage local AI coding agents.
Install, uninstall, start, stop, restart and update AI agents from a YAML
catalog. Handles dependencies (Node, Python, Go, Rust, Bun), binaries from
GitHub Releases, install scripts, and git repositories. All state lives under
your user directories; nothing pollutes the system."
)]
pub struct Cli {
/// Use an alternate configuration file
#[arg(short = 'c', long, global = true, value_name = "FILE")]
pub config: Option<PathBuf>,
/// Verbose output: show every command executed and its details
#[arg(short = 'v', long, global = true, action = ArgAction::SetTrue)]
pub verbose: bool,
/// Quiet mode: only errors are printed
#[arg(short = 'q', long, global = true, conflicts_with = "verbose", action = ArgAction::SetTrue)]
pub quiet: bool,
/// Answer yes to every confirmation prompt
#[arg(short = 'y', long, global = true, action = ArgAction::SetTrue)]
pub yes: bool,
/// Simulate the action without changing anything
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub dry_run: bool,
/// Emit machine-readable JSON on stdout (for scripts)
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub json: bool,
/// Disable ANSI colors
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub no_color: bool,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand, Debug, Clone)]
pub enum Command {
/// List installed agents (default); use --all for the whole catalog
List {
/// Show every agent known to the catalog, not only the installed ones
#[arg(long)]
all: bool,
/// Only show agents of this category
#[arg(long, value_name = "CATEGORY")]
category: Option<String>,
},
/// Start an agent (foreground by default, or detached with --background)
Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
Stop {
/// Agent name, alias, or group:<name>
agent: String,
/// Kill immediately instead of terminating gracefully
#[arg(long)]
force: bool,
/// Grace period in seconds before killing (default: from config, 5s)
#[arg(long, value_name = "SECS")]
timeout: Option<u64>,
},
/// Restart an agent: stop, then start with the same options
Restart {
#[command(flatten)]
start: StartArgs,
/// Kill immediately instead of terminating gracefully
#[arg(long)]
force: bool,
/// Grace period in seconds before killing (default: from config, 5s)
#[arg(long, value_name = "SECS")]
timeout: Option<u64>,
},
/// Show the state of one agent, or of every installed agent
Status {
/// Agent name or alias (omit to show all installed agents)
agent: Option<String>,
},
/// Install an agent and its dependencies
Install {
/// Agent name or alias
agent: String,
/// Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git)
#[arg(long, value_name = "METHOD")]
method: Option<String>,
/// Reinstall even if already installed
#[arg(long)]
force: bool,
},
/// Uninstall an agent and clean its files
Uninstall {
/// Agent name or alias
agent: String,
/// Also remove logs and the agent entry from the user config file
#[arg(long)]
purge: bool,
},
/// Update an installed agent to the latest available version
Update {
/// Agent name or alias (required unless --all)
agent: Option<String>,
/// Update every installed managed agent
#[arg(long)]
all: bool,
},
/// Search the catalog by keyword (name, description, category, tags)
Search {
/// Keyword to search for (case-insensitive substring)
keyword: String,
/// Restrict to this category
#[arg(long, value_name = "CATEGORY")]
category: Option<String>,
},
/// Show detailed information about one agent
Info {
/// Agent name or alias
agent: String,
},
/// Manage the configuration file
#[command(subcommand)]
Config(ConfigCmd),
/// Check the environment: tools, config validity, paths, permissions
Doctor {
/// Attempt to repair problems (create directories, fix the state file)
#[arg(long)]
fix: bool,
},
/// Run the agent command directly with the given arguments (no process management)
Run {
/// Agent name or alias
agent: String,
/// Arguments passed through to the agent command
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS...")]
args: Vec<std::ffi::OsString>,
},
/// Generate a shell completion script
Completion {
/// Shell to generate completions for
#[arg(value_enum, value_name = "SHELL")]
shell: Shell,
},
/// Update agent-manager itself from GitHub Releases
SelfUpdate {
/// Only check whether a newer version exists
#[arg(long)]
check: bool,
/// Download the new binary to this path instead of replacing the current one
#[arg(long, value_name = "FILE")]
to: Option<PathBuf>,
},
/// Export the configuration and installation state (backup)
Export {
/// Output file (default: am-export.json)
#[arg(long, value_name = "FILE", default_value = "am-export.json")]
output: PathBuf,
},
/// Import a previously exported configuration and state
Import {
/// File exported by the export command
file: PathBuf,
},
}
/// Arguments shared by start and restart.
#[derive(Args, Debug, Clone, Default)]
pub struct StartArgs {
/// Agent name, alias, or group:<name>
pub agent: String,
/// Run detached in the background; output goes to the agent log file
#[arg(short = 'b', long, conflicts_with = "foreground", action = ArgAction::SetTrue)]
pub background: bool,
/// Run in the foreground, attached to this terminal (the default)
#[arg(short = 'f', long, action = ArgAction::SetTrue)]
pub foreground: bool,
/// Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)
#[arg(long, value_name = "ARGS", num_args = 0.., action = ArgAction::Append)]
pub args: Vec<String>,
/// Set an environment variable for the agent (repeatable): --env KEY=VALUE
#[arg(long, value_name = "KEY=VALUE", action = ArgAction::Append)]
pub env: Vec<String>,
/// Send a desktop notification once the agent has started
#[arg(long, action = ArgAction::SetTrue)]
pub notify: bool,
}
/// Configuration management subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum ConfigCmd {
/// Print the effective (merged) configuration as YAML
Show,
/// Print the location of the configuration file(s)
Path,
/// Open the user configuration file in your editor
Edit,
/// Validate the configuration file(s)
Validate,
/// Add another agent definition file to the user configuration
Add {
/// YAML (or JSON) file defining agents to include
file: PathBuf,
},
}
+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)
}
+739
View File
@@ -0,0 +1,739 @@
//! Configuration: YAML schema, loading with includes, merging with the
//! embedded default catalog, validation, and path expansion.
use crate::cli::Cli;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
/// Default catalog embedded in the binary at compile time.
pub const DEFAULT_CONFIG: &str = include_str!("../config.yaml");
/// Name of the user configuration file inside the config directory.
pub const CONFIG_FILE_NAME: &str = "config.yaml";
/// Name of the local (current directory) configuration file.
pub const LOCAL_CONFIG_NAME: &str = "agent-manager.yaml";
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub settings: Settings,
#[serde(default)]
pub aliases: BTreeMap<String, String>,
#[serde(default)]
pub groups: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub agents: Vec<AgentDef>,
}
fn default_version() -> String {
"1.0".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Settings {
/// Installation root for all agents.
#[serde(default)]
pub install_dir: Option<String>,
/// Directory for logs.
#[serde(default)]
pub log_dir: Option<String>,
/// Shell used to execute install scripts (defaults to sh on Unix).
#[serde(default)]
pub default_shell: Option<String>,
/// Try to install missing dependencies automatically.
#[serde(default)]
pub auto_install_deps: Option<bool>,
/// Ask for confirmation before running install scripts.
#[serde(default)]
pub confirm_before_run: Option<bool>,
/// Grace period (seconds) between SIGTERM and SIGKILL; 0 = default (5).
#[serde(default)]
pub stop_timeout_secs: u64,
/// GitHub repository (owner/repo) used by the self-update command.
#[serde(default)]
pub self_update_repo: Option<String>,
}
impl Settings {
pub fn auto_install_deps(&self) -> bool {
self.auto_install_deps.unwrap_or(true)
}
pub fn confirm_before_run(&self) -> bool {
self.confirm_before_run.unwrap_or(true)
}
pub fn stop_timeout(&self) -> u64 {
if self.stop_timeout_secs == 0 {
5
} else {
self.stop_timeout_secs
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentDef {
/// Unique identifier (slug).
pub name: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub website: Option<String>,
#[serde(default)]
pub install: Option<InstallSpec>,
#[serde(default)]
pub dependencies: Vec<Dependency>,
/// Command used to start the agent (e.g. "claude", "python -m nanobot").
#[serde(default)]
pub run: Option<String>,
/// Default arguments appended to the run command.
#[serde(default)]
pub args: Vec<String>,
/// Default environment variables for the agent.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Known version (otherwise detected via --version).
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
/// When false, the agent is listed but cannot be installed locally.
#[serde(default = "default_true")]
pub installable: bool,
/// Explanation shown when installable is false.
#[serde(default)]
pub note: Option<String>,
/// Hidden from list/search output (still addressable by name).
#[serde(default)]
pub hidden: bool,
/// Restrict the agent to platforms: linux, macos, windows (empty = all).
#[serde(default)]
pub platforms: Vec<String>,
}
fn default_true() -> bool {
true
}
impl AgentDef {
pub fn title(&self) -> &str {
self.display_name.as_deref().unwrap_or(&self.name)
}
/// First word of the run command (the executable name).
pub fn first_token(&self) -> Option<String> {
self.run
.as_deref()
.and_then(|r| shell_words::split(r).ok())
.and_then(|mut v| {
if v.is_empty() {
None
} else {
Some(v.remove(0))
}
})
}
/// The run command split into tokens (empty when unset).
pub fn run_tokens(&self) -> Vec<String> {
self.run
.as_deref()
.and_then(|r| shell_words::split(r).ok())
.unwrap_or_default()
}
pub fn supports_current_platform(&self) -> bool {
if self.platforms.is_empty() {
return true;
}
let platform = current_platform();
self.platforms.iter().any(|p| p == platform)
}
}
pub fn current_platform() -> &'static str {
if cfg!(windows) {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"linux"
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InstallType {
Npm,
Pip,
Uv,
Cargo,
Go,
Bun,
Curl,
Binary,
Git,
}
impl InstallType {
pub fn as_str(&self) -> &'static str {
match self {
InstallType::Npm => "npm",
InstallType::Pip => "pip",
InstallType::Uv => "uv",
InstallType::Cargo => "cargo",
InstallType::Go => "go",
InstallType::Bun => "bun",
InstallType::Curl => "curl",
InstallType::Binary => "binary",
InstallType::Git => "git",
}
}
}
impl std::fmt::Display for InstallType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// A single install method: npm, pip, uv, cargo, go, bun, curl, binary or git.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MethodSpec {
#[serde(rename = "type")]
pub kind: InstallType,
/// Package name (npm, pip, cargo, go, bun).
#[serde(default)]
pub package: Option<String>,
/// Direct download URL (binary method).
#[serde(default)]
pub url: Option<String>,
/// Install script URL (curl method).
#[serde(default)]
pub script: Option<String>,
/// GitHub repository "owner/repo" (binary method) or full clone URL (git).
#[serde(default)]
pub repo: Option<String>,
/// Substrings that must all appear in the release asset name.
#[serde(default)]
pub asset_match: Vec<String>,
/// Substrings that must not appear in the release asset name.
#[serde(default)]
pub asset_exclude: Vec<String>,
/// Expected checksum of the downloaded file, "sha256:<hex>".
#[serde(default)]
pub checksum: Option<String>,
/// Expected binary name (used to locate the executable).
#[serde(default)]
pub binary: Option<String>,
/// Build command(s) for the git method (string or list).
#[serde(default)]
pub build: Option<OneOrMany>,
/// Path of the built binary inside the repository (git method).
#[serde(default)]
pub binary_path: Option<String>,
/// Extra arguments for the installer command.
#[serde(default)]
pub args: Vec<String>,
/// Extra environment for the installer command.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Free-form note shown in info and method listings.
#[serde(default)]
pub note: Option<String>,
}
/// A string or a list of strings (for build commands and similar fields).
#[derive(Debug, Clone, Serialize)]
pub enum OneOrMany {
One(String),
Many(Vec<String>),
}
impl OneOrMany {
pub fn items(&self) -> Vec<String> {
match self {
OneOrMany::One(s) => vec![s.clone()],
OneOrMany::Many(v) => v.clone(),
}
}
}
impl<'de> Deserialize<'de> for OneOrMany {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = OneOrMany;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a string or a list of strings")
}
fn visit_str<E>(self, v: &str) -> Result<OneOrMany, E> {
Ok(OneOrMany::One(v.to_string()))
}
fn visit_string<E>(self, v: String) -> Result<OneOrMany, E> {
Ok(OneOrMany::One(v))
}
fn visit_seq<A>(self, mut seq: A) -> Result<OneOrMany, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut out = Vec::new();
while let Some(v) = seq.next_element::<String>()? {
out.push(v);
}
Ok(OneOrMany::Many(out))
}
}
deserializer.deserialize_any(Visitor)
}
}
/// Install specification: one primary method, optionally several alternatives.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstallSpec {
#[serde(flatten)]
pub primary: MethodSpec,
/// Alternative methods; the first one is the default.
#[serde(default)]
pub methods: Vec<MethodSpec>,
/// Commands executed after a successful install (PATH includes the bin dir).
#[serde(default)]
pub post_install: Vec<String>,
}
impl InstallSpec {
/// All available methods: the alternatives when present, else the primary.
pub fn all_methods(&self) -> Vec<&MethodSpec> {
if self.methods.is_empty() {
vec![&self.primary]
} else {
self.methods.iter().collect()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Dependency {
pub name: String,
/// Command used to probe the tool (defaults from the name).
#[serde(default)]
pub check: Option<String>,
/// Minimum required version (lenient comparison).
#[serde(default)]
pub min_version: Option<String>,
/// Human-readable hint on how to install the tool.
#[serde(default)]
pub install_hint: Option<String>,
/// Command that installs the tool (used with auto_install_deps).
#[serde(default)]
pub install_cmd: Vec<String>,
}
// ---------------------------------------------------------------------------
// Loading and merging
// ---------------------------------------------------------------------------
pub struct LoadedConfig {
pub config: Config,
/// The main user configuration file that was found (if any).
pub origin: Option<PathBuf>,
}
/// Load the effective configuration: embedded defaults, then the user file
/// (found by priority order), then its includes.
pub fn load(cli: &Cli) -> Result<LoadedConfig> {
let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG)
.context("embedded default catalog is invalid — this is a bug in agent-manager")?;
let mut origin: Option<PathBuf> = None;
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(p) = std::env::var_os("AGENT_MANAGER_CONFIG") {
candidates.push(PathBuf::from(p));
}
if let Some(p) = &cli.config {
candidates.push(p.clone());
}
candidates.push(PathBuf::from(LOCAL_CONFIG_NAME));
if let Some(dir) = user_config_dir() {
candidates.push(dir.join(CONFIG_FILE_NAME));
}
let chosen = candidates.into_iter().find(|p| p.is_file());
if let Some(path) = chosen {
let overlay = load_file_with_includes(&path, &mut HashSet::new())?;
merge(&mut base, overlay);
origin = Some(path);
}
let problems = validate(&base);
if !problems.is_empty() {
return Err(anyhow!(
"configuration problems:
- {}",
problems.join("
- ")
));
}
Ok(LoadedConfig { config: base, origin })
}
/// Parse a YAML or JSON config file.
pub fn parse_file(path: &Path) -> Result<Config> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read config file {}", path.display()))?;
let is_json = path.extension().map(|e| e == "json").unwrap_or(false);
if is_json {
serde_json::from_str(&text)
.map_err(|e| anyhow!("invalid JSON in {}: {e}", path.display()))
} else {
serde_yaml::from_str(&text)
.map_err(|e| anyhow!("invalid YAML in {}: {e}", path.display()))
}
}
fn load_file_with_includes(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<Config> {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if !visited.insert(canon.clone()) {
return Err(anyhow!("config include cycle detected at {}", path.display()));
}
let mut cfg = parse_file(path)?;
let includes = cfg.include.clone();
cfg.include.clear();
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut merged = Config::default();
merged.version = cfg.version.clone();
for inc in &includes {
let inc_path = expand_path(inc);
let abs = if inc_path.is_absolute() {
inc_path
} else {
base_dir.join(inc_path)
};
let sub = load_file_with_includes(&abs, visited)?;
merge(&mut merged, sub);
}
merge(&mut merged, cfg);
Ok(merged)
}
/// Merge an overlay config into a base config: user definitions replace
/// default agents with the same name; maps are merged key by key.
pub fn merge(base: &mut Config, overlay: Config) {
let s = &mut base.settings;
let o = overlay.settings;
if o.install_dir.is_some() {
s.install_dir = o.install_dir;
}
if o.log_dir.is_some() {
s.log_dir = o.log_dir;
}
if o.default_shell.is_some() {
s.default_shell = o.default_shell;
}
if o.auto_install_deps.is_some() {
s.auto_install_deps = o.auto_install_deps;
}
if o.confirm_before_run.is_some() {
s.confirm_before_run = o.confirm_before_run;
}
if o.stop_timeout_secs != 0 {
s.stop_timeout_secs = o.stop_timeout_secs;
}
if o.self_update_repo.is_some() {
s.self_update_repo = o.self_update_repo;
}
for (k, v) in overlay.aliases {
base.aliases.insert(k, v);
}
for (k, v) in overlay.groups {
base.groups.insert(k, v);
}
for agent in overlay.agents {
if let Some(existing) = base.agents.iter_mut().find(|a| a.name == agent.name) {
*existing = agent;
} else {
base.agents.push(agent);
}
}
}
/// Validate a merged config. Returns a list of human-readable problems.
pub fn validate(config: &Config) -> Vec<String> {
let mut problems = Vec::new();
if config.version != "1" && config.version != "1.0" {
problems.push(format!(
"unsupported config version '{}' (expected '1.0')",
config.version
));
}
let mut seen = HashSet::new();
for agent in &config.agents {
if !seen.insert(agent.name.clone()) {
problems.push(format!("duplicate agent name '{}'", agent.name));
}
if agent.name.is_empty()
|| !agent
.name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
problems.push(format!(
"agent name '{}' must be a lowercase slug ([a-z0-9_-])",
agent.name
));
}
if agent.installable {
if agent.run.as_deref().unwrap_or("").trim().is_empty() {
problems.push(format!("agent '{}': 'run' is required when installable", agent.name));
}
if agent.install.is_none() {
problems.push(format!(
"agent '{}': 'install' block is required when installable",
agent.name
));
}
}
for dep in &agent.dependencies {
if dep.name.trim().is_empty() {
problems.push(format!("agent '{}': dependency without a name", agent.name));
}
}
}
problems
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
/// Expand "~" and environment variables ("$HOME", "${VAR}") in a path.
pub fn expand_path(input: &str) -> PathBuf {
let mut s = input.to_string();
if s == "~" {
s = home_dir()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|| "~".to_string());
} else if let Some(rest) = s.strip_prefix("~/").or_else(|| s.strip_prefix("~\\")) {
if let Some(h) = home_dir() {
s = format!("{}{}{}", h.to_string_lossy(), std::path::MAIN_SEPARATOR, rest);
}
}
PathBuf::from(expand_vars(&s))
}
/// Expand "$VAR" and "${VAR}" occurrences.
pub fn expand_vars(input: &str) -> String {
let chars: Vec<char> = input.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '$' && i + 1 < chars.len() {
if chars[i + 1] == '{' {
if let Some(end) = chars[i + 2..].iter().position(|&c| c == '}') {
let name: String = chars[i + 2..i + 2 + end].iter().collect();
if let Ok(v) = std::env::var(&name) {
out.push_str(&v);
i += end + 3;
continue;
}
}
} else {
let mut j = i + 1;
while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') {
j += 1;
}
if j > i + 1 {
let name: String = chars[i + 1..j].iter().collect();
if let Ok(v) = std::env::var(&name) {
out.push_str(&v);
i = j;
continue;
}
}
}
}
out.push(chars[i]);
i += 1;
}
out
}
/// The user's home directory.
pub fn home_dir() -> Option<PathBuf> {
if let Some(h) = std::env::var_os("HOME") {
return Some(PathBuf::from(h));
}
if let Some(h) = std::env::var_os("USERPROFILE") {
return Some(PathBuf::from(h));
}
None
}
/// Directory holding the user configuration file.
pub fn user_config_dir() -> Option<PathBuf> {
if let Some(d) = std::env::var_os("AGENT_MANAGER_CONFIG_DIR") {
return Some(PathBuf::from(d));
}
let base = if cfg!(windows) {
std::env::var_os("APPDATA").map(PathBuf::from)
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| home_dir().map(|h| h.join(".config")))
};
base.map(|b| b.join("agent-manager"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_embedded_default() {
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
assert!(cfg.agents.len() >= 60, "catalog too small: {}", cfg.agents.len());
assert!(cfg.agents.iter().any(|a| a.name == "claude-code"));
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
}
#[test]
fn expands_paths() {
std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value");
let home = home_dir().unwrap_or_else(|| PathBuf::from("/home/test"));
let p = expand_path("~/a/$AM_TEST_EXPAND_VAR/b");
let s = p.to_string_lossy().to_string();
assert!(!s.contains('~'));
assert!(!s.contains("$AM_TEST_EXPAND_VAR"));
assert!(s.contains("expanded-value"));
assert!(s.contains("a"));
assert!(s.contains(&home.to_string_lossy().to_string()));
}
#[test]
fn merges_user_over_default() {
let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
let overlay: Config = serde_yaml::from_str(
r#"
version: "1.0"
agents:
- name: claude-code
display_name: "Mon Claude"
install:
type: npm
package: "@anthropic-ai/claude-code"
run: claude
- name: my-agent
display_name: "My Agent"
install:
type: binary
url: "https://example.com/bin"
run: my-agent
"#,
)
.unwrap();
merge(&mut base, overlay);
let cc = base.agents.iter().find(|a| a.name == "claude-code").unwrap();
assert_eq!(cc.display_name.as_deref(), Some("Mon Claude"));
assert!(base.agents.iter().any(|a| a.name == "my-agent"));
assert!(validate(&base).is_empty());
}
#[test]
fn validate_rejects_bad_agents() {
let cfg: Config = serde_yaml::from_str(
r#"
version: "1.0"
agents:
- name: broken
installable: true
run: ""
install:
type: npm
package: x
- name: broken
install:
type: npm
package: y
run: broken
"#,
)
.unwrap();
let problems = validate(&cfg);
assert!(problems.iter().any(|p| p.contains("duplicate")));
assert!(problems.iter().any(|p| p.contains("'run' is required")));
}
#[test]
fn one_or_many_parses_both() {
let a: OneOrMany = serde_yaml::from_str("\"make build\"").unwrap();
assert_eq!(a.items(), vec!["make build".to_string()]);
let b: OneOrMany = serde_yaml::from_str("- cargo
- build").unwrap();
assert_eq!(b.items(), vec!["cargo".to_string(), "build".to_string()]);
}
#[test]
fn includes_resolve_relative() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub.yaml");
std::fs::write(
&sub,
r#"
version: "1.0"
agents:
- name: from-include
display_name: "Included"
install: { type: npm, package: pkg }
run: from-include
"#,
)
.unwrap();
let main = dir.path().join("main.yaml");
std::fs::write(
&main,
r#"
version: "1.0"
include: [sub.yaml]
agents:
- name: from-main
display_name: "Main"
install: { type: npm, package: pkg2 }
run: from-main
"#,
)
.unwrap();
let cfg = load_file_with_includes(&main, &mut HashSet::new()).unwrap();
assert!(cfg.agents.iter().any(|a| a.name == "from-include"));
assert!(cfg.agents.iter().any(|a| a.name == "from-main"));
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Dependency checking: probe required tools (node, python, go, ...),
//! compare minimum versions, and produce actionable error messages.
use crate::app::App;
use crate::config::{AgentDef, Dependency};
use crate::runner::Runner;
use anyhow::{bail, Result};
use std::collections::BTreeMap;
use std::cmp::Ordering;
#[derive(Debug, Clone)]
pub struct DepCheck {
pub name: String,
pub found: bool,
pub version: Option<String>,
pub min: Option<String>,
pub ok: bool,
pub detail: String,
}
/// Default probe command for a dependency name: (program, args).
fn default_probe(name: &str) -> (String, Vec<String>) {
match name {
"go" => ("go".to_string(), vec!["version".to_string()]),
_ => (name.to_string(), vec!["--version".to_string()]),
}
}
/// Probe one dependency. Python is special: python3 is preferred.
pub fn check_dep(runner: &dyn Runner, dep: &Dependency) -> DepCheck {
let probes: Vec<(String, Vec<String>)> = if dep.name == "python" {
vec![
("python3".to_string(), vec!["--version".to_string()]),
("python".to_string(), vec!["--version".to_string()]),
]
} else if let Some(check) = &dep.check {
match shell_words::split(check) {
Ok(tokens) if !tokens.is_empty() => {
let prog = tokens[0].clone();
vec![(prog, tokens[1..].to_vec())]
}
_ => vec![default_probe(&dep.name)],
}
} else {
vec![default_probe(&dep.name)]
};
let empty = BTreeMap::new();
for (prog, args) in probes {
if runner.which(&prog).is_none() {
continue;
}
match runner.run(&prog, &args, &empty, None, true) {
Ok(out) if out.code == 0 => {
let combined = format!("{}
{}", out.stdout, out.stderr);
let version = crate::version::find_version(&combined);
let ok = match (&dep.min_version, &version) {
(Some(min), Some(v)) => crate::version::compare(v, min)
.map(|o| o != Ordering::Less)
.unwrap_or(true),
_ => true,
};
let detail = if ok {
version.clone().unwrap_or_else(|| "found".to_string())
} else {
format!(
"version {} is too old (need >= {})",
version.as_deref().unwrap_or("unknown"),
dep.min_version.as_deref().unwrap_or("?")
)
};
return DepCheck {
name: dep.name.clone(),
found: true,
version,
min: dep.min_version.clone(),
ok,
detail,
};
}
Ok(out) => {
return DepCheck {
name: dep.name.clone(),
found: true,
version: None,
min: dep.min_version.clone(),
ok: false,
detail: format!("probe failed (exit code {})", out.code),
};
}
Err(_) => {}
}
}
DepCheck {
name: dep.name.clone(),
found: false,
version: None,
min: dep.min_version.clone(),
ok: false,
detail: "not found on PATH".to_string(),
}
}
/// Check every dependency of an agent.
pub fn check_agent(agent: &AgentDef, runner: &dyn Runner) -> Vec<DepCheck> {
agent
.dependencies
.iter()
.map(|d| check_dep(runner, d))
.collect()
}
/// Fail with an actionable message when dependencies are missing.
/// With auto_install_deps enabled, dependencies that define an install_cmd
/// are offered for automatic installation first.
pub fn ensure_deps(app: &App, agent: &AgentDef, runner: &dyn Runner) -> Result<()> {
let mut checks = check_agent(agent, runner);
if checks.iter().all(|c| c.ok) {
return Ok(());
}
if app.config.settings.auto_install_deps() && !app.dry_run() {
for dep in &agent.dependencies {
if dep.install_cmd.is_empty() {
continue;
}
if check_dep(runner, dep).ok {
continue;
}
let cmd_line = dep.install_cmd.join(" ");
app.log.warn(&format!("dependency '{}' is missing", dep.name));
let proceed = app
.confirm(&format!("Run '{cmd_line}' to install it?"))
.unwrap_or(false);
if proceed {
let prog = dep.install_cmd[0].clone();
let args: Vec<String> = dep.install_cmd[1..].to_vec();
let empty = BTreeMap::new();
let out = runner.run(&prog, &args, &empty, None, false)?;
if out.code != 0 {
app.log
.warn(&format!("automatic installation of '{}' failed", dep.name));
}
}
}
checks = check_agent(agent, runner);
}
if checks.iter().all(|c| c.ok) {
return Ok(());
}
let mut lines = Vec::new();
for c in checks.iter().filter(|c| !c.ok) {
let mut line = format!(" - {}: {}", c.name, c.detail);
if let Some(min) = &c.min {
line.push_str(&format!(" (minimum: {min})"));
}
if let Some(dep) = agent.dependencies.iter().find(|d| d.name == c.name) {
if let Some(hint) = &dep.install_hint {
line.push_str(&format!(" — install via {hint}"));
}
}
lines.push(line);
}
bail!(
"agent '{}' has unmet dependencies:
{}",
agent.name,
lines.join("
")
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Dependency;
use crate::runner::MockRunner;
#[test]
fn missing_tool_reported() {
let runner = MockRunner::new();
let dep = Dependency {
name: "node".to_string(),
check: None,
min_version: Some("18.0.0".to_string()),
install_hint: Some("https://nodejs.org".to_string()),
install_cmd: vec![],
};
let c = check_dep(&runner, &dep);
assert!(!c.ok);
assert!(!c.found);
assert_eq!(c.detail, "not found on PATH");
}
#[test]
fn python3_preferred() {
let runner = MockRunner::new().with_found("python3").with_found("python");
let dep = Dependency {
name: "python".to_string(),
check: None,
min_version: Some("3.9".to_string()),
install_hint: None,
install_cmd: vec![],
};
let c = check_dep(&runner, &dep);
// MockRunner returns empty output => version unknown => ok when no min.
assert!(c.found);
}
}
+229
View File
@@ -0,0 +1,229 @@
//! doctor: environment checks — tools, config validity, paths,
//! permissions — with an optional --fix mode.
use crate::app::App;
use crate::runner::Runner;
use anyhow::{Context, Result};
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Ok,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize)]
pub struct Check {
pub name: String,
pub status: Status,
pub detail: String,
}
pub struct DoctorReport {
pub checks: Vec<Check>,
}
impl DoctorReport {
pub fn ok(&self) -> bool {
!self.checks.iter().any(|c| c.status == Status::Error)
}
pub fn problems(&self) -> usize {
self.checks.iter().filter(|c| c.status != Status::Ok).count()
}
}
/// Args for probing a tool's version.
fn probe_args(tool: &str) -> Vec<String> {
match tool {
"go" => vec!["version".to_string()],
_ => vec!["--version".to_string()],
}
}
pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
let mut checks = Vec::new();
// --- tools ----------------------------------------------------------
// (name, required)
let tools: &[(&str, bool)] = &[
("git", true),
("sh", cfg!(unix)),
("curl", true),
("node", false),
("npm", false),
("python3", false),
("python", false),
("cargo", false),
("go", false),
("bun", false),
("uv", false),
("docker", false),
];
for (tool, required) in tools {
match runner.which(tool) {
Some(path) => {
let mut detail = path.display().to_string();
let empty = BTreeMap::new();
if let Ok(out) = runner.run(tool, &probe_args(tool), &empty, None, true) {
if out.code == 0 {
let combined = format!("{}
{}", out.stdout, out.stderr);
if let Some(v) = crate::version::find_version(&combined) {
detail = format!("{} (v{v})", path.display());
}
}
}
checks.push(Check {
name: tool.to_string(),
status: Status::Ok,
detail,
});
}
None => {
let (status, detail) = if *required {
(
Status::Error,
"required but not found on PATH".to_string(),
)
} else {
(Status::Ok, "not installed".to_string())
};
checks.push(Check {
name: tool.to_string(),
status,
detail,
});
}
}
}
// --- configuration ---------------------------------------------------
match &app.config_origin {
Some(path) => checks.push(Check {
name: "config".to_string(),
status: Status::Ok,
detail: path.display().to_string(),
}),
None => checks.push(Check {
name: "config".to_string(),
status: Status::Warn,
detail: "no user config file found; using the embedded default catalog".to_string(),
}),
}
let problems = crate::config::validate(&app.config);
if problems.is_empty() {
checks.push(Check {
name: "config-schema".to_string(),
status: Status::Ok,
detail: format!("{} agents defined", app.catalog.len()),
});
} else {
checks.push(Check {
name: "config-schema".to_string(),
status: Status::Error,
detail: problems.join("; "),
});
}
// --- state file ------------------------------------------------------
match app.state.load() {
Ok(sf) => checks.push(Check {
name: "state".to_string(),
status: Status::Ok,
detail: format!("{} installed agent(s)", sf.installed.len()),
}),
Err(e) => checks.push(Check {
name: "state".to_string(),
status: Status::Error,
detail: format!("{e:#}"),
}),
}
// --- directories -----------------------------------------------------
for (name, dir) in [
("install-dir", app.paths.install_dir.clone()),
("bin-dir", app.paths.bin_dir.clone()),
("log-dir", app.paths.log_dir.clone()),
] {
let status = if dir.is_dir() {
Status::Ok
} else if app.paths.install_dir.parent().map(|p| p.is_dir()).unwrap_or(false) {
Status::Warn
} else {
Status::Warn
};
let detail = if dir.is_dir() {
dir.display().to_string()
} else {
format!("{} (missing; 'am doctor --fix' creates it)", dir.display())
};
checks.push(Check {
name: name.to_string(),
status,
detail,
});
}
// --- bin dir on PATH --------------------------------------------------
let on_path = is_on_path(&app.paths.bin_dir);
checks.push(Check {
name: "bin-on-path".to_string(),
status: if on_path { Status::Ok } else { Status::Warn },
detail: if on_path {
format!("{} is on PATH", app.paths.bin_dir.display())
} else {
format!(
"{} is not on PATH — add it so agents are found from your shell",
app.paths.bin_dir.display()
)
},
});
DoctorReport { checks }
}
fn is_on_path(dir: &std::path::Path) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|p| {
if cfg!(windows) {
p.to_string_lossy().to_lowercase() == dir.to_string_lossy().to_lowercase()
} else {
p == dir
}
})
}
/// Repair what can be repaired: create directories, fix a corrupt state file.
pub fn fix(app: &App) -> Result<Vec<String>> {
let mut repaired = Vec::new();
app.paths.ensure().context("creating directories")?;
repaired.push(format!("created {}", app.paths.install_dir.display()));
repaired.push(format!("created {}", app.paths.log_dir.display()));
// Corrupt state file: back it up and start fresh.
if app.state.path().exists() && app.state.load().is_err() {
let backup = app
.state
.path()
.with_extension("json.corrupt.bak");
std::fs::rename(app.state.path(), &backup).with_context(|| {
format!(
"cannot back up corrupt state file {} to {}",
app.state.path().display(),
backup.display()
)
})?;
app.state.save(&crate::state::StateFile::default())?;
repaired.push(format!(
"corrupt state file moved to {} and recreated empty",
backup.display()
));
}
Ok(repaired)
}
+470
View File
@@ -0,0 +1,470 @@
//! Downloading (with checksum verification), GitHub Releases resolution,
//! archive extraction and executable discovery.
use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::io::{IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
pub const USER_AGENT: &str = concat!("agent-manager/", env!("CARGO_PKG_VERSION"));
// ---------------------------------------------------------------------------
// URL validation + download
// ---------------------------------------------------------------------------
/// Only http, https and file schemes are allowed.
pub fn validate_url(url: &str) -> Result<()> {
let lower = url.to_lowercase();
let ok = lower.starts_with("https://")
|| lower.starts_with("http://")
|| lower.starts_with("file://");
if !ok {
bail!(
"refusing URL with unsupported scheme (only http, https and file are allowed): {url}"
);
}
Ok(())
}
/// Download a URL to dest, verifying the optional sha256 checksum.
pub fn download(url: &str, dest: &Path, expected_sha256: Option<&str>, progress: bool) -> Result<()> {
validate_url(url)?;
if let Some(local) = url.strip_prefix("file://") {
let src = PathBuf::from(local);
std::fs::copy(&src, dest)
.with_context(|| format!("cannot copy {} to {}", src.display(), dest.display()))?;
verify_sha256(dest, expected_sha256)?;
return Ok(());
}
let resp = ureq::get(url)
.set("User-Agent", USER_AGENT)
.call()
.map_err(|e| {
match e {
ureq::Error::Status(code, r) => anyhow!(
"download failed for {url}: HTTP {code} {}",
r.status_text()
),
other => anyhow!("download failed for {url}: {other}"),
}
})?;
let total: Option<u64> = resp.header("content-length").and_then(|v| v.parse().ok());
let mut reader = resp.into_reader();
let mut file = std::fs::File::create(dest)
.with_context(|| format!("cannot create {}", dest.display()))?;
let mut buf = [0u8; 65536];
let mut done: u64 = 0;
let mut last_shown: u64 = 0;
let show = progress && std::io::stderr().is_terminal();
loop {
let n = reader
.read(&mut buf)
.with_context(|| format!("read error while downloading {url}"))?;
if n == 0 {
break;
}
file.write_all(&buf[..n])?;
done += n as u64;
if show && (done - last_shown >= 8 * 1024 * 1024 || done == total.unwrap_or(u64::MAX)) {
last_shown = done;
match total {
Some(t) => eprint!(
"\r downloading {:.1} / {:.1} MB",
done as f64 / 1e6,
t as f64 / 1e6
),
None => eprint!("\r downloaded {:.1} MB", done as f64 / 1e6),
}
std::io::stderr().flush().ok();
}
}
if show {
eprintln!();
}
verify_sha256(dest, expected_sha256)?;
Ok(())
}
fn verify_sha256(path: &Path, expected: Option<&str>) -> Result<()> {
let Some(exp) = expected else {
return Ok(());
};
let hex = exp
.strip_prefix("sha256:")
.or_else(|| exp.strip_prefix("SHA256:"))
.unwrap_or(exp)
.trim();
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
bail!("invalid sha256 checksum in config: {exp}");
}
let mut f = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
std::io::copy(&mut f, &mut hasher)?;
let actual = to_hex(&hasher.finalize());
if !actual.eq_ignore_ascii_case(hex) {
bail!(
"checksum mismatch for {}: expected {hex}, got {actual}",
path.display()
);
}
Ok(())
}
pub fn to_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
// ---------------------------------------------------------------------------
// GitHub Releases
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct GhAsset {
pub name: String,
pub browser_download_url: String,
#[serde(default)]
pub size: u64,
}
#[derive(Debug, Deserialize)]
pub struct GhRelease {
pub tag_name: String,
#[serde(default)]
pub assets: Vec<GhAsset>,
}
pub fn latest_release(repo: &str) -> Result<GhRelease> {
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
let resp = ureq::get(&url)
.set("User-Agent", USER_AGENT)
.call()
.map_err(|e| match e {
ureq::Error::Status(404, _) => {
anyhow!("no GitHub releases found for repository '{repo}'")
}
other => anyhow!("GitHub API request failed for '{repo}': {other}"),
})?;
let text = resp
.into_string()
.context("reading GitHub API response")?;
serde_json::from_str::<GhRelease>(&text).context("parsing GitHub API response")
}
pub struct PickedAsset {
pub name: String,
pub url: String,
pub tag: String,
}
/// Choose the release asset that best matches the current platform and arch.
pub fn pick_asset(
release: &GhRelease,
want: &[String],
exclude: &[String],
binary_hint: Option<&str>,
) -> Option<PickedAsset> {
let platform = if cfg!(windows) {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"linux"
};
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"x86_64"
};
let mut best: Option<(u32, usize, &GhAsset)> = None;
for asset in &release.assets {
let lower = asset.name.to_lowercase();
// Skip checksums, signatures and desktop packages.
if lower.ends_with(".sha256")
|| lower.ends_with(".sha512")
|| lower.ends_with(".sig")
|| lower.ends_with(".asc")
|| lower.ends_with(".pem")
|| lower.contains("checksums")
|| lower.ends_with(".dmg")
|| lower.ends_with(".deb")
|| lower.ends_with(".rpm")
|| lower.ends_with(".msi")
|| lower.ends_with(".apk")
{
continue;
}
if !want.iter().all(|w| lower.contains(&w.to_lowercase())) {
continue;
}
if exclude.iter().any(|w| lower.contains(&w.to_lowercase())) {
continue;
}
let score = match platform_score(&lower, platform, arch) {
Some(s) => s,
None => continue,
};
let replace = match best {
None => true,
Some((s, _, _)) => score > s,
};
if replace {
best = Some((score, asset.name.len(), asset));
}
}
best.map(|(_, _, a)| {
let _ = binary_hint;
PickedAsset {
name: a.name.clone(),
url: a.browser_download_url.clone(),
tag: release.tag_name.clone(),
}
})
}
fn platform_score(lower: &str, platform: &str, arch: &str) -> Option<u32> {
let plat_ok = match platform {
"windows" => {
lower.contains("windows")
|| lower.ends_with(".exe")
|| lower.contains("win64")
|| lower.contains("win32")
|| lower.contains("msvc")
|| lower.contains("mingw")
|| lower.contains("pc-windows")
}
"macos" => {
lower.contains("darwin")
|| lower.contains("macos")
|| lower.contains("apple")
|| lower.contains("osx")
}
_ => {
lower.contains("linux")
|| lower.contains("unknown-linux")
|| lower.contains("manylinux")
}
};
if !plat_ok {
return None;
}
let arch_ok = match arch {
"aarch64" => lower.contains("aarch64") || lower.contains("arm64") || lower.contains("armv8"),
_ => {
lower.contains("x86_64")
|| lower.contains("amd64")
|| lower.contains("x64")
|| lower.contains("i686")
}
};
if !arch_ok && !lower.contains("universal") {
return None;
}
let mut s = 1u32;
if lower.contains("universal") {
s += 4;
}
if arch == "aarch64" && lower.contains("arm64") {
s += 3;
}
if arch == "x86_64" && (lower.contains("x86_64") || lower.contains("amd64")) {
s += 3;
}
if lower.contains("musl") {
s += 1;
}
if lower.contains("static") || lower.contains("portable") {
s += 1;
}
if lower.contains("armv7")
|| lower.contains("armhf")
|| lower.contains("armv6")
|| lower.contains("i686")
|| lower.contains("i386")
{
s = s.saturating_sub(2);
}
Some(s)
}
// ---------------------------------------------------------------------------
// Archives and executables
// ---------------------------------------------------------------------------
/// Extract zip / tar.gz / tgz / tar / gz archives. Returns false when the
/// file is not a recognized archive (caller treats it as a plain binary).
pub fn extract_archive(path: &Path, dest: &Path) -> Result<bool> {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
std::fs::create_dir_all(dest)?;
if name.ends_with(".zip") {
extract_zip(path, dest)?;
Ok(true)
} else if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
let f = std::fs::File::open(path)?;
let dec = flate2::read::GzDecoder::new(f);
let mut ar = tar::Archive::new(dec);
ar.unpack(dest)
.with_context(|| format!("extracting {}", path.display()))?;
Ok(true)
} else if name.ends_with(".tar") {
let f = std::fs::File::open(path)?;
let mut ar = tar::Archive::new(f);
ar.unpack(dest)
.with_context(|| format!("extracting {}", path.display()))?;
Ok(true)
} else if name.ends_with(".gz") {
let inner = name.trim_end_matches(".gz").to_string();
let out = dest.join(inner);
let f = std::fs::File::open(path)?;
let mut dec = flate2::read::GzDecoder::new(f);
let mut o = std::fs::File::create(&out)?;
std::io::copy(&mut dec, &mut o)?;
Ok(true)
} else {
Ok(false)
}
}
fn extract_zip(path: &Path, dest: &Path) -> Result<()> {
let f = std::fs::File::open(path)?;
let mut zip = zip::ZipArchive::new(f)
.with_context(|| format!("opening zip archive {}", path.display()))?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i)?;
let Some(enclosed) = entry.enclosed_name() else {
bail!("zip archive {} contains an unsafe path", path.display());
};
let out_path = dest.join(enclosed);
if entry.is_dir() {
std::fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out = std::fs::File::create(&out_path)?;
std::io::copy(&mut entry, &mut out)?;
}
}
Ok(())
}
/// Find the executable in a directory tree: exact name match first, then a
/// single candidate file, then a single executable file (Unix).
pub fn find_executable(dir: &Path, expected: Option<&str>) -> Result<PathBuf> {
let mut files = Vec::new();
collect_files(dir, &mut files);
if files.is_empty() {
bail!("no files found in {}", dir.display());
}
if let Some(exp) = expected {
for f in &files {
let name = f
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let stripped = name.strip_suffix(".exe").unwrap_or(&name);
if stripped.eq_ignore_ascii_case(exp) {
return Ok(f.clone());
}
}
}
if files.len() == 1 {
return Ok(files.remove(0));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let exec: Vec<&PathBuf> = files
.iter()
.filter(|f| {
std::fs::metadata(f)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
})
.collect();
if exec.len() == 1 {
return Ok(exec[0].clone());
}
}
let names: Vec<String> = files
.iter()
.map(|f| format!(" {}", f.display()))
.collect();
bail!(
"cannot determine which file is the executable in {}; candidates:
{}",
dir.display(),
names.join("
")
)
}
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) {
if let Ok(rd) = std::fs::read_dir(dir) {
let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for p in entries {
if p.is_dir() {
collect_files(&p, out);
} else {
out.push(p);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_validation() {
assert!(validate_url("https://example.com/x").is_ok());
assert!(validate_url("http://example.com/x").is_ok());
assert!(validate_url("file:///tmp/x").is_ok());
assert!(validate_url("ftp://example.com/x").is_err());
assert!(validate_url("javascript:alert(1)").is_err());
}
#[test]
fn sha256_verification() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("f.bin");
std::fs::write(&p, b"hello world").unwrap();
let good = to_hex(&Sha256::digest(b"hello world"));
verify_sha256(&p, Some(&good)).unwrap();
verify_sha256(&p, Some("0000000000000000000000000000000000000000000000000000000000000000"))
.unwrap_err();
}
#[test]
fn extracts_and_finds() {
let dir = tempfile::tempdir().unwrap();
let payload = dir.path().join("app.tar.gz");
{
let f = std::fs::File::create(&payload).unwrap();
let enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
let mut ar = tar::Builder::new(enc);
let mut header = tar::Header::new_gnu();
header.set_size(6);
header.set_mode(0o755);
header.set_cksum();
ar.append_data(&mut header, "bin/app", &b"binary"[..]).unwrap();
ar.finish().unwrap();
}
let out = dir.path().join("out");
assert!(extract_archive(&payload, &out).unwrap());
let found = find_executable(&out, Some("app")).unwrap();
assert!(found.ends_with("bin/app"));
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Binary installer: download a precompiled binary (direct URL or GitHub
//! Releases asset), verify its checksum, extract it if needed, and install
//! it into the global bin directory.
use super::*;
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let root = install_root(app, &agent.name);
let dist = root.join("dist");
let bindir = bin_dir(app);
ensure_dir(app, &root)?;
ensure_dir(app, &dist)?;
ensure_dir(app, &bindir)?;
// Resolve the download URL (direct or GitHub release asset).
let (url, tag): (String, Option<String>) = if let Some(u) = &method.url {
(u.clone(), None)
} else if let Some(repo) = &method.repo {
let release = crate::download::latest_release(repo)?;
let picked = crate::download::pick_asset(
&release,
&method.asset_match,
&method.asset_exclude,
method.binary.as_deref(),
)
.ok_or_else(|| {
anyhow!(
"no release asset of '{repo}' matches this platform ({}/{})",
crate::config::current_platform(),
std::env::consts::ARCH
)
})?;
app.log
.info(&format!("selected asset: {} ({})", picked.name, picked.tag));
(
picked.url,
Some(picked.tag.trim_start_matches('v').to_string()),
)
} else {
bail!("binary install requires 'url' or 'repo' (GitHub owner/repo)");
};
let names = expected_bin_names(agent, method);
let bin_name = names
.first()
.cloned()
.unwrap_or_else(|| agent.name.clone());
let dest_bin = bindir.join(exe_name(&bin_name));
if app.dry_run() {
app.log
.dry(format!("would download {url} and install {}", dest_bin.display()));
return Ok(InstallOutcome {
bins: vec![dest_bin],
version: tag,
});
}
let tmpdir = tempfile::tempdir()?;
let tmpfile = tmpdir.path().join("download");
crate::download::download(&url, &tmpfile, method.checksum.as_deref(), true)?;
if crate::download::extract_archive(&tmpfile, &dist)? {
let found = crate::download::find_executable(&dist, Some(&bin_name))?;
std::fs::copy(&found, &dest_bin).with_context(|| {
format!("cannot copy {} to {}", found.display(), dest_bin.display())
})?;
app.log
.info(&format!("extracted {} -> {}", found.display(), dest_bin.display()));
} else {
std::fs::copy(&tmpfile, &dest_bin).with_context(|| {
format!("cannot copy {} to {}", tmpfile.display(), dest_bin.display())
})?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dest_bin, std::fs::Permissions::from_mode(0o755));
}
let version = detect_version(runner, &dest_bin).or(tag);
Ok(InstallOutcome {
bins: vec![dest_bin],
version,
})
}
/// Latest release tag (without the leading "v").
pub fn latest(
_app: &App,
_agent: &AgentDef,
method: &MethodSpec,
_runner: &dyn Runner,
) -> Result<Option<String>> {
let Some(repo) = method.repo.as_deref() else {
return Ok(None);
};
let release = crate::download::latest_release(repo)?;
Ok(Some(release.tag_name.trim_start_matches('v').to_string()))
}
+76
View File
@@ -0,0 +1,76 @@
//! cargo installer: cargo install --root <root> <crate>.
use super::*;
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let pkg = method
.package
.as_deref()
.ok_or_else(|| anyhow!("cargo install requires 'package' (crate name)"))?;
require_tool(runner, "cargo", "https://rustup.rs")?;
let root = install_root(app, &agent.name);
ensure_dir(app, &root)?;
let mut args: Vec<String> = vec![
"install".into(),
"--root".into(),
root.display().to_string(),
pkg.to_string(),
];
args.extend(method.args.iter().cloned());
sh(runner, "cargo", &args, &method.env, None)?;
// cargo puts the binaries in <root>/bin
let expected = expected_bin_names(agent, method);
let bins = find_bins(&root.join("bin"), &expected);
let version = bins.first().and_then(|b| detect_version(runner, b));
Ok(InstallOutcome { bins, version })
}
/// Latest version from cargo search <crate> --limit 1.
pub fn latest(
_app: &App,
_agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<Option<String>> {
let Some(pkg) = method.package.as_deref() else {
return Ok(None);
};
if runner.which("cargo").is_none() {
return Ok(None);
}
let empty = BTreeMap::new();
let out = runner.run(
"cargo",
&[
"search".to_string(),
pkg.to_string(),
"--limit".to_string(),
"1".to_string(),
],
&empty,
None,
true,
)?;
if out.code != 0 {
return Ok(None);
}
// Output looks like: smelt = "0.5.0" # description
let text = out.stdout.trim().to_string();
let Some(eq) = text.find('=') else {
return Ok(None);
};
let rest = text[eq + 1..].trim();
let Some(start) = rest.find('"') else {
return Ok(None);
};
let inner = &rest[start + 1..];
let Some(end) = inner.find('"') else {
return Ok(None);
};
Ok(Some(inner[..end].to_string()))
}
+159
View File
@@ -0,0 +1,159 @@
//! Git installer: clone the repository, run the build command(s), then
//! either copy the built binary or create a shim that runs the agent from
//! the repository directory.
use super::*;
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let url = method
.repo
.as_deref()
.ok_or_else(|| anyhow!("git install requires 'repo' (clone URL)"))?;
if !(url.starts_with("https://")
|| url.starts_with("http://")
|| url.starts_with("git@")
|| url.starts_with("file://"))
{
bail!("unsupported git URL: {url}");
}
require_tool(runner, "git", "https://git-scm.com")?;
let root = install_root(app, &agent.name);
let repo_dir = root.join("repo");
let bindir = bin_dir(app);
ensure_dir(app, &root)?;
ensure_dir(app, &bindir)?;
if app.dry_run() {
app.log
.dry(format!("would clone {url} into {} and build", repo_dir.display()));
return Ok(InstallOutcome::default());
}
if repo_dir.exists() {
std::fs::remove_dir_all(&repo_dir)
.with_context(|| format!("cannot remove {}", repo_dir.display()))?;
}
let mut env = method.env.clone();
env.insert("GIT_TERMINAL_PROMPT".to_string(), "0".to_string());
sh(
runner,
"git",
&[
"clone".to_string(),
"--depth".to_string(),
"1".to_string(),
url.to_string(),
repo_dir.display().to_string(),
],
&env,
None,
)?;
// Build commands (string or list).
if let Some(build) = &method.build {
for cmd in build.items() {
let tokens = shell_words::split(&cmd)
.map_err(|e| anyhow!("cannot parse build command '{cmd}': {e}"))?;
if tokens.is_empty() {
continue;
}
let prog = tokens[0].clone();
let args: Vec<String> = tokens[1..].to_vec();
let mut benv = BTreeMap::new();
let nm = repo_dir.join("node_modules").join(".bin");
if nm.is_dir() {
if let Ok(path) = std::env::var("PATH") {
benv.insert(
"PATH".to_string(),
format!("{}{}{}", nm.display(), std::path::MAIN_SEPARATOR, path),
);
}
}
sh(runner, &prog, &args, &benv, Some(&repo_dir))?;
}
}
// Install the binary (copy) or a shim that runs from the repo.
let names = expected_bin_names(agent, method);
let bin_name = names
.first()
.cloned()
.unwrap_or_else(|| agent.name.clone());
let mut bins = Vec::new();
if let Some(bp) = &method.binary_path {
let src = repo_dir.join(bp);
if !src.exists() {
bail!(
"built binary not found at {} (build succeeded but 'binary_path' may be wrong)",
src.display()
);
}
let dest = bindir.join(exe_name(&bin_name));
std::fs::copy(&src, &dest)
.with_context(|| format!("cannot copy {} to {}", src.display(), dest.display()))?;
bins.push(dest);
} else {
let shim = bindir.join(exe_name(&bin_name));
write_shim(agent, &shim, &repo_dir)?;
bins.push(shim);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for b in &bins {
let _ = std::fs::set_permissions(b, std::fs::Permissions::from_mode(0o755));
}
}
// Version: git describe --tags --always inside the repo.
let empty = BTreeMap::new();
let version = runner
.run(
"git",
&[
"-C".to_string(),
repo_dir.display().to_string(),
"describe".to_string(),
"--tags".to_string(),
"--always".to_string(),
],
&empty,
None,
true,
)
.ok()
.filter(|o| o.code == 0)
.map(|o| o.stdout.trim().to_string())
.filter(|s| !s.is_empty());
Ok(InstallOutcome { bins, version })
}
/// Create a small script that runs the agent from inside the repository
/// directory (with node_modules/.bin on PATH for Node-based projects).
fn write_shim(agent: &AgentDef, shim: &Path, repo_dir: &Path) -> Result<()> {
let run = agent.run.clone().unwrap_or_default();
if run.is_empty() {
bail!("git install without 'binary_path' needs a 'run' command for the shim");
}
let script = if cfg!(windows) {
let nm = repo_dir.join("node_modules").join(".bin");
format!(
"@echo off\r\ncd /d \"{repo}\"\r\nset \"PATH={nm};%PATH%\"\r\n{run} %*\r\n",
repo = repo_dir.display(),
nm = nm.display()
)
} else {
let nm = repo_dir.join("node_modules").join(".bin");
format!(
"#!/bin/sh\ncd \"{repo}\"\n[ -d \"{nm}\" ] && PATH=\"{nm}:$PATH\"\nexec {run} \"$@\"\n",
repo = repo_dir.display(),
nm = nm.display()
)
};
std::fs::write(shim, script).with_context(|| format!("cannot write shim {}", shim.display()))?;
Ok(())
}
+32
View File
@@ -0,0 +1,32 @@
//! Go installer: go install <module>@latest into our own GOBIN.
use super::*;
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let pkg = method
.package
.as_deref()
.ok_or_else(|| anyhow!("go install requires 'package' (module path)"))?;
require_tool(runner, "go", "https://go.dev/dl")?;
let bindir = bin_dir(app);
ensure_dir(app, &bindir)?;
// go install needs "@version"; default to @latest.
let target = if pkg.contains('@') {
pkg.to_string()
} else {
format!("{pkg}@latest")
};
let mut env = method.env.clone();
env.insert("GOBIN".to_string(), bindir.display().to_string());
let args = vec!["install".to_string(), target];
sh(runner, "go", &args, &env, None)?;
let expected = expected_bin_names(agent, method);
let bins = find_bins(&bindir, &expected);
let version = bins.first().and_then(|b| detect_version(runner, b));
Ok(InstallOutcome { bins, version })
}
+296
View File
@@ -0,0 +1,296 @@
//! Installer implementations: one module per install method.
//!
//! All installers share the same helpers (command execution through a
//! Runner, bin-dir snapshots, version detection) so they behave identically
//! under --dry-run and in tests.
pub mod binary;
pub mod cargo;
pub mod git;
pub mod golang;
pub mod npm;
pub mod pipuv;
pub mod script;
use crate::app::App;
use crate::config::{AgentDef, InstallType, MethodSpec};
use crate::runner::{RunOutput, Runner};
use crate::state::InstalledEntry;
use anyhow::{anyhow, bail, Context, Result};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Platform executable suffix ("" on Unix, ".exe" on Windows).
pub const EXE_SUFFIX: &str = if cfg!(windows) { ".exe" } else { "" };
#[derive(Debug, Clone, Default)]
pub struct InstallOutcome {
/// Executables (or shims) that belong to this installation.
pub bins: Vec<PathBuf>,
/// Detected version (when possible).
pub version: Option<String>,
}
/// Execute the right installer for the method type.
pub fn run_install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
match method.kind {
InstallType::Npm => npm::install(app, agent, method, runner),
InstallType::Bun => npm::bun_install(app, agent, method, runner),
InstallType::Pip => pipuv::install(app, agent, method, runner, false),
InstallType::Uv => pipuv::install(app, agent, method, runner, true),
InstallType::Cargo => cargo::install(app, agent, method, runner),
InstallType::Go => golang::install(app, agent, method, runner),
InstallType::Curl => script::install(app, agent, method, runner),
InstallType::Binary => binary::install(app, agent, method, runner),
InstallType::Git => git::install(app, agent, method, runner),
}
}
/// Best-effort "latest available version" for the method type.
pub fn latest_version(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<Option<String>> {
match method.kind {
InstallType::Npm | InstallType::Bun => npm::latest(app, agent, method, runner),
InstallType::Pip | InstallType::Uv => pipuv::latest(app, agent, method, runner),
InstallType::Cargo => cargo::latest(app, agent, method, runner),
InstallType::Binary => binary::latest(app, agent, method, runner),
_ => Ok(None),
}
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/// Root directory for one agent's installation.
pub fn install_root(app: &App, agent_name: &str) -> PathBuf {
app.paths.install_dir.join(agent_name)
}
/// Global bin directory (shims/executables, added to PATH).
pub fn bin_dir(app: &App) -> PathBuf {
app.paths.bin_dir.clone()
}
/// Create a directory unless running in dry-run mode.
pub fn ensure_dir(app: &App, path: &Path) -> Result<()> {
if !app.dry_run() {
std::fs::create_dir_all(path)
.with_context(|| format!("cannot create {}", path.display()))?;
}
Ok(())
}
/// Run a captured command, failing on a non-zero exit code.
pub fn sh(
runner: &dyn Runner,
cmd: &str,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
) -> Result<RunOutput> {
let out = runner.run(cmd, args, env, cwd, true)?;
if out.code != 0 {
let mut detail = out.stderr.trim().to_string();
if detail.is_empty() {
detail = out.stdout.trim().to_string();
}
bail!(
"command failed (exit code {}): {} {}
{}",
out.code,
cmd,
args.join(" "),
detail
);
}
Ok(out)
}
/// Run a command with inherited stdio, failing on a non-zero exit code.
pub fn sh_inherit(
runner: &dyn Runner,
cmd: &str,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
) -> Result<()> {
let out = runner.run(cmd, args, env, cwd, false)?;
if out.code != 0 {
bail!(
"command failed (exit code {}): {} {}",
out.code,
cmd,
args.join(" ")
);
}
Ok(())
}
/// Require a tool on the PATH, with an actionable error message.
pub fn require_tool(runner: &dyn Runner, tool: &str, hint: &str) -> Result<PathBuf> {
runner
.which(tool)
.ok_or_else(|| anyhow!("'{tool}' not found on PATH — install it first ({hint})"))
}
/// Detect a binary's version via --version, then -V.
pub fn detect_version(runner: &dyn Runner, bin: &Path) -> Option<String> {
let empty = BTreeMap::new();
if let Ok(out) = runner.run(&bin.display().to_string(), &["--version".to_string()], &empty, None, true)
{
if out.code == 0 {
let combined = format!("{}
{}", out.stdout, out.stderr);
if let Some(v) = crate::version::find_version(&combined) {
return Some(v);
}
}
}
if let Ok(out) = runner.run(&bin.display().to_string(), &["-V".to_string()], &empty, None, true) {
if out.code == 0 {
let combined = format!("{}
{}", out.stdout, out.stderr);
return crate::version::find_version(&combined);
}
}
None
}
/// Executable name with the platform suffix.
pub fn exe_name(name: &str) -> String {
format!("{name}{EXE_SUFFIX}")
}
/// Names the installed binaries should match: the binary hint, the run
/// command's first token, and the agent name.
pub fn expected_bin_names(agent: &AgentDef, method: &MethodSpec) -> Vec<String> {
let mut names = Vec::new();
if let Some(b) = &method.binary {
names.push(b.clone());
}
if let Some(t) = agent.first_token() {
if !names.iter().any(|n| n.eq_ignore_ascii_case(&t)) {
names.push(t);
}
}
if !names.iter().any(|n| n == &agent.name) {
names.push(agent.name.clone());
}
names
}
/// Snapshot a directory: file name -> full path.
pub fn snapshot_dir(dir: &Path) -> BTreeMap<String, PathBuf> {
let mut map = BTreeMap::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for entry in rd.flatten() {
let p = entry.path();
if p.is_file() {
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
map.insert(name.to_string(), p);
}
}
}
}
map
}
/// Entries present in "after" but not in "before".
pub fn diff_dir(
before: &BTreeMap<String, PathBuf>,
after: &BTreeMap<String, PathBuf>,
) -> Vec<PathBuf> {
after
.iter()
.filter(|(k, _)| !before.contains_key(*k))
.map(|(_, v)| v.clone())
.collect()
}
/// Files in dir whose base name (sans extension) matches an expected name.
/// On Windows, .cmd/.bat shims are preferred over bare scripts.
pub fn find_bins(dir: &Path, expected: &[String]) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(dir) else {
return out;
};
let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for p in entries {
if !p.is_file() {
continue;
}
let name = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let stripped = name
.strip_suffix(".exe")
.or_else(|| name.strip_suffix(".cmd"))
.or_else(|| name.strip_suffix(".bat"))
.or_else(|| name.strip_suffix(".ps1"))
.unwrap_or(&name)
.to_string();
if expected.iter().any(|e| e.eq_ignore_ascii_case(&stripped)) {
out.push(p);
}
}
// Prefer a launchable shim for this platform.
out.sort_by_key(|p| {
let name = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
if cfg!(windows) {
if name.ends_with(".cmd") || name.ends_with(".bat") {
0u8
} else if name.ends_with(".exe") {
1
} else {
2
}
} else if name.ends_with(".cmd") || name.ends_with(".exe") || name.ends_with(".ps1") {
2
} else {
0
}
});
out
}
pub fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
/// Build the state entry recorded after a successful install.
pub fn make_entry(
agent: &AgentDef,
method: &MethodSpec,
outcome: &InstallOutcome,
app: &App,
) -> InstalledEntry {
InstalledEntry {
name: agent.name.clone(),
version: outcome.version.clone().or_else(|| agent.version.clone()),
method: method.kind.as_str().to_string(),
install_dir: install_root(app, &agent.name).display().to_string(),
bins: outcome.bins.iter().map(|b| b.display().to_string()).collect(),
run: agent.run.clone().unwrap_or_default(),
installed_at: now_rfc3339(),
updated_at: None,
pid: None,
started_at: None,
}
}
+115
View File
@@ -0,0 +1,115 @@
//! npm and bun installers: install a package globally inside our own
//! prefix so nothing pollutes the system.
use super::*;
/// npm: npm install -g --prefix <root> <package>.
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let pkg = method
.package
.as_deref()
.ok_or_else(|| anyhow!("npm install requires 'package'"))?;
require_tool(runner, "npm", "https://nodejs.org")?;
let root = install_root(app, &agent.name);
let bindir = bin_dir(app);
let before = snapshot_dir(&bindir);
ensure_dir(app, &root)?;
ensure_dir(app, &bindir)?;
let mut args: Vec<String> = vec![
"install".into(),
"-g".into(),
"--prefix".into(),
root.display().to_string(),
pkg.to_string(),
];
args.extend(method.args.iter().cloned());
sh(runner, "npm", &args, &method.env, None)?;
// npm places the shims directly in <prefix> on Windows and in
// <prefix>/bin on Unix — check both, then the global bin dir.
let expected = expected_bin_names(agent, method);
let mut bins = find_bins(&root, &expected);
if bins.is_empty() {
bins = find_bins(&root.join("bin"), &expected);
}
if bins.is_empty() {
let after = snapshot_dir(&bindir);
bins = diff_dir(&before, &after);
}
if bins.is_empty() {
bins = find_bins(&bindir, &expected);
}
let version = bins.first().and_then(|b| detect_version(runner, b));
Ok(InstallOutcome { bins, version })
}
/// bun: bun install -g <package> with BUN_INSTALL pointed at our root.
pub fn bun_install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let pkg = method
.package
.as_deref()
.ok_or_else(|| anyhow!("bun install requires 'package'"))?;
require_tool(runner, "bun", "https://bun.sh")?;
let root = install_root(app, &agent.name);
let bindir = bin_dir(app);
let before = snapshot_dir(&bindir);
ensure_dir(app, &root)?;
ensure_dir(app, &bindir)?;
let mut env = method.env.clone();
env.insert("BUN_INSTALL".to_string(), root.display().to_string());
let mut args: Vec<String> = vec!["install".into(), "-g".into(), pkg.to_string()];
args.extend(method.args.iter().cloned());
sh(runner, "bun", &args, &env, None)?;
let expected = expected_bin_names(agent, method);
let mut bins = find_bins(&root.join("bin"), &expected);
if bins.is_empty() {
bins = find_bins(&bindir, &expected);
}
if bins.is_empty() {
let after = snapshot_dir(&bindir);
bins = diff_dir(&before, &after);
}
let version = bins.first().and_then(|b| detect_version(runner, b));
Ok(InstallOutcome { bins, version })
}
/// Latest published version: npm view <package> version.
pub fn latest(
_app: &App,
_agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<Option<String>> {
let Some(pkg) = method.package.as_deref() else {
return Ok(None);
};
if runner.which("npm").is_none() {
return Ok(None);
}
let empty = BTreeMap::new();
let out = runner.run(
"npm",
&["view".to_string(), pkg.to_string(), "version".to_string()],
&empty,
None,
true,
)?;
if out.code != 0 {
return Ok(None);
}
let text = out.stdout.trim();
if text.is_empty() {
return Ok(None);
}
// npm view prints one version per line; take the last (latest).
Ok(text.lines().last().map(|l| l.trim().to_string()))
}
+130
View File
@@ -0,0 +1,130 @@
//! pip and uv installers: a private virtualenv per agent keeps everything
//! self-contained (no --user, no system pollution).
use super::*;
fn find_python(runner: &dyn Runner) -> Result<PathBuf> {
runner
.which("python3")
.or_else(|| runner.which("python"))
.ok_or_else(|| {
anyhow!(
"neither 'python3' nor 'python' found on PATH — install Python first (https://python.org)"
)
})
}
fn venv_bin_dir(venv: &Path) -> PathBuf {
if cfg!(windows) {
venv.join("Scripts")
} else {
venv.join("bin")
}
}
fn venv_python(venv: &Path) -> PathBuf {
venv_bin_dir(venv).join(if cfg!(windows) {
"python.exe"
} else {
"python"
})
}
/// Shared implementation for pip (use_uv=false) and uv (use_uv=true).
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
use_uv: bool,
) -> Result<InstallOutcome> {
let pkg = method
.package
.as_deref()
.ok_or_else(|| anyhow!("pip/uv install requires 'package'"))?;
let python = find_python(runner)?;
let root = install_root(app, &agent.name);
let venv = root.join("venv");
ensure_dir(app, &root)?;
if !app.dry_run() && venv.exists() {
std::fs::remove_dir_all(&venv)
.with_context(|| format!("cannot remove {}", venv.display()))?;
}
let empty = BTreeMap::new();
sh(
runner,
&python.display().to_string(),
&[
"-m".to_string(),
"venv".to_string(),
venv.display().to_string(),
],
&empty,
None,
)?;
let vp = venv_python(&venv);
let vbin = venv_bin_dir(&venv);
if use_uv {
let uv = require_tool(
runner,
"uv",
"https://docs.astral.sh/uv — e.g. 'pip install uv'",
)?;
let mut args: Vec<String> = vec![
"pip".into(),
"install".into(),
"--python".into(),
vp.display().to_string(),
pkg.to_string(),
];
args.extend(method.args.iter().cloned());
sh(runner, &uv.display().to_string(), &args, &method.env, None)?;
} else {
let mut args: Vec<String> = vec![
"-m".into(),
"pip".into(),
"install".into(),
"--upgrade".into(),
pkg.to_string(),
];
args.extend(method.args.iter().cloned());
sh(runner, &vp.display().to_string(), &args, &method.env, None)?;
}
let expected = expected_bin_names(agent, method);
let mut bins = find_bins(&vbin, &expected);
if bins.is_empty() {
bins = find_bins(&bin_dir(app), &expected);
}
let version = bins.first().and_then(|b| detect_version(runner, b));
Ok(InstallOutcome { bins, version })
}
/// Latest published version from the PyPI JSON API.
pub fn latest(
_app: &App,
_agent: &AgentDef,
method: &MethodSpec,
_runner: &dyn Runner,
) -> Result<Option<String>> {
let Some(pkg) = method.package.as_deref() else {
return Ok(None);
};
let url = format!("https://pypi.org/pypi/{pkg}/json");
let resp = match ureq::get(&url)
.set("User-Agent", crate::download::USER_AGENT)
.call()
{
Ok(r) => r,
Err(_) => return Ok(None),
};
let Ok(text) = resp.into_string() else {
return Ok(None);
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
return Ok(None);
};
Ok(v.get("info")
.and_then(|i| i.get("version"))
.and_then(|x| x.as_str())
.map(|s| s.to_string()))
}
+68
View File
@@ -0,0 +1,68 @@
//! curl|sh installer: download the script, show it (verbose), confirm, then
//! execute it in its own directory. Never pipes curl directly into a shell.
use super::*;
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let url = method
.script
.as_deref()
.ok_or_else(|| anyhow!("curl install requires 'script' (URL)"))?;
crate::download::validate_url(url)?;
if cfg!(windows) {
bail!(
"the curl install method needs a Unix shell; on Windows install '{}' manually or use WSL",
agent.title()
);
}
require_tool(runner, "sh", "a POSIX shell (sh)")?;
let root = install_root(app, &agent.name);
ensure_dir(app, &root)?;
if app.dry_run() {
app.log
.dry(format!("would download and run install script {url}"));
return Ok(InstallOutcome::default());
}
let tmp = tempfile::Builder::new().suffix(".sh").tempfile()?;
crate::download::download(url, tmp.path(), None, true)?;
if app.log.verbose {
if let Ok(text) = std::fs::read_to_string(tmp.path()) {
eprintln!("--- install script ({url}) ---");
eprintln!("{text}");
eprintln!("--- end of script ---");
}
}
let shell = app
.config
.settings
.default_shell
.clone()
.unwrap_or_else(|| "sh".to_string());
sh_inherit(
runner,
&shell,
&[tmp.path().display().to_string()],
&method.env,
Some(&root),
)?;
// Scripts usually install into the user profile; locate the binaries.
let expected = expected_bin_names(agent, method);
let mut bins = Vec::new();
for name in &expected {
if let Some(p) = runner.which(name) {
if !bins.contains(&p) {
bins.push(p);
}
}
}
let version = bins
.first()
.and_then(|b| detect_version(runner, b))
.or_else(|| agent.version.clone());
Ok(InstallOutcome { bins, version })
}
+62
View File
@@ -0,0 +1,62 @@
//! agent-manager — a CLI to manage local AI coding agents.
//!
//! The crate is organised around a few core concepts:
//!
//! * cli — clap definitions for the am command line interface.
//! * config — YAML schema, loading, validation, includes, path expansion.
//! * catalog — the merged agent catalog (embedded defaults + user config).
//! * state — the local installation database (JSON file).
//! * process — background/foreground process management (PID bookkeeping).
//! * installers — one installer per method (npm, pip, cargo, go, bun, curl,
//! binary, git).
//! * commands — one module per CLI command.
pub mod app;
pub mod catalog;
pub mod cli;
pub mod commands;
pub mod config;
pub mod deps;
pub mod doctor;
pub mod download;
pub mod installers;
pub mod output;
pub mod probe;
pub mod process;
pub mod repl;
pub mod runner;
pub mod state;
pub mod version;
use clap::Parser;
/// CLI entry point used by the am binary. Returns the process exit code.
pub fn main_entry() -> i32 {
let cli = cli::Cli::parse();
match app::App::from_cli(cli) {
Ok(app) => match commands::execute(&app) {
Ok(code) => code,
Err(err) => {
if app.json() {
let payload = serde_json::json!({
"error": format!("{:#}", err),
});
let _ = serde_json::to_writer_pretty(std::io::stdout(), &payload);
let _ = writeln!(std::io::stdout());
} else {
eprintln!("{} {}", "error:".red().bold(), format!("{:#}", err));
}
1
}
},
Err(err) => {
// Config could not even be loaded: fall back to plain stderr output.
eprintln!("{} {}", "error:".red().bold(), format!("{:#}", err));
1
}
}
}
#[allow(unused_imports)]
use colored::Colorize;
use std::io::Write;
+8
View File
@@ -0,0 +1,8 @@
//! Binary entry point for the am (agent-manager) CLI.
//!
//! All the logic lives in the agent_manager library crate so that
//! integration tests can exercise it directly.
fn main() {
std::process::exit(agent_manager::main_entry());
}
+273
View File
@@ -0,0 +1,273 @@
//! Console output helpers: colored logging, plain text tables, JSON output.
use colored::Colorize;
use serde::Serialize;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
/// Name of the global log file inside the log directory.
pub const LOG_FILE: &str = "agent-manager.log";
// ---------------------------------------------------------------------------
// Logger
// ---------------------------------------------------------------------------
/// Structured logger: writes every message to the timestamped log file and
/// echoes the important ones to stderr (stdout stays reserved for data).
#[derive(Debug)]
pub struct Logger {
log_dir: PathBuf,
pub verbose: bool,
pub quiet: bool,
pub no_color: bool,
}
impl Logger {
pub fn new(log_dir: PathBuf, verbose: bool, quiet: bool, no_color: bool) -> Self {
Self {
log_dir,
verbose,
quiet,
no_color,
}
}
pub fn log_dir(&self) -> &PathBuf {
&self.log_dir
}
pub fn color_enabled(&self) -> bool {
!self.no_color && std::io::stderr().is_terminal()
}
fn write_log(&self, line: &str) {
if std::fs::create_dir_all(&self.log_dir).is_err() {
return;
}
let path = self.log_dir.join(LOG_FILE);
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let _ = writeln!(f, "[{ts}] {line}");
}
}
/// Detailed message, shown only in verbose mode (always written to the log).
pub fn verbose<S: AsRef<str>>(&self, msg: S) {
let msg = msg.as_ref();
self.write_log(msg);
if self.verbose && !self.quiet {
if self.color_enabled() {
eprintln!("{}", msg.dimmed());
} else {
eprintln!("{msg}");
}
}
}
/// Normal informational message.
pub fn info<S: AsRef<str>>(&self, msg: S) {
let msg = msg.as_ref();
self.write_log(msg);
if !self.quiet {
eprintln!("{msg}");
}
}
/// Success message (green).
pub fn success<S: AsRef<str>>(&self, msg: S) {
let msg = msg.as_ref();
self.write_log(msg);
if !self.quiet {
if self.color_enabled() {
eprintln!("{}", msg.green());
} else {
eprintln!("{msg}");
}
}
}
/// Warning message (yellow).
pub fn warn<S: AsRef<str>>(&self, msg: S) {
let msg = msg.as_ref();
self.write_log(msg);
if !self.quiet {
if self.color_enabled() {
eprintln!("{}", msg.yellow());
} else {
eprintln!("{msg}");
}
}
}
/// Error message (red); always shown, even in quiet mode.
pub fn error<S: AsRef<str>>(&self, msg: S) {
let msg = msg.as_ref();
self.write_log(msg);
if self.color_enabled() {
eprintln!("{}", msg.red());
} else {
eprintln!("{msg}");
}
}
/// Dry-run note (yellow prefix).
pub fn dry<S: AsRef<str>>(&self, msg: S) {
let msg = msg.as_ref();
self.write_log(msg);
if !self.quiet {
if self.color_enabled() {
eprintln!("{} {msg}", "[dry-run]".yellow().bold());
} else {
eprintln!("[dry-run] {msg}");
}
}
}
/// Record an executed command (log file; echoed when verbose).
pub fn cmd(&self, display: &str) {
self.write_log(&format!("exec: {display}"));
if self.verbose && !self.quiet {
eprintln!(" {}", display.dimmed());
}
}
/// Record a command result.
pub fn result(&self, code: i32, stderr: &str) {
if code != 0 {
self.write_log(&format!("exit code {code}; stderr: {}", stderr.trim()));
}
}
}
// ---------------------------------------------------------------------------
// Tables
// ---------------------------------------------------------------------------
/// A minimal plain-text table. Cells must be pre-colored (widths are computed
/// on ANSI-stripped text).
pub struct Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
}
const MAX_CELL: usize = 48;
impl Table {
pub fn new(headers: Vec<&str>) -> Self {
Self {
headers: headers.into_iter().map(|h| h.to_string()).collect(),
rows: Vec::new(),
}
}
pub fn row(&mut self, cells: Vec<String>) -> &mut Self {
self.rows.push(cells);
self
}
pub fn render(&self) -> String {
if self.headers.is_empty() {
return String::new();
}
let cols = self.headers.len();
let mut widths: Vec<usize> = self
.headers
.iter()
.map(|h| visible_len(h).min(MAX_CELL))
.collect();
for row in &self.rows {
for (i, cell) in row.iter().enumerate().take(cols) {
widths[i] = widths[i].max(visible_len(cell).min(MAX_CELL));
}
}
let mut out = String::new();
let mut header_line = String::new();
for (i, h) in self.headers.iter().enumerate() {
if i > 0 {
header_line.push_str(" ");
}
header_line.push_str(&pad(h, widths[i]));
}
let header_line = header_line.trim_end().to_string();
out.push_str(&format!("{}
", header_line.bold()));
out.push_str(&"-".repeat(visible_len(&header_line)));
out.push('\n');
for row in &self.rows {
let mut l = String::new();
for i in 0..cols {
if i > 0 {
l.push_str(" ");
}
let cell = row.get(i).cloned().unwrap_or_default();
l.push_str(&pad(&cell, widths[i]));
}
out.push_str(l.trim_end());
out.push('\n');
}
out
}
}
/// Color a status cell for display in tables.
pub fn status_style(status: &str, color: bool) -> String {
let s = status.to_string();
if !color {
return s;
}
match status {
"running" => s.green().bold().to_string(),
"installed" => s.cyan().to_string(),
"external" => s.yellow().to_string(),
"not-installable" => s.red().to_string(),
"not-installed" => s.dimmed().to_string(),
"ok" => s.green().to_string(),
"warn" => s.yellow().to_string(),
"error" => s.red().bold().to_string(),
_ => s,
}
}
/// Print a value as pretty JSON on stdout (for scripts).
pub fn print_json<T: Serialize>(value: &T) {
if serde_json::to_writer_pretty(std::io::stdout(), value).is_ok() {
println!();
}
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '' {
for n in chars.by_ref() {
if n == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn visible_len(s: &str) -> usize {
strip_ansi(s).chars().count()
}
fn pad(s: &str, width: usize) -> String {
let n = visible_len(s);
if n > width {
let plain = strip_ansi(s);
let mut cut: String = plain.chars().take(width.saturating_sub(1)).collect();
cut.push('…');
cut
} else {
format!("{s}{}", " ".repeat(width - n))
}
}
+325
View File
@@ -0,0 +1,325 @@
//! External agent detection: PATH lookup for unmanaged agents, version
//! probing in parallel (one thread per binary), and an on-disk cache keyed
//! by the PATH string and by binary identity (path + mtime + size), so
//! repeated runs never re-scan the PATH or re-probe versions.
use crate::app::App;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use wait_timeout::ChildExt;
#[derive(Debug, Default)]
pub struct ExternalInfo {
/// agent name -> binary path (catalog agents found on PATH, unmanaged).
pub paths: BTreeMap<String, PathBuf>,
/// agent name -> detected version (None = unknown).
pub versions: BTreeMap<String, Option<String>>,
}
impl ExternalInfo {
pub fn path_of(&self, name: &str) -> Option<&PathBuf> {
self.paths.get(name)
}
pub fn version_of(&self, name: &str) -> Option<&Option<String>> {
self.versions.get(name)
}
}
/// Detect the version of an external binary (--version), bounded by a
/// timeout so a misbehaving agent can never hang the whole listing.
pub fn detect_external_version(bin_path: &Path) -> Option<String> {
use std::io::Read;
let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string());
let mut cmd = std::process::Command::new(&prog);
cmd.args(&prefix)
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let Ok(mut child) = cmd.spawn() else {
return None;
};
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
let status: Option<std::process::ExitStatus> = match child.wait_timeout(PROBE_TIMEOUT) {
Ok(Some(s)) => Some(s),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
None
}
Err(_) => None,
};
let Some(status) = status else {
return None;
};
if !status.success() {
return None;
}
let mut stdout = String::new();
let mut stderr = String::new();
if let Some(mut o) = child.stdout.take() {
let _ = o.read_to_string(&mut stdout);
}
if let Some(mut e) = child.stderr.take() {
let _ = e.read_to_string(&mut stderr);
}
let combined = format!("{stdout}
{stderr}");
crate::version::find_version(&combined)
}
// ---------------------------------------------------------------------------
// On-disk cache
// ---------------------------------------------------------------------------
#[derive(Debug, Default, Serialize, Deserialize)]
struct ProbeCache {
/// FNV-1a hash of the PATH string the token index was built from.
#[serde(default)]
path_hash: u64,
/// Catalog run-command first token -> resolved path (null = not found).
#[serde(default)]
tokens: BTreeMap<String, Option<String>>,
/// Binary identity key -> detected version.
#[serde(default)]
versions: BTreeMap<String, Option<String>>,
}
fn path_hash() -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
if let Some(p) = std::env::var_os("PATH") {
for b in p.to_string_lossy().bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
h
}
/// Build an index of every executable on the PATH in a single pass.
/// Much cheaper than one which() call per agent when the catalog is large.
fn build_path_index() -> BTreeMap<String, PathBuf> {
let mut map: BTreeMap<String, PathBuf> = BTreeMap::new();
let Some(path) = std::env::var_os("PATH") else {
return map;
};
for dir in std::env::split_paths(&path) {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let p = entry.path();
if !p.is_file() {
continue;
}
let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
continue;
};
let lower = name.to_lowercase();
#[cfg(windows)]
{
let stem = if lower.ends_with(".exe") {
lower[..lower.len() - 4].to_string()
} else if lower.ends_with(".cmd") {
lower[..lower.len() - 4].to_string()
} else if lower.ends_with(".bat") {
lower[..lower.len() - 4].to_string()
} else if lower.ends_with(".com") {
lower[..lower.len() - 4].to_string()
} else {
continue;
};
map.entry(stem).or_insert(p);
}
#[cfg(not(windows))]
{
use std::os::unix::fs::PermissionsExt;
let executable = std::fs::metadata(&p)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false);
if executable {
map.entry(lower).or_insert(p);
}
}
}
}
map
}
/// Detect every unmanaged catalog agent present on the PATH.
///
/// The PATH is scanned once and its result cached (invalidated by the PATH
/// string hash); version probes run in parallel and are cached by binary
/// identity. A steady-state run does no PATH scanning and no subprocess.
pub fn detect_externals(app: &App) -> ExternalInfo {
let managed: BTreeMap<String, crate::state::InstalledEntry> =
app.state.all().unwrap_or_default();
let cache_path = app.paths.probe_cache_file.clone();
let mut cache: ProbeCache = std::fs::read_to_string(&cache_path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default();
let mut dirty = false;
// Resolve the run-command tokens of the catalog (once per PATH change).
let current_hash = path_hash();
if cache.path_hash != current_hash {
let index = build_path_index();
cache.tokens.clear();
for agent in app.catalog.agents() {
let Some(token) = agent.first_token() else {
continue;
};
if !cache.tokens.contains_key(&token) {
let found = index
.get(&token.to_lowercase())
.map(|p| p.display().to_string());
cache.tokens.insert(token, found);
}
}
cache.path_hash = current_hash;
dirty = true;
}
let mut info = ExternalInfo::default();
// Stale cached paths (binary removed since the cache was built) force a
// rebuild of the token index.
let stale = cache
.tokens
.values()
.flatten()
.any(|p| !Path::new(p).exists());
if stale {
let index = build_path_index();
cache.tokens.clear();
for agent in app.catalog.agents() {
let Some(token) = agent.first_token() else {
continue;
};
if !cache.tokens.contains_key(&token) {
let found = index
.get(&token.to_lowercase())
.map(|p| p.display().to_string());
cache.tokens.insert(token, found);
}
}
dirty = true;
}
for agent in app.catalog.agents() {
if agent.hidden || !agent.installable || managed.contains_key(&agent.name) {
continue;
}
let Some(token) = agent.first_token() else {
continue;
};
if let Some(Some(path)) = cache.tokens.get(&token) {
info.paths.insert(agent.name.clone(), PathBuf::from(path));
}
}
// Versions: on-disk cache first, then parallel probes for the misses.
let mut missing: Vec<(String, PathBuf)> = Vec::new();
for (name, bin) in &info.paths {
let key = bin_key(bin);
match cache.versions.get(&key) {
Some(v) => {
info.versions.insert(name.clone(), v.clone());
}
None => missing.push((name.clone(), bin.clone())),
}
}
if !missing.is_empty() {
let results = std::sync::Mutex::new(Vec::new());
std::thread::scope(|s| {
for (name, bin) in &missing {
let results = &results;
let name = name.clone();
let bin = bin.clone();
s.spawn(move || {
let version = detect_external_version(&bin);
results.lock().unwrap().push((name, bin, version));
});
}
});
for (name, bin, version) in results.into_inner().unwrap_or_default() {
let key = bin_key(&bin);
cache.versions.insert(key.clone(), version.clone());
info.versions.insert(name, version);
dirty = true;
}
}
// Keep the caches bounded.
if cache.versions.len() > 500 {
cache.versions.clear();
dirty = true;
}
if dirty {
if let Ok(text) = serde_json::to_string_pretty(&cache) {
if let Some(parent) = cache_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&cache_path, text);
}
}
info
}
/// Identity key for a binary: path + modification time + size, so a stale
/// cache entry is never reused after the binary changed.
fn bin_key(bin: &Path) -> String {
let meta = std::fs::metadata(bin).ok();
let mtime = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let size = meta.map(|m| m.len()).unwrap_or(0);
format!("{}|{mtime}|{size}", bin.display())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bin_key_is_stable_and_distinct() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.exe");
std::fs::write(&a, b"abc").unwrap();
let k1 = bin_key(&a);
let k2 = bin_key(&a);
assert_eq!(k1, k2);
std::fs::write(&a, b"abcd").unwrap();
let k3 = bin_key(&a);
assert_ne!(k1, k3);
}
#[test]
fn probe_cache_roundtrip() {
let mut cache = ProbeCache::default();
cache
.versions
.insert("C:/x|1|2".to_string(), Some("1.2.3".to_string()));
cache.tokens.insert("node".to_string(), None);
let text = serde_json::to_string(&cache).unwrap();
let back: ProbeCache = serde_json::from_str(&text).unwrap();
assert_eq!(
back.versions.get("C:/x|1|2").unwrap().as_deref(),
Some("1.2.3")
);
assert!(back.tokens.contains_key("node"));
}
#[test]
fn old_cache_format_is_ignored() {
// The previous cache format had only an "entries" field; loading it
// must not fail.
let text = r#"{ "entries": { "a": "1.0" } }"#;
let back: ProbeCache = serde_json::from_str(text).unwrap();
assert!(back.versions.is_empty());
assert!(back.tokens.is_empty());
}
}
+221
View File
@@ -0,0 +1,221 @@
//! Process management: detached background start, PID bookkeeping, stop
//! with a graceful signal followed by a forceful kill after a timeout.
use crate::app::App;
use anyhow::{bail, Context, Result};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Log file for a background agent.
pub fn agent_log_path(app: &App, agent_name: &str) -> PathBuf {
app.log.log_dir().join(format!("{agent_name}.log"))
}
/// Is a process with this PID alive?
pub fn is_running(pid: u32) -> bool {
#[cfg(windows)]
{
let out = Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout)
.split_whitespace()
.any(|w| w == pid.to_string()),
Err(_) => false,
}
}
#[cfg(not(windows))]
{
Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
}
/// Send SIGTERM/SIGKILL (or taskkill / taskkill /F on Windows).
fn signal(pid: u32, force: bool) -> bool {
#[cfg(windows)]
{
let mut cmd = Command::new("taskkill");
cmd.arg("/PID").arg(pid.to_string());
if force {
cmd.arg("/T").arg("/F");
}
cmd.status().map(|s| s.success()).unwrap_or(false)
}
#[cfg(not(windows))]
{
let sig = if force { "-KILL" } else { "-TERM" };
Command::new("kill")
.arg(sig)
.arg(pid.to_string())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
}
/// Stop a process: TERM first, then KILL after the timeout. Returns whether
/// the process was running when the call started.
pub fn stop_pid(pid: u32, force: bool, timeout_secs: u64) -> bool {
if !is_running(pid) {
return false;
}
if force {
signal(pid, true);
} else {
signal(pid, false);
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
while Instant::now() < deadline {
if !is_running(pid) {
return true;
}
std::thread::sleep(Duration::from_millis(200));
}
if is_running(pid) {
signal(pid, true);
}
}
for _ in 0..50 {
if !is_running(pid) {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
!is_running(pid)
}
/// Start a program detached: stdout/stderr are redirected to the agent log
/// file. Returns the PID. Fails when the process exits immediately.
pub fn spawn_background(
app: &App,
agent_name: &str,
program: &Path,
args: &[String],
env: &BTreeMap<String, String>,
) -> Result<u32> {
let log = agent_log_path(app, agent_name);
if app.dry_run() {
app.log.dry(format!(
"would start {} (detached, log: {})",
program.display(),
log.display()
));
return Ok(0);
}
if let Some(parent) = log.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::File::create(&log)
.with_context(|| format!("cannot create log file {}", log.display()))?;
let (prog, prefix) = crate::runner::resolve_program(&program.display().to_string());
let mut full_args = prefix;
full_args.extend(args.iter().cloned());
let mut cmd = Command::new(&prog);
cmd.args(&full_args)
.envs(env)
.stdout(Stdio::from(file.try_clone()?))
.stderr(Stdio::from(file));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
app.log
.cmd(&format!("{} {}", program.display(), args.join(" ")));
let mut child = cmd
.spawn()
.with_context(|| format!("failed to start {}", program.display()))?;
let pid = child.id();
// Give the process a moment: some agents crash at startup.
std::thread::sleep(Duration::from_millis(400));
match child.try_wait() {
Ok(Some(status)) => {
let tail = last_lines(&log, 8);
bail!(
"agent '{agent_name}' exited immediately after start (status {status}); log: {}
{}",
log.display(),
tail
);
}
Ok(None) => {}
Err(_) => {}
}
Ok(pid)
}
/// Tail of a text file (last n lines).
pub fn last_lines(path: &Path, n: usize) -> String {
let Ok(text) = std::fs::read_to_string(path) else {
return String::new();
};
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("
")
}
/// Best-effort desktop notification once an agent has started.
pub fn desktop_notify(title: &str, body: &str) {
#[cfg(target_os = "linux")]
{
let _ = Command::new("notify-send")
.args(["-a", "agent-manager", title, body])
.spawn();
}
#[cfg(target_os = "macos")]
{
let _ = Command::new("osascript")
.args([
"-e",
&format!("display notification \"{body}\" with title \"{title}\""),
])
.spawn();
}
#[cfg(windows)]
{
// No built-in non-blocking toast without extra tooling; log instead.
let _ = (title, body);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sleep_cmd() -> (&'static str, Vec<String>) {
if cfg!(windows) {
("ping", vec!["-n".to_string(), "30".to_string(), "127.0.0.1".to_string()])
} else {
("sleep", vec!["30".to_string()])
}
}
#[test]
fn pid_lifecycle() {
let (prog, args) = sleep_cmd();
let mut child = Command::new(prog).args(&args).spawn().unwrap();
let pid = child.id();
assert!(is_running(pid));
assert!(stop_pid(pid, true, 5));
assert!(!is_running(pid));
let _ = child.wait();
}
#[test]
fn stop_non_running_is_false() {
assert!(!stop_pid(u32::MAX - 1, true, 1));
}
}
+436
View File
@@ -0,0 +1,436 @@
//! Interactive shell launched when am is run without a subcommand.
//!
//! Uses rustyline for line editing: Tab completion (commands, agents, aliases,
//! groups, flags), command history persisted in the state directory, and
//! graceful fallback to plain line input when no terminal is available.
use crate::app::App;
use crate::cli::{Cli, Command, StartArgs};
use crate::commands;
use anyhow::{anyhow, Result};
use clap::CommandFactory;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Context, Helper};
use std::collections::BTreeMap;
use std::io::{IsTerminal, Write};
// ---------------------------------------------------------------------------
// Tab completion
// ---------------------------------------------------------------------------
/// Completion candidates for the interactive shell.
pub struct AmCompleter {
commands: Vec<&'static str>,
config_sub: Vec<&'static str>,
shells: Vec<&'static str>,
options: Vec<&'static str>,
agents: Vec<String>,
aliases: Vec<String>,
groups: Vec<String>,
}
impl AmCompleter {
pub fn new(app: &App) -> Self {
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups)
}
pub fn from_catalog(
catalog: &crate::catalog::Catalog,
aliases: &BTreeMap<String, String>,
groups: &BTreeMap<String, Vec<String>>,
) -> Self {
let mut agents: Vec<String> = catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.map(|a| a.name.clone())
.collect();
agents.sort();
let mut alias_list: Vec<String> = aliases.keys().cloned().collect();
alias_list.sort();
let mut group_list: Vec<String> = groups.keys().map(|g| format!("group:{g}")).collect();
group_list.sort();
Self {
commands: vec![
"list", "status", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion",
"help", "version", "exit",
],
config_sub: vec!["show", "path", "edit", "validate", "add"],
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
options: vec![
"--background", "--force", "--purge", "--method", "--all",
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
],
agents,
aliases: alias_list,
groups: group_list,
}
}
/// Pure completion logic: given the line up to the cursor, returns the
/// replacement start position and the matching candidate strings.
pub fn candidates_for(&self, line: &str, pos: usize) -> (usize, Vec<String>) {
let before = &line[..pos.min(line.len())];
let start = before
.rfind(char::is_whitespace)
.map(|i| i + 1)
.unwrap_or(0);
let word = &before[start..];
let previous: Vec<&str> = before[..start].split_whitespace().collect();
let mut pool: Vec<String> = Vec::new();
if previous.is_empty() {
// Completing the command name itself.
pool = self.commands.iter().map(|s| s.to_string()).collect();
} else {
let first = previous[0];
match first {
"config" => {
pool = self.config_sub.iter().map(|s| s.to_string()).collect();
}
"completion" => {
pool = self.shells.iter().map(|s| s.to_string()).collect();
}
"install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run"
| "info" | "status" => {
if word.starts_with("group:") {
pool = self.groups.clone();
} else if previous.len() == 1 {
pool.extend(self.agents.iter().cloned());
pool.extend(self.aliases.iter().cloned());
pool.extend(self.groups.iter().cloned());
} else if word.starts_with('-') {
pool = self.options.iter().map(|s| s.to_string()).collect();
} else {
// Extra arguments: only offer flags.
pool = self.options.iter().map(|s| s.to_string()).collect();
}
}
_ => {
if word.starts_with('-') {
pool = self.options.iter().map(|s| s.to_string()).collect();
}
}
}
}
let mut cands: Vec<String> = pool
.iter()
.filter(|c| c.starts_with(word))
.cloned()
.collect();
if cands.is_empty() && !word.is_empty() {
let lower = word.to_lowercase();
cands = pool
.into_iter()
.filter(|c| c.to_lowercase().starts_with(&lower))
.collect();
} else if word.is_empty() {
cands = pool;
}
cands.sort();
cands.dedup();
(start, cands)
}
}
impl Completer for AmCompleter {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let (start, cands) = self.candidates_for(line, pos);
let pairs = cands
.into_iter()
.map(|c| Pair {
display: c.clone(),
replacement: c,
})
.collect();
Ok((start, pairs))
}
}
impl Hinter for AmCompleter {
type Hint = String;
fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> {
None
}
}
impl Highlighter for AmCompleter {}
impl Validator for AmCompleter {}
impl Helper for AmCompleter {}
// ---------------------------------------------------------------------------
// Interactive loop
// ---------------------------------------------------------------------------
pub fn run(app: &App) -> Result<i32> {
if !std::io::stdin().is_terminal() {
// Piped stdin: print the help instead of hanging.
let mut cmd = Cli::command();
cmd.print_help()?;
println!();
return Ok(2);
}
match run_with_editor(app) {
Ok(code) => Ok(code),
Err(e) => {
app.log.verbose(&format!(
"line editor unavailable ({e:#}); falling back to plain input"
));
run_plain(app)
}
}
}
fn run_with_editor(app: &App) -> Result<i32> {
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
rustyline::Editor::new()?;
rl.set_helper(Some(AmCompleter::new(app)));
let history_path = app.paths.state_file.with_file_name("history.txt");
if rl.load_history(&history_path).is_err() {
// First run: no history yet.
}
eprintln!(
"agent-manager interactive shell — Tab pour compléter, flèches pour l'historique, 'help', 'exit'"
);
loop {
match rl.readline("am> ") {
Ok(line) => {
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
continue;
}
let _ = rl.add_history_entry(trimmed.as_str());
match trimmed.as_str() {
"exit" | "quit" | "q" => break,
"help" | "?" => print_help(),
"version" => println!("am {}", env!("CARGO_PKG_VERSION")),
_ => {
if let Err(e) = handle_line(app, &trimmed) {
app.log.error(&format!("{e:#}"));
}
}
}
}
Err(ReadlineError::Interrupted) => continue,
Err(ReadlineError::Eof) => break,
Err(e) => {
app.log.warn(&format!("input error: {e}"));
break;
}
}
}
let _ = rl.save_history(&history_path);
Ok(0)
}
/// Plain fallback when no terminal/line editor is available.
fn run_plain(app: &App) -> Result<i32> {
eprintln!("agent-manager interactive shell — type 'help' for commands, 'exit' to quit");
loop {
eprint!("am> ");
std::io::stderr().flush().ok();
let mut line = String::new();
if std::io::stdin().read_line(&mut line).is_err() {
break;
}
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
match line.as_str() {
"exit" | "quit" | "q" => break,
"help" | "?" => print_help(),
"version" => println!("am {}", env!("CARGO_PKG_VERSION")),
_ => {
if let Err(e) = handle_line(app, &line) {
app.log.error(&format!("{e:#}"));
}
}
}
}
Ok(0)
}
fn print_help() {
println!("commands:");
println!(" list [--all]");
println!(" status [agent]");
println!(" search <keyword>");
println!(" info <agent>");
println!(" install <agent> [--method <n|type>] [--force]");
println!(" uninstall <agent> [--purge]");
println!(" update <agent> | update --all");
println!(" start <agent> [--background]");
println!(" stop <agent> [--force]");
println!(" restart <agent> [--background] [--force]");
println!(" run <agent> [args...]");
println!(" doctor [--fix]");
println!(" config show | path | validate | edit");
println!(" help | version | exit");
}
fn handle_line(app: &App, line: &str) -> Result<()> {
let tokens = shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))?;
if tokens.is_empty() {
return Ok(());
}
let cmd = tokens[0].as_str();
let rest = &tokens[1..];
let need = |what: &str| -> Result<String> {
rest.first()
.cloned()
.ok_or_else(|| anyhow!("missing {what}"))
};
let flag = |f: &str| rest.iter().any(|t| t == f);
let method_flag = || {
rest.iter()
.position(|t| t == "--method")
.and_then(|i| rest.get(i + 1))
.cloned()
};
let command = match cmd {
"list" => Command::List {
all: flag("--all"),
category: None,
},
"status" => Command::Status {
agent: rest.first().cloned(),
},
"search" => Command::Search {
keyword: rest.join(" "),
category: None,
},
"info" => Command::Info {
agent: need("agent name")?,
},
"start" => Command::Start(StartArgs {
agent: need("agent name")?,
background: flag("--background") || flag("-b"),
..Default::default()
}),
"stop" => Command::Stop {
agent: need("agent name")?,
force: flag("--force"),
timeout: None,
},
"restart" => Command::Restart {
start: StartArgs {
agent: need("agent name")?,
background: flag("--background") || flag("-b"),
..Default::default()
},
force: flag("--force"),
timeout: None,
},
"install" => Command::Install {
agent: need("agent name")?,
method: method_flag(),
force: flag("--force"),
},
"uninstall" => Command::Uninstall {
agent: need("agent name")?,
purge: flag("--purge"),
},
"update" => {
if flag("--all") {
Command::Update {
agent: None,
all: true,
}
} else {
Command::Update {
agent: rest.first().cloned(),
all: false,
}
}
}
"doctor" => Command::Doctor { fix: flag("--fix") },
"run" => {
let agent = need("agent name")?;
Command::Run {
agent,
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
}
}
"config" => Command::Config(crate::cli::ConfigCmd::Show),
other => {
app.log
.error(&format!("unknown command '{other}' — type 'help'"));
return Ok(());
}
};
commands::execute_command(app, &command)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn completer() -> AmCompleter {
let cfg: crate::config::Config =
serde_yaml::from_str(crate::config::DEFAULT_CONFIG).unwrap();
let catalog = crate::catalog::Catalog::new(
cfg.agents,
cfg.aliases.clone(),
cfg.groups.clone(),
);
AmCompleter::from_catalog(&catalog, &cfg.aliases, &cfg.groups)
}
#[test]
fn completes_commands() {
let c = completer();
let (start, cands) = c.candidates_for("sta", 3);
assert_eq!(start, 0);
assert!(cands.contains(&"start".to_string()));
assert!(cands.contains(&"status".to_string()));
}
#[test]
fn completes_agent_names_and_aliases() {
let c = completer();
let (start, cands) = c.candidates_for("install claude", 14);
assert_eq!(start, 8);
assert!(cands.contains(&"claude-code".to_string()));
let (_, cands) = c.candidates_for("info cc", 7);
assert!(cands.contains(&"cc".to_string()));
}
#[test]
fn completes_group_selectors() {
let c = completer();
let (_, cands) = c.candidates_for("start group:d", 13);
assert!(!cands.is_empty());
assert!(cands.iter().all(|x| x.starts_with("group:")));
assert!(cands.contains(&"group:dev".to_string()));
}
#[test]
fn completes_flags_after_arguments() {
let c = completer();
let (_, cands) = c.candidates_for("install smelt --dr", 18);
assert!(cands.contains(&"--dry-run".to_string()));
}
#[test]
fn completes_config_subcommands() {
let c = completer();
let (_, cands) = c.candidates_for("config sh", 9);
assert!(cands.contains(&"show".to_string()));
}
}
+186
View File
@@ -0,0 +1,186 @@
//! Command execution abstraction: real process execution for production,
//! an in-memory recorder for tests and dry runs.
use crate::output::Logger;
use anyhow::{Context, Result};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct RunOutput {
pub code: i32,
pub stdout: String,
pub stderr: String,
}
/// Resolve a command name to an executable path, handling Windows
/// .cmd/.bat scripts (which std::process::Command cannot launch directly).
/// Returns the program to spawn and a prefix of arguments to insert before
/// the caller's arguments (e.g. cmd.exe /C script.cmd).
pub fn resolve_program(cmd: &str) -> (String, Vec<String>) {
#[cfg(windows)]
{
let path = if cmd.contains('\\') || cmd.contains('/') {
Some(PathBuf::from(cmd))
} else {
which::which(cmd).ok()
};
if let Some(p) = path {
let ext = p
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if ext == "cmd" || ext == "bat" {
let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
return (comspec, vec!["/C".to_string(), p.display().to_string()]);
}
return (p.display().to_string(), Vec::new());
}
}
(cmd.to_string(), Vec::new())
}
/// Execute external commands with logging and dry-run support.
pub trait Runner {
fn run(
&self,
cmd: &str,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
capture: bool,
) -> Result<RunOutput>;
/// Locate a binary on the PATH (like the which command).
fn which(&self, bin: &str) -> Option<PathBuf>;
}
/// Production runner backed by std::process::Command.
pub struct SystemRunner<'a> {
pub dry_run: bool,
pub verbose: bool,
pub log: &'a Logger,
}
impl<'a> SystemRunner<'a> {
pub fn new(dry_run: bool, verbose: bool, log: &'a Logger) -> Self {
Self {
dry_run,
verbose,
log,
}
}
}
impl<'a> Runner for SystemRunner<'a> {
fn run(
&self,
cmd: &str,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
capture: bool,
) -> Result<RunOutput> {
let display = if args.is_empty() {
cmd.to_string()
} else {
format!("{cmd} {}", args.join(" "))
};
if self.dry_run {
self.log.dry(format!("would run: {display}"));
return Ok(RunOutput::default());
}
self.log.cmd(&display);
let (prog, prefix) = resolve_program(cmd);
let mut full_args = prefix;
full_args.extend(args.iter().cloned());
let mut c = std::process::Command::new(&prog);
c.args(&full_args);
c.envs(env);
if let Some(dir) = cwd {
c.current_dir(dir);
}
if capture {
let out = c
.output()
.with_context(|| format!("failed to execute: {display}"))?;
let code = out.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
self.log.result(code, &stderr);
Ok(RunOutput {
code,
stdout,
stderr,
})
} else {
let status = c
.status()
.with_context(|| format!("failed to execute: {display}"))?;
Ok(RunOutput {
code: status.code().unwrap_or(-1),
stdout: String::new(),
stderr: String::new(),
})
}
}
fn which(&self, bin: &str) -> Option<PathBuf> {
which::which(bin).ok()
}
}
/// In-memory runner for tests: records every invocation, returns success.
#[derive(Default)]
pub struct MockRunner {
pub calls: std::sync::Mutex<Vec<String>>,
pub found: HashSet<String>,
pub outputs: std::sync::Mutex<Vec<RunOutput>>,
}
impl MockRunner {
pub fn new() -> Self {
Self::default()
}
pub fn with_found(mut self, bin: &str) -> Self {
self.found.insert(bin.to_string());
self
}
pub fn with_output(mut self, out: RunOutput) -> Self {
self.outputs.get_mut().unwrap().push(out);
self
}
pub fn snapshot(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
}
impl Runner for MockRunner {
fn run(
&self,
cmd: &str,
args: &[String],
_env: &BTreeMap<String, String>,
_cwd: Option<&Path>,
_capture: bool,
) -> Result<RunOutput> {
self.calls
.lock()
.unwrap()
.push(format!("{cmd} {}", args.join(" ")));
let out = self.outputs.lock().unwrap().pop();
Ok(out.unwrap_or_default())
}
fn which(&self, bin: &str) -> Option<PathBuf> {
if self.found.contains(bin) {
Some(PathBuf::from(bin))
} else {
None
}
}
}
+178
View File
@@ -0,0 +1,178 @@
//! The local installation database: a JSON file recording every agent
//! installed by agent-manager (name, version, paths, PID, dates).
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const STATE_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateFile {
#[serde(default = "state_version")]
pub version: u32,
#[serde(default)]
pub installed: BTreeMap<String, InstalledEntry>,
}
impl Default for StateFile {
fn default() -> Self {
Self {
version: STATE_VERSION,
installed: BTreeMap::new(),
}
}
}
fn state_version() -> u32 {
STATE_VERSION
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledEntry {
pub name: String,
#[serde(default)]
pub version: Option<String>,
/// Installer method that was used (npm, pip, binary, git, ...).
pub method: String,
/// Root directory of this agent's installation.
pub install_dir: String,
/// Executable files/shims that belong to this installation.
#[serde(default)]
pub bins: Vec<String>,
/// Run command (as configured at install time).
pub run: String,
/// RFC 3339 timestamp of the installation.
pub installed_at: String,
/// RFC 3339 timestamp of the last update (if any).
#[serde(default)]
pub updated_at: Option<String>,
/// PID of the background process (when running).
#[serde(default)]
pub pid: Option<u32>,
/// RFC 3339 timestamp of the last background start.
#[serde(default)]
pub started_at: Option<String>,
}
#[derive(Debug, Clone)]
pub struct StateStore {
path: PathBuf,
}
impl StateStore {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Result<StateFile> {
if !self.path.exists() {
return Ok(StateFile::default());
}
let text = std::fs::read_to_string(&self.path)
.with_context(|| format!("cannot read state file {}", self.path.display()))?;
let sf: StateFile = serde_json::from_str(&text).map_err(|e| {
anyhow!(
"state file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
self.path.display()
)
})?;
Ok(sf)
}
pub fn save(&self, sf: &StateFile) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = self.path.with_extension("json.tmp");
let text = serde_json::to_string_pretty(sf)?;
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, &self.path)
.with_context(|| format!("cannot write state file {}", self.path.display()))?;
Ok(())
}
pub fn get(&self, name: &str) -> Result<Option<InstalledEntry>> {
Ok(self.load()?.installed.get(name).cloned())
}
pub fn all(&self) -> Result<BTreeMap<String, InstalledEntry>> {
Ok(self.load()?.installed)
}
pub fn set(&self, entry: &InstalledEntry) -> Result<()> {
let mut sf = self.load()?;
sf.installed.insert(entry.name.clone(), entry.clone());
self.save(&sf)
}
pub fn remove(&self, name: &str) -> Result<bool> {
let mut sf = self.load()?;
let removed = sf.installed.remove(name).is_some();
if removed {
self.save(&sf)?;
}
Ok(removed)
}
/// Update the PID / start timestamp of a background agent.
pub fn update_pid(&self, name: &str, pid: Option<u32>, started_at: Option<String>) -> Result<()> {
let mut sf = self.load()?;
if let Some(e) = sf.installed.get_mut(name) {
e.pid = pid;
e.started_at = started_at;
self.save(&sf)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store(dir: &Path) -> StateStore {
StateStore::new(dir.join("state.json"))
}
#[test]
fn roundtrip() {
let dir = tempfile::tempdir().unwrap();
let st = store(dir.path());
assert!(st.all().unwrap().is_empty());
let entry = InstalledEntry {
name: "claude-code".to_string(),
version: Some("1.2.3".to_string()),
method: "npm".to_string(),
install_dir: "/tmp/cc".to_string(),
bins: vec!["/tmp/cc/bin/claude".to_string()],
run: "claude".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: Some(42),
started_at: None,
};
st.set(&entry).unwrap();
let got = st.get("claude-code").unwrap().unwrap();
assert_eq!(got.version.as_deref(), Some("1.2.3"));
assert_eq!(got.pid, Some(42));
st.update_pid("claude-code", None, None).unwrap();
assert_eq!(st.get("claude-code").unwrap().unwrap().pid, None);
assert!(st.remove("claude-code").unwrap());
assert!(st.all().unwrap().is_empty());
}
#[test]
fn corrupt_file_is_reported() {
let dir = tempfile::tempdir().unwrap();
let st = store(dir.path());
std::fs::write(st.path(), "{ not json").unwrap();
let err = st.load().unwrap_err().to_string();
assert!(err.contains("corrupt"), "unexpected error: {err}");
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Version string utilities: extraction from arbitrary command output and
//! lenient version comparison.
use std::cmp::Ordering;
/// Extract the first plausible version string from arbitrary text.
///
/// Handles forms such as "v1.2.3", "1.2", "go1.21.0", "2026.1", as well as
/// suffixes like "-beta" or "+meta". Prefers candidates with the most
/// dot-separated components.
pub fn find_version(text: &str) -> Option<String> {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut best: Option<(usize, usize, usize)> = None; // (dots, len, start)
let mut i = 0;
while i < n {
if chars[i].is_ascii_digit() {
let mut j = i;
while j < n && chars[j].is_ascii_digit() {
j += 1;
}
let mut dots = 0usize;
loop {
if j + 1 < n && chars[j] == '.' && chars[j + 1].is_ascii_digit() {
dots += 1;
j += 1;
while j < n && chars[j].is_ascii_digit() {
j += 1;
}
} else {
break;
}
}
let mut k = j;
if k < n && (chars[k] == '-' || chars[k] == '+') {
k += 1;
while k < n
&& (chars[k].is_ascii_alphanumeric() || chars[k] == '.' || chars[k] == '-')
{
k += 1;
}
}
let len = k - i;
let score = dots.saturating_mul(1000) + len.min(999);
if score > best.map(|(d, l, _)| d * 1000 + l.min(999)).unwrap_or(0) {
best = Some((dots, len, i));
}
i = k.max(j);
} else {
i += 1;
}
}
let (_, _, start) = best?;
// Reconstruct the chosen candidate.
let mut j = start;
while j < n && chars[j].is_ascii_digit() {
j += 1;
}
while j + 1 < n && chars[j] == '.' && chars[j + 1].is_ascii_digit() {
j += 1;
while j < n && chars[j].is_ascii_digit() {
j += 1;
}
}
let mut k = j;
if k < n && (chars[k] == '-' || chars[k] == '+') {
k += 1;
while k < n && (chars[k].is_ascii_alphanumeric() || chars[k] == '.' || chars[k] == '-')
{
k += 1;
}
}
while k > start && (chars[k - 1] == '.' || chars[k - 1] == '-') {
k -= 1;
}
if k <= start {
return None;
}
Some(chars[start..k].iter().collect())
}
/// Strip a leading "v"/"V" and surrounding whitespace.
pub fn normalize(s: &str) -> String {
let mut t = s.trim().to_string();
if t.len() > 1
&& (t.starts_with('v') || t.starts_with('V'))
&& t[1..].chars().next().map_or(false, |c| c.is_ascii_digit())
{
t = t[1..].to_string();
}
t
}
/// Compare two version strings leniently: semver when possible, otherwise a
/// numeric-tuple comparison. Returns None when the two cannot be compared at
/// all (never happens for two non-empty strings).
pub fn compare(a: &str, b: &str) -> Option<Ordering> {
let a = normalize(a);
let b = normalize(b);
if a == b {
return Some(Ordering::Equal);
}
if let (Ok(av), Ok(bv)) = (semver::Version::parse(&a), semver::Version::parse(&b)) {
return Some(av.cmp(&bv));
}
let pa = numeric_parts(&a);
let pb = numeric_parts(&b);
let mut idx = 0;
loop {
match (pa.get(idx), pb.get(idx)) {
(Some(x), Some(y)) => {
if x != y {
return Some(x.cmp(y));
}
idx += 1;
}
(Some(_), None) => return Some(Ordering::Greater),
(None, Some(_)) => return Some(Ordering::Less),
(None, None) => return None,
}
}
}
fn numeric_parts(s: &str) -> Vec<u64> {
s.split(|c: char| !c.is_ascii_digit())
.filter(|p| !p.is_empty())
.filter_map(|p| p.parse::<u64>().ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_common_forms() {
assert_eq!(find_version("Claude Code v1.2.3 (abc)").as_deref(), Some("1.2.3"));
assert_eq!(find_version("go version go1.21.0 windows/amd64").as_deref(), Some("1.21.0"));
assert_eq!(find_version("uv 0.7.12").as_deref(), Some("0.7.12"));
assert_eq!(find_version("node 18.20.4").as_deref(), Some("18.20.4"));
assert_eq!(find_version("1.2.3-beta.1").as_deref(), Some("1.2.3-beta.1"));
assert_eq!(find_version("no version here"), None);
}
#[test]
fn prefers_dotted_candidate() {
assert_eq!(find_version("cargo 1.85.0 (8a56 2026-01-01)").as_deref(), Some("1.85.0"));
}
#[test]
fn compares_semver_and_numeric() {
assert_eq!(compare("1.10.0", "1.9.0"), Some(Ordering::Greater));
assert_eq!(compare("v2.0.0", "2.0.0"), Some(Ordering::Equal));
assert_eq!(compare("2.0.0-rc1", "2.0.0"), Some(Ordering::Less));
assert_eq!(compare("2026", "2025"), Some(Ordering::Greater));
assert_eq!(compare("0.7", "0.7.12"), Some(Ordering::Less));
}
}