434 lines
15 KiB
Rust
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()]);
|
|
}
|
|
}
|