Files
agent-manager/src/commands/mod.rs
T

434 lines
15 KiB
Rust

//! Command implementations and dispatch.
pub mod agents_cmd;
pub mod alias_cmd;
pub mod annotations_cmd;
pub mod audit_cmd;
pub mod catalog_cmd;
pub mod completion_cmd;
pub mod config_cmd;
pub mod dashboard_cmd;
pub mod doctor_cmd;
pub mod export_import;
pub mod help_cmd;
pub mod history_cmd;
pub mod info_cmd;
pub mod init_cmd;
pub mod install_cmd;
pub mod list_cmd;
pub mod log_cmd;
pub mod logs_cmd;
pub mod man_cmd;
pub mod models_cmd;
pub mod open_cmd;
pub mod profile_cmd;
pub mod projects_cmd;
pub mod run_cmd;
pub mod schedule_cmd;
pub mod search_cmd;
pub mod service_cmd;
pub mod secret_cmd;
pub mod self_uninstall;
pub mod self_update;
pub mod sessions_cmd;
pub mod stats_cmd;
pub mod status_cmd;
pub mod suggest_cmd;
pub mod theme_cmd;
pub mod timeline_cmd;
pub mod tip_cmd;
pub mod uninstall_cmd;
pub mod update_cmd;
pub mod version_cmd;
pub mod watch_cmd;
use crate::app::App;
use crate::cli::Command;
use crate::config::{AgentDef, MethodSpec, UpdatePolicy};
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,
running,
sort,
} => list_cmd::run(app, *all, category.as_deref(), *running, sort.as_deref()),
Command::Logs {
agent,
lines,
follow,
} => logs_cmd::run(app, agent, *lines, *follow),
Command::Open { agent } => open_cmd::run(app, agent),
Command::Watch {
agent,
restart,
notify,
interval,
} => watch_cmd::run(app, agent, *restart, *notify, *interval),
Command::Dashboard => dashboard_cmd::run(app),
Command::Favorite { agent } => annotations_cmd::run_favorite(app, agent),
Command::Unfavorite { agent } => annotations_cmd::run_unfavorite(app, agent),
Command::Note { agent, text } => annotations_cmd::run_note(app, agent, text),
Command::Tag { agent, words } => annotations_cmd::run_tag(app, agent, words),
Command::Untag { agent, tag } => annotations_cmd::run_untag(app, agent, tag),
Command::Tags { agent } => annotations_cmd::run_tags(app, agent.as_deref()),
Command::Profile(sub) => profile_cmd::run(app, sub),
Command::Man { command, output } => man_cmd::run(app, command.as_deref(), output.as_deref()),
Command::Agents { installed } => agents_cmd::run(app, *installed),
Command::Alias(sub) => alias_cmd::run(app, sub),
Command::Secret(sub) => secret_cmd::run(app, sub),
Command::Models(args) => models_cmd::run(app, args),
Command::Catalog(sub) => catalog_cmd::run(app, sub),
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
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::Stats { agent, period } => stats_cmd::run(app, agent.as_deref(), period.as_deref()),
Command::Top { period } => stats_cmd::run_top(app, period.as_deref()),
Command::Report {
last_week,
last_month,
output,
} => stats_cmd::run_report(app, *last_week, *last_month, output.as_deref()),
Command::Log {
agent,
kind,
since,
limit,
follow,
} => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit, *follow),
Command::Projects { name } => projects_cmd::run(app, name.as_deref()),
Command::Timeline {
agent,
project,
since,
limit,
} => timeline_cmd::run(app, agent.as_deref(), project.as_deref(), since.as_deref(), *limit),
Command::Sessions {
agent,
project,
status,
show,
resume,
export,
output,
retention,
} => sessions_cmd::run(
app,
agent.as_deref(),
project.as_deref(),
status.as_deref(),
show.as_deref(),
resume.as_deref(),
export.as_deref(),
output.as_deref(),
*retention,
),
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, rollback } => {
update_cmd::run(app, agent.as_deref(), *all, rollback.as_deref())
}
Command::Audit => audit_cmd::run(app),
Command::Search { keyword, category, tag } => {
search_cmd::run(app, keyword.as_deref(), category.as_deref(), tag.as_deref())
}
Command::History {
kind,
cwd,
search,
failed,
session,
limit,
rerun,
} => history_cmd::run(
app,
kind.as_deref(),
cwd.as_deref(),
search.as_deref(),
*failed,
session.as_deref(),
*limit,
*rerun,
),
Command::Info { agent } => info_cmd::run(app, agent),
Command::Init { force, template } => {
init_cmd::run(app, *force, template.as_deref())
}
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
Command::Version => version_cmd::run(app),
Command::Tip { random } => tip_cmd::run(app, *random),
Command::Config(sub) => config_cmd::run(app, sub),
Command::Theme { name } => theme_cmd::run(app, name.as_deref()),
Command::Doctor { fix, watch } => {
doctor_cmd::run(app, *fix, *watch)
}
Command::Run { agent, model, container, args } => {
run_cmd::run(app, agent, model.as_deref(), *container, args)
}
Command::Service(svc) => match svc {
crate::cli::ServiceCmd::Install { agent, autostart } => {
service_cmd::run(app, "install", Some(agent), *autostart)
}
crate::cli::ServiceCmd::Uninstall { agent } => {
service_cmd::run(app, "uninstall", Some(agent), false)
}
crate::cli::ServiceCmd::Status { agent } => {
service_cmd::run(app, "status", Some(agent), false)
}
},
Command::Schedule(sched) => match sched {
crate::cli::ScheduleCmd::Add { command, at } => {
// '--at HH:MM' may land inside the trailing command args
// (allow_hyphen_values): strip it and recover the value.
let mut cmd = command.clone();
let mut at_value = at.clone();
if let Some(idx) = cmd.iter().position(|a| a == "--at") {
if idx + 1 < cmd.len() {
at_value = at_value.or_else(|| Some(cmd[idx + 1].clone()));
cmd.remove(idx + 1);
}
cmd.remove(idx);
}
schedule_cmd::run(app, "add", cmd, at_value.as_deref())
}
crate::cli::ScheduleCmd::List => schedule_cmd::run(app, "list", vec![], None),
crate::cli::ScheduleCmd::Remove { id } => {
schedule_cmd::run(app, "remove", vec![id.clone()], None)
}
crate::cli::ScheduleCmd::Run { id } => schedule_cmd::run(app, "run", vec![id.clone()], None),
},
Command::Completion { shell, installed } => completion_cmd::run(*shell, *installed),
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
Command::SelfUninstall => self_uninstall::run(app),
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());
let warnings =
crate::secrets::resolve_env_secrets(&crate::secrets::store(), &agent.name, &mut env);
for w in warnings {
app.log.warn(&w);
}
let (program, args) = route_cmd_script(program, args);
Ok(ResolvedExec { program, args, env })
}
/// On Windows, .cmd/.bat shims (git installs without a build step) must be
/// executed through cmd.exe — spawning them directly fails.
pub fn route_cmd_script(program: String, args: Vec<String>) -> (String, Vec<String>) {
let lower = program.to_lowercase();
if cfg!(windows) && (lower.ends_with(".cmd") || lower.ends_with(".bat")) {
let shell = std::env::var_os("COMSPEC")
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "cmd.exe".to_string());
let mut full = vec!["/C".to_string(), program];
full.extend(args);
(shell, full)
} else {
(program, args)
}
}
/// 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(", ")
)
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn routes_cmd_scripts_through_comspec() {
let (program, args) = route_cmd_script(
"C:/x/agent.cmd".to_string(),
vec!["--flag".to_string()],
);
let lower = program.to_lowercase();
if cfg!(windows) {
assert!(lower.ends_with("cmd.exe"), "{program}");
assert_eq!(args[0], "/C");
assert_eq!(args[1], "C:/x/agent.cmd");
assert_eq!(args[2], "--flag");
} else {
assert_eq!(program, "C:/x/agent.cmd");
assert_eq!(args, vec!["--flag".to_string()]);
}
}
#[test]
fn leaves_regular_programs_untouched() {
let (program, args) = route_cmd_script("node".to_string(), vec!["-v".to_string()]);
assert_eq!(program, "node");
assert_eq!(args, vec!["-v".to_string()]);
}
}