feat: phase 2 — automatisation: doctor --watch, schedule, service, orchestration, conteneurs (closes #55 #56 #57 #58 #59)
This commit is contained in:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.4.7"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.4.7"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
|
||||
@@ -87,6 +87,11 @@ plateforme, il compile automatiquement depuis les sources.
|
||||
| am sessions --export <id> | export d'une session (métadonnées + commandes + extrait de log) |
|
||||
| am sessions --retention <jours> | purge des sessions terminées au-delà de N jours |
|
||||
| am init --template <stack> | génère une config pré-remplie (web, python, rust, cli) |
|
||||
| am doctor --watch <s> | vérifications périodiques de l'environnement + alerte en cas de panne |
|
||||
| am service install <agent> --autostart | service système (systemd / launchd / tâche Windows) + démarrage auto |
|
||||
| am schedule add <cmd...> --at HH:MM | planifie une commande am (ex: update --all) ; list / remove / run |
|
||||
| am start group:dev --parallel | orchestration de groupes : ordre, --parallel, attente de santé (healthcheck) |
|
||||
| am run <agent> --container | exécute l'agent dans son profil conteneur (docker/podman) |
|
||||
|
||||
**Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé
|
||||
sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
|
||||
+10
-10
@@ -245,11 +245,11 @@ consultable, reprenable, archivable.
|
||||
|---|---|---|
|
||||
| Hooks on_install / on_start / on_stop / on_update (agent, projet, global) — via le runner existant | M | P0 |
|
||||
| am watch claude-code --restart-on-crash --notify | M | P1 |
|
||||
| Services : unités systemd / plists launchd / tâches Windows, am service install claude-code --autostart | L | P2 |
|
||||
| Planification : am schedule --daily update --all + check santé (cron / Task Scheduler) | M | P2 |
|
||||
| Orchestration de groupes : ordre, --parallel, attente de santé | L | P2 |
|
||||
| Conteneurs : profil docker/podman par agent (isolation à la demande) | L | P2 |
|
||||
| am doctor --watch — vérifications périodiques avec alertes | M | P2 |
|
||||
| Services : unités systemd / plists launchd / tâches Windows, am service install claude-code --autostart ✅ #55 | L | P2 |
|
||||
| Planification : am schedule add update --all --at 06:30 + check santé (cron / Task Scheduler) ✅ #56 | M | P2 |
|
||||
| Orchestration de groupes : ordre, --parallel, attente de santé ✅ #57 | L | P2 |
|
||||
| Conteneurs : profil docker/podman par agent (isolation à la demande) ✅ #58 | L | P2 |
|
||||
| am doctor --watch — vérifications périodiques avec alertes ✅ #59 | M | P2 |
|
||||
|
||||
### Axe 7 — 🧠 Intelligence & catalogue
|
||||
|
||||
@@ -436,11 +436,11 @@ moins de 2 secondes.
|
||||
| [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | ✅ Templates — am init --template web/python/rust/cli (substitution + validation) | M |
|
||||
| [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | ✅ am sessions export + rétention configurable (sessions_retention_days) | M |
|
||||
| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | am web — serveur local + API JSON + graphiques | XL |
|
||||
| [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) | L |
|
||||
| [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (--daily update --all, check santé) | M |
|
||||
| [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) | L |
|
||||
| [#58](https://git.dracodev.net/Projets/agent-manager/issues/58) | Conteneurs — profils docker/podman par agent | L |
|
||||
| [#59](https://git.dracodev.net/Projets/agent-manager/issues/59) | am doctor --watch — vérifications périodiques avec alertes | M |
|
||||
| [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) ✅ | L |
|
||||
| [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (add --at / list / remove / run) ✅ | M |
|
||||
| [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) ✅ | L |
|
||||
| [#58](https://git.dracodev.net/Projets/agent-manager/issues/58) | Conteneurs — profils docker/podman par agent ✅ | L |
|
||||
| [#59](https://git.dracodev.net/Projets/agent-manager/issues/59) | am doctor --watch — vérifications périodiques avec alertes ✅ | M |
|
||||
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | ✅ Catalogue distant — am catalog update / am catalog add <url> | M |
|
||||
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | ✅ am suggest — « un agent pour du Python » (tags + usage réel) | M |
|
||||
| [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | am lab — benchmark : même tâche sur N agents | L |
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
doctor \- Check the environment: tools, config validity, paths, permissions
|
||||
.SH SYNOPSIS
|
||||
\fBdoctor\fR [\fB\-\-fix\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
\fBdoctor\fR [\fB\-\-fix\fR] [\fB\-\-watch\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Check the environment: tools, config validity, paths, permissions
|
||||
.SH OPTIONS
|
||||
@@ -12,5 +12,8 @@ Check the environment: tools, config validity, paths, permissions
|
||||
\fB\-\-fix\fR
|
||||
Attempt to repair problems (create directories, fix the state file)
|
||||
.TP
|
||||
\fB\-\-watch\fR \fI<SECONDS>\fR
|
||||
Re\-run the checks every N seconds, alerting on failure (issue #59)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
|
||||
+7
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
restart \- Restart an agent: stop, then start with the same options
|
||||
.SH SYNOPSIS
|
||||
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
.SH DESCRIPTION
|
||||
Restart an agent: stop, then start with the same options
|
||||
.SH OPTIONS
|
||||
@@ -30,6 +30,12 @@ Apply an environment profile (env + args, defined in the config)
|
||||
\fB\-\-model\fR \fI<MODEL>\fR
|
||||
Local model to use for this run (validated against the local runtimes)
|
||||
.TP
|
||||
\fB\-\-parallel\fR
|
||||
Start every member of a group simultaneously (issue #57)
|
||||
.TP
|
||||
\fB\-\-container\fR
|
||||
Run the agent inside a container (issue #58)
|
||||
.TP
|
||||
\fB\-\-force\fR
|
||||
Kill immediately instead of terminating gracefully
|
||||
.TP
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
run \- Run the agent command directly with the given arguments (no process management)
|
||||
.SH SYNOPSIS
|
||||
\fBrun\fR [\fB\-\-model\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
|
||||
\fBrun\fR [\fB\-\-model\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
|
||||
.SH DESCRIPTION
|
||||
Run the agent command directly with the given arguments (no process management)
|
||||
.SH OPTIONS
|
||||
@@ -12,6 +12,9 @@ Run the agent command directly with the given arguments (no process management)
|
||||
\fB\-\-model\fR \fI<MODEL>\fR
|
||||
Local model to use for this run (validated against the local runtimes)
|
||||
.TP
|
||||
\fB\-\-container\fR
|
||||
Run the agent inside a container (issue #58)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-schedule 1 "schedule "
|
||||
.SH NAME
|
||||
schedule \- Schedule am commands (issue #56)
|
||||
.SH SYNOPSIS
|
||||
\fBschedule\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
|
||||
.SH DESCRIPTION
|
||||
Schedule am commands (issue #56)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.SH SUBCOMMANDS
|
||||
.TP
|
||||
schedule\-add(1)
|
||||
Add a daily schedule (ex: \*(Aqam schedule add update \-\-all\*(Aq)
|
||||
.TP
|
||||
schedule\-list(1)
|
||||
List the schedules
|
||||
.TP
|
||||
schedule\-remove(1)
|
||||
Remove a schedule by id
|
||||
.TP
|
||||
schedule\-run(1)
|
||||
Run a schedule now (invoked by the OS task, issue #56)
|
||||
.TP
|
||||
schedule\-help(1)
|
||||
Print this message or the help of the given subcommand(s)
|
||||
@@ -0,0 +1,26 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-service 1 "service "
|
||||
.SH NAME
|
||||
service \- Register an agent as a system service (issue #55)
|
||||
.SH SYNOPSIS
|
||||
\fBservice\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
|
||||
.SH DESCRIPTION
|
||||
Register an agent as a system service (issue #55)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.SH SUBCOMMANDS
|
||||
.TP
|
||||
service\-install(1)
|
||||
Install the service unit (systemd / launchd / Task Scheduler)
|
||||
.TP
|
||||
service\-uninstall(1)
|
||||
Remove the service unit
|
||||
.TP
|
||||
service\-status(1)
|
||||
Show whether the service is installed
|
||||
.TP
|
||||
service\-help(1)
|
||||
Print this message or the help of the given subcommand(s)
|
||||
+7
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
start \- Start an agent (foreground by default, or detached with \-\-background)
|
||||
.SH SYNOPSIS
|
||||
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-parallel\fR] [\fB\-\-container\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
.SH DESCRIPTION
|
||||
Start an agent (foreground by default, or detached with \-\-background)
|
||||
.SH OPTIONS
|
||||
@@ -30,6 +30,12 @@ Apply an environment profile (env + args, defined in the config)
|
||||
\fB\-\-model\fR \fI<MODEL>\fR
|
||||
Local model to use for this run (validated against the local runtimes)
|
||||
.TP
|
||||
\fB\-\-parallel\fR
|
||||
Start every member of a group simultaneously (issue #57)
|
||||
.TP
|
||||
\fB\-\-container\fR
|
||||
Run the agent inside a container (issue #58)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am 1 "am 0.4.7"
|
||||
.TH am 1 "am 0.5.0"
|
||||
.SH NAME
|
||||
am \- agent\-manager (am) — manage local AI coding agents
|
||||
.SH SYNOPSIS
|
||||
@@ -96,6 +96,12 @@ Manage secrets in the OS keyring (never in plaintext config)
|
||||
am\-audit(1)
|
||||
Audit the configuration: who changed what, when (issue #63)
|
||||
.TP
|
||||
am\-service(1)
|
||||
Register an agent as a system service (issue #55)
|
||||
.TP
|
||||
am\-schedule(1)
|
||||
Schedule am commands (issue #56)
|
||||
.TP
|
||||
am\-models(1)
|
||||
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
.TP
|
||||
@@ -192,4 +198,4 @@ Export the configuration and installation state (backup)
|
||||
am\-import(1)
|
||||
Import a previously exported configuration and state
|
||||
.SH VERSION
|
||||
v0.4.7
|
||||
v0.5.0
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Automation helpers (axe 6): system service units, scheduled tasks,
|
||||
//! container commands and health checks. Pure functions here are unit
|
||||
//! tested; the thin command layers live in commands/service_cmd.rs,
|
||||
//! commands/schedule_cmd.rs, run_cmd.rs and doctor_cmd.rs.
|
||||
|
||||
use crate::config::{ContainerSpec, HealthCheck};
|
||||
use anyhow::{bail, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::TcpStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Am binary path used inside generated units/tasks (quoted).
|
||||
pub fn am_bin_quoted() -> String {
|
||||
let exe = std::env::current_exe()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "am".to_string());
|
||||
shell_escape(&exe)
|
||||
}
|
||||
|
||||
fn shell_escape(s: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
format!("\"{}\"", s.replace('"', "\\\""))
|
||||
} else {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #55: system service units
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// systemd user unit for 'am service install <agent> --autostart'.
|
||||
pub fn systemd_unit(agent: &str, am_bin: &str) -> String {
|
||||
format!(
|
||||
"# Generated by 'am service install {agent}' — managed by agent-manager\n\
|
||||
[Unit]\n\
|
||||
Description=agent-manager: {agent}\n\
|
||||
After=network-online.target\n\
|
||||
Wants=network-online.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
ExecStart={am_bin} start {agent} --background --notify\n\
|
||||
Restart=on-failure\n\
|
||||
RestartSec=10\n\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=default.target\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// launchd plist for macOS.
|
||||
pub fn launchd_plist(agent: &str, am_bin: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
|
||||
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
|
||||
<plist version=\"1.0\">\n\
|
||||
<dict>\n\
|
||||
\t<key>Label</key>\n\
|
||||
\t<string>com.agent-manager.{agent}</string>\n\
|
||||
\t<key>ProgramArguments</key>\n\
|
||||
\t<array>\n\
|
||||
\t\t<string>{am_bin}</string>\n\
|
||||
\t\t<string>start</string>\n\
|
||||
\t\t<string>{agent}</string>\n\
|
||||
\t\t<string>--background</string>\n\
|
||||
\t\t<string>--notify</string>\n\
|
||||
\t</array>\n\
|
||||
\t<key>RunAtLoad</key>\n\
|
||||
\t<true/>\n\
|
||||
\t<key>KeepAlive</key>\n\
|
||||
\t<true/>\n\
|
||||
</dict>\n\
|
||||
</plist>\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Windows scheduled task XML (on logon, with retry).
|
||||
pub fn windows_task_xml(agent: &str, am_bin: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
|
||||
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
|
||||
\t<Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>\n\
|
||||
\t<Principals><Principal id=\"Author\"><LogonType>InteractiveToken</LogonType>\n\
|
||||
\t<RunLevel>LeastPrivilege</RunLevel></Principal></Principals>\n\
|
||||
\t<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
|
||||
\t<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n\
|
||||
\t<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n\
|
||||
\t<RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>\n\
|
||||
\t</Settings>\n\
|
||||
\t<Actions><Exec>\n\
|
||||
\t<Command>{am_bin}</Command>\n\
|
||||
\t<Arguments>start {agent} --background --notify</Arguments>\n\
|
||||
\t</Exec></Actions>\n\
|
||||
</Task>\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Path of the systemd user unit for an agent.
|
||||
pub fn systemd_unit_path(agent: &str) -> PathBuf {
|
||||
let base = std::env::var("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
crate::config::home_dir()
|
||||
.map(|h| h.join(".config"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config"))
|
||||
});
|
||||
base.join("systemd").join("user").join(format!("am-{agent}.service"))
|
||||
}
|
||||
|
||||
/// Path of the launchd plist for an agent.
|
||||
pub fn launchd_path(agent: &str) -> PathBuf {
|
||||
crate::config::home_dir()
|
||||
.map(|h| h.join("Library/LaunchAgents"))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(format!("com.agent-manager.{agent}.plist"))
|
||||
}
|
||||
|
||||
/// Windows task name for an agent.
|
||||
pub fn windows_task_name(agent: &str) -> String {
|
||||
format!("am-{agent}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #56: scheduled tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// HH:MM at which a "daily" schedule runs (default 08:00). Parsed leniently.
|
||||
pub fn parse_hhmm(at: &str) -> (u8, u8) {
|
||||
let parts: Vec<&str> = at.split(':').collect();
|
||||
let h: u8 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(8);
|
||||
let m: u8 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||
(h.min(23), m.min(59))
|
||||
}
|
||||
|
||||
/// Crontab line for a daily schedule (Linux/macOS).
|
||||
pub fn crontab_line(at: &str, am_bin: &str, schedule_id: &str) -> String {
|
||||
let (h, m) = parse_hhmm(at);
|
||||
format!("{m} {h} * * * {am_bin} schedule-run {schedule_id}")
|
||||
}
|
||||
|
||||
/// schtasks command registering a daily task (Windows).
|
||||
pub fn schtasks_daily(at: &str, am_bin: &str, schedule_id: &str, task_name: &str) -> Command {
|
||||
let (h, m) = parse_hhmm(at);
|
||||
let mut c = Command::new("schtasks");
|
||||
c.args([
|
||||
"/create",
|
||||
"/tn",
|
||||
task_name,
|
||||
"/sc",
|
||||
"daily",
|
||||
"/st",
|
||||
&format!("{h:02}:{m:02}"),
|
||||
"/tr",
|
||||
&format!("\"{am_bin}\" schedule-run {schedule_id}"),
|
||||
"/f",
|
||||
]);
|
||||
c
|
||||
}
|
||||
|
||||
/// The command that actually runs a schedule: am schedule-run <id>.
|
||||
pub fn schedule_run_command(app: &crate::app::App, entry: &crate::state::ScheduleEntry) -> Result<i32> {
|
||||
// Re-dispatch through the CLI so hooks/events/state stay consistent.
|
||||
let args: Vec<String> = entry
|
||||
.command
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let mut full = vec!["am".to_string()];
|
||||
if let Some(cfg) = &app.config_origin {
|
||||
full.push("--config".to_string());
|
||||
full.push(cfg.to_string_lossy().to_string());
|
||||
}
|
||||
full.extend(args);
|
||||
let status = Command::new(std::env::current_exe()?)
|
||||
.args(&full[1..])
|
||||
.status()?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #57: health checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wait for an agent health check (issue #57). Returns Ok when the check
|
||||
/// passes within the timeout, Err otherwise. Never probes when the agent
|
||||
/// has no healthcheck configured.
|
||||
pub fn wait_health(agent: &str, hc: Option<&HealthCheck>, log: &crate::output::Logger) -> Result<()> {
|
||||
let Some(hc) = hc else {
|
||||
return Ok(());
|
||||
};
|
||||
let timeout = hc.timeout_secs.unwrap_or(30).max(1);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout);
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let ok = healthcheck_once(hc);
|
||||
if ok {
|
||||
return Ok(());
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
bail!(
|
||||
"health check for '{agent}' failed after {timeout}s \
|
||||
(command: {}, port: {})",
|
||||
hc.command.as_deref().unwrap_or("-"),
|
||||
hc.port.map(|p| p.to_string()).unwrap_or_else(|| "-".to_string())
|
||||
);
|
||||
}
|
||||
if attempt == 1 || attempt % 5 == 0 {
|
||||
log.verbose(&format!(
|
||||
"waiting for '{agent}' health (attempt {attempt})..."
|
||||
));
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
/// One health check probe: command exit 0, or TCP connect on the port.
|
||||
pub fn healthcheck_once(hc: &HealthCheck) -> bool {
|
||||
if let Some(cmd) = &hc.command {
|
||||
let ok = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(port) = hc.port {
|
||||
if TcpStream::connect(("127.0.0.1", port)).is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #58: container profiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detect a container runtime: podman preferred, docker fallback.
|
||||
pub fn detect_runtime() -> Option<String> {
|
||||
for bin in ["podman", "docker"] {
|
||||
if Command::new(bin)
|
||||
.arg("--version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(bin.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the container run command for an agent (issue #58): mounts the
|
||||
/// state dir and log dir, applies the profile volumes/network/env, runs
|
||||
/// the agent's run command in the image.
|
||||
pub fn container_command(
|
||||
runtime: &str,
|
||||
agent: &str,
|
||||
spec: &ContainerSpec,
|
||||
state_dir: &Path,
|
||||
log_dir: &Path,
|
||||
) -> Result<Vec<String>> {
|
||||
if spec.image.trim().is_empty() {
|
||||
bail!("agent '{agent}': container profile requires an 'image'");
|
||||
}
|
||||
let mut cmd = vec![
|
||||
runtime.to_string(),
|
||||
"run".to_string(),
|
||||
"-d".to_string(),
|
||||
"--name".to_string(),
|
||||
format!("am-{agent}"),
|
||||
];
|
||||
// State + logs mounts so sessions/history keep working.
|
||||
cmd.push("-v".to_string());
|
||||
cmd.push(format!("{}:/data", state_dir.to_string_lossy()));
|
||||
cmd.push("-v".to_string());
|
||||
cmd.push(format!("{}:/logs", log_dir.to_string_lossy()));
|
||||
for v in &spec.volumes {
|
||||
cmd.push("-v".to_string());
|
||||
cmd.push(v.clone());
|
||||
}
|
||||
match &spec.network {
|
||||
Some(n) => {
|
||||
cmd.push("--network".to_string());
|
||||
cmd.push(n.clone());
|
||||
}
|
||||
None => {
|
||||
cmd.push("--network".to_string());
|
||||
cmd.push(
|
||||
if cfg!(target_os = "linux") {
|
||||
"host".to_string()
|
||||
} else {
|
||||
"bridge".to_string()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
for (k, v) in &spec.env {
|
||||
cmd.push("-e".to_string());
|
||||
cmd.push(format!("{k}={v}"));
|
||||
}
|
||||
cmd.push(spec.image.clone());
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn systemd_unit_is_valid_and_referenced() {
|
||||
let unit = systemd_unit("claude-code", "/usr/local/bin/am");
|
||||
assert!(unit.contains("[Unit]"));
|
||||
assert!(unit.contains("ExecStart=/usr/local/bin/am start claude-code --background --notify"));
|
||||
assert!(unit.contains("Restart=on-failure"));
|
||||
assert!(unit.contains("[Install]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launchd_plist_is_valid_xml_shape() {
|
||||
let plist = launchd_plist("aider", "/opt/homebrew/bin/am");
|
||||
assert!(plist.contains("com.agent-manager.aider"));
|
||||
assert!(plist.contains("<string>/opt/homebrew/bin/am</string>"));
|
||||
assert!(plist.contains("<key>RunAtLoad</key>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_task_xml_has_action() {
|
||||
let xml = windows_task_xml("codex", "C:\\bin\\am.exe");
|
||||
assert!(xml.contains("LogonTrigger"));
|
||||
assert!(xml.contains("am.exe"));
|
||||
assert!(xml.contains("start codex --background --notify"));
|
||||
assert!(xml.contains("RestartOnFailure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crontab_line_formats_time_and_id() {
|
||||
let line = crontab_line("22:05", "/usr/bin/am", "sched-1");
|
||||
assert_eq!(line, "5 22 * * * /usr/bin/am schedule-run sched-1");
|
||||
let line = crontab_line("", "/usr/bin/am", "sched-2");
|
||||
assert_eq!(line, "0 8 * * * /usr/bin/am schedule-run sched-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hhmm_is_lenient() {
|
||||
assert_eq!(parse_hhmm("09:30"), (9, 30));
|
||||
assert_eq!(parse_hhmm("garbage"), (8, 0));
|
||||
assert_eq!(parse_hhmm("25:99"), (23, 59));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthcheck_command_exit_0_is_healthy() {
|
||||
let hc = HealthCheck {
|
||||
command: Some("true".to_string()),
|
||||
port: None,
|
||||
timeout_secs: Some(5),
|
||||
};
|
||||
assert!(healthcheck_once(&hc));
|
||||
let bad = HealthCheck {
|
||||
command: Some("false".to_string()),
|
||||
port: None,
|
||||
timeout_secs: Some(5),
|
||||
};
|
||||
assert!(!healthcheck_once(&bad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthcheck_without_config_skips() {
|
||||
let app = test_app();
|
||||
assert!(wait_health("x", None, &app.log).is_ok());
|
||||
}
|
||||
|
||||
fn test_app() -> crate::app::App {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
" confirm_before_run: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
use clap::Parser;
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = crate::app::App::from_cli(cli).unwrap();
|
||||
app.paths.state_file = dir.path().join("state.json");
|
||||
app.state = crate::state::StateStore::new(dir.path().join("state.json"));
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_command_mounts_and_env() {
|
||||
let spec = ContainerSpec {
|
||||
image: "node:20".to_string(),
|
||||
volumes: vec!["/host/work:/work:ro".to_string()],
|
||||
network: None,
|
||||
env: BTreeMap::from([("NODE_ENV".to_string(), "production".to_string())]),
|
||||
};
|
||||
let cmd = container_command(
|
||||
"docker",
|
||||
"my-agent",
|
||||
&spec,
|
||||
Path::new("/state"),
|
||||
Path::new("/logs"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd[0], "docker");
|
||||
assert!(cmd.contains(&"run".to_string()));
|
||||
assert!(cmd.contains(&"-v".to_string()));
|
||||
assert!(cmd.contains(&"/state:/data".to_string()));
|
||||
assert!(cmd.contains(&"/logs:/logs".to_string()));
|
||||
assert!(cmd.contains(&"--network".to_string()));
|
||||
assert!(cmd.contains(&"-e".to_string()));
|
||||
assert!(cmd.contains(&"NODE_ENV=production".to_string()));
|
||||
assert_eq!(cmd.last().unwrap(), "node:20");
|
||||
// Empty image fails clearly.
|
||||
let bad = ContainerSpec { image: String::new(), ..Default::default() };
|
||||
assert!(container_command("docker", "a", &bad, Path::new("/s"), Path::new("/l")).is_err());
|
||||
}
|
||||
}
|
||||
+66
-1
@@ -177,6 +177,12 @@ pub enum Command {
|
||||
Secret(SecretCmd),
|
||||
/// Audit the configuration: who changed what, when (issue #63)
|
||||
Audit,
|
||||
/// Register an agent as a system service (issue #55)
|
||||
#[command(subcommand)]
|
||||
Service(ServiceCmd),
|
||||
/// Schedule am commands (issue #56)
|
||||
#[command(subcommand)]
|
||||
Schedule(ScheduleCmd),
|
||||
/// List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
Models(ModelsArgs),
|
||||
/// Manage remote catalogs: update the official one, add external ones
|
||||
@@ -412,6 +418,9 @@ pub enum Command {
|
||||
/// Attempt to repair problems (create directories, fix the state file)
|
||||
#[arg(long)]
|
||||
fix: bool,
|
||||
/// Re-run the checks every N seconds, alerting on failure (issue #59)
|
||||
#[arg(long, value_name = "SECONDS")]
|
||||
watch: Option<u64>,
|
||||
},
|
||||
/// Run the agent command directly with the given arguments (no process management)
|
||||
Run {
|
||||
@@ -420,6 +429,9 @@ pub enum Command {
|
||||
/// Local model to use for this run (validated against the local runtimes)
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
model: Option<String>,
|
||||
/// Run the agent inside a container (issue #58)
|
||||
#[arg(long)]
|
||||
container: bool,
|
||||
/// Arguments passed through to the agent command
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS...")]
|
||||
args: Vec<std::ffi::OsString>,
|
||||
@@ -488,6 +500,12 @@ pub struct StartArgs {
|
||||
/// Local model to use for this run (validated against the local runtimes)
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
pub model: Option<String>,
|
||||
/// Start every member of a group simultaneously (issue #57)
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub parallel: bool,
|
||||
/// Run the agent inside a container (issue #58)
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub container: bool,
|
||||
}
|
||||
|
||||
/// Arguments of the models command: inventory by default, prune with --prune.
|
||||
@@ -534,7 +552,54 @@ pub enum ProfileCmd {
|
||||
},
|
||||
}
|
||||
|
||||
/// Secret management subcommands.
|
||||
/// 'am service' subcommands (issue #55).
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ServiceCmd {
|
||||
/// Install the service unit (systemd / launchd / Task Scheduler)
|
||||
Install {
|
||||
/// Agent name
|
||||
agent: String,
|
||||
/// Enable autostart (boot/logon)
|
||||
#[arg(long)]
|
||||
autostart: bool,
|
||||
},
|
||||
/// Remove the service unit
|
||||
Uninstall {
|
||||
/// Agent name
|
||||
agent: String,
|
||||
},
|
||||
/// Show whether the service is installed
|
||||
Status {
|
||||
/// Agent name
|
||||
agent: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 'am schedule' subcommands (issue #56).
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ScheduleCmd {
|
||||
/// Add a daily schedule (ex: 'am schedule add update --all')
|
||||
Add {
|
||||
/// Command to run, e.g. ["update", "--all"]
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "CMD...")]
|
||||
command: Vec<String>,
|
||||
/// Time of day HH:MM (default 08:00)
|
||||
#[arg(long, value_name = "HH:MM")]
|
||||
at: Option<String>,
|
||||
},
|
||||
/// List the schedules
|
||||
List,
|
||||
/// Remove a schedule by id
|
||||
Remove {
|
||||
/// Schedule id ('am schedule list' shows them)
|
||||
id: String,
|
||||
},
|
||||
/// Run a schedule now (invoked by the OS task, issue #56)
|
||||
Run {
|
||||
/// Schedule id
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum SecretCmd {
|
||||
/// Store a secret for an agent
|
||||
|
||||
@@ -7,7 +7,10 @@ use crate::output::{boxed_table, print_json, status_style};
|
||||
use crate::runner::SystemRunner;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn run(app: &App, fix: bool) -> Result<i32> {
|
||||
pub fn run(app: &App, fix: bool, watch: Option<u64>) -> Result<i32> {
|
||||
if let Some(interval) = watch {
|
||||
return watch_loop(app, interval.max(1));
|
||||
}
|
||||
if fix {
|
||||
let repaired = doctor::fix(app)?;
|
||||
for r in &repaired {
|
||||
@@ -61,3 +64,41 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
|
||||
}
|
||||
Ok(if report.ok() { 0 } else { 1 })
|
||||
}
|
||||
|
||||
/// Issue #59: re-run the doctor checks every N seconds; alert (desktop
|
||||
/// notification + event) on the first failure, recover silently. Clean
|
||||
/// Ctrl-C via ctrlc.
|
||||
fn watch_loop(app: &App, interval: u64) -> Result<i32> {
|
||||
app.log
|
||||
.info(&format!("watching every {interval}s (Ctrl-C to stop)"));
|
||||
let mut was_ok = true;
|
||||
loop {
|
||||
let runner = SystemRunner::new(false, false, &app.log);
|
||||
let report = doctor::run(app, &runner);
|
||||
let ok = report.ok();
|
||||
if !ok && was_ok {
|
||||
// First failure: alert.
|
||||
app.emit(
|
||||
&Event::now(EventKind::Doctor)
|
||||
.with_exit_code(1)
|
||||
.with_reason(format!("{} problem(s) found", report.problems())),
|
||||
);
|
||||
app.log.error(&format!(
|
||||
"{} problem(s) found — 'am doctor --fix' repairs what it can",
|
||||
report.problems()
|
||||
));
|
||||
let _ = crate::process::desktop_notify(
|
||||
"agent-manager",
|
||||
&format!("doctor: {} problem(s)", report.problems()),
|
||||
);
|
||||
was_ok = false;
|
||||
} else if ok && !was_ok {
|
||||
app.log.success("environment recovered");
|
||||
was_ok = true;
|
||||
} else if !ok {
|
||||
app.log
|
||||
.warn(&format!("still {} problem(s)", report.problems()));
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(interval));
|
||||
}
|
||||
}
|
||||
|
||||
+40
-2
@@ -24,7 +24,9 @@ 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;
|
||||
@@ -184,8 +186,44 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
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 } => doctor_cmd::run(app, *fix),
|
||||
Command::Run { agent, model, args } => run_cmd::run(app, agent, model.as_deref(), args),
|
||||
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),
|
||||
|
||||
+71
-1
@@ -84,8 +84,15 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
|
||||
if members.is_empty() {
|
||||
bail!("unknown group '{group}'");
|
||||
}
|
||||
// Issue #57: --parallel launches every member back-to-back without
|
||||
// waiting (the OS spawns them concurrently); the default waits each
|
||||
// health check before the next member.
|
||||
for m in members {
|
||||
start_one(app, m, &extra_args, &extra_env, opts.notify, true, cwd)?;
|
||||
if !opts.parallel {
|
||||
// Issue #57: block until healthy before the next member.
|
||||
crate::automation::wait_health(&m.name, m.healthcheck.as_ref(), &app.log)?;
|
||||
}
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -309,8 +316,18 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
|
||||
}
|
||||
|
||||
/// run: execute the agent command directly, no process management.
|
||||
pub fn run(app: &App, target: &str, model: Option<&str>, extra: &[OsString]) -> Result<i32> {
|
||||
pub fn run(
|
||||
app: &App,
|
||||
target: &str,
|
||||
model: Option<&str>,
|
||||
container: bool,
|
||||
extra: &[OsString],
|
||||
) -> Result<i32> {
|
||||
let agent = require_agent(app, target)?;
|
||||
// Issue #58: --container runs the agent inside its container profile.
|
||||
if container {
|
||||
return run_container(app, agent, extra);
|
||||
}
|
||||
let mut extra_args: Vec<String> = extra
|
||||
.iter()
|
||||
.map(|o| o.to_string_lossy().to_string())
|
||||
@@ -353,6 +370,59 @@ pub fn run(app: &App, target: &str, model: Option<&str>, extra: &[OsString]) ->
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Issue #58: run the agent inside its container profile. Detects the
|
||||
/// runtime (podman > docker), mounts state/log dirs and the profile
|
||||
/// volumes/network/env, then execs the container in the foreground.
|
||||
fn run_container(app: &App, agent: &crate::config::AgentDef, extra: &[OsString]) -> Result<i32> {
|
||||
let Some(spec) = &agent.container else {
|
||||
bail!(
|
||||
"agent '{}' has no container profile — add 'container: {{image: ...}}' to its definition",
|
||||
agent.name
|
||||
);
|
||||
};
|
||||
let Some(runtime) = crate::automation::detect_runtime() else {
|
||||
bail!(
|
||||
"no container runtime found (docker or podman) — install one or drop --container"
|
||||
);
|
||||
};
|
||||
let state_dir = app.paths.state_file.parent().unwrap_or(std::path::Path::new("."));
|
||||
let log_dir = &app.paths.log_dir;
|
||||
let mut cmd = crate::automation::container_command(
|
||||
&runtime,
|
||||
&agent.name,
|
||||
spec,
|
||||
state_dir,
|
||||
log_dir,
|
||||
)?;
|
||||
// The agent's run command + extra args become the container command.
|
||||
let exec = resolve_exec(app, agent, &[], &BTreeMap::new())?;
|
||||
cmd.push(exec.program);
|
||||
cmd.extend(exec.args.iter().cloned());
|
||||
cmd.extend(extra.iter().map(|o| o.to_string_lossy().to_string()));
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!("would run: {}", cmd.join(" ")));
|
||||
return Ok(0);
|
||||
}
|
||||
app.log.info(&format!(
|
||||
"running '{}' in a container ({runtime})",
|
||||
agent.name
|
||||
));
|
||||
let status = Command::new(&cmd[0])
|
||||
.args(&cmd[1..])
|
||||
.status()
|
||||
.with_context(|| format!("failed to run {}", cmd.join(" ")))?;
|
||||
let code = status.code().unwrap_or(1);
|
||||
app.emit(
|
||||
&Event::now(EventKind::Run)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_reason(format!("container ({runtime})"))
|
||||
.with_args(cmd.clone())
|
||||
.with_exit_code(code),
|
||||
);
|
||||
let _ = app.state.touch(&agent.name);
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Current directory as a string (None when unavailable).
|
||||
fn cwd_string() -> Option<String> {
|
||||
std::env::current_dir().ok().map(|p| p.display().to_string())
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! schedule: plan am commands (axe 6, issue #56). Schedules live in
|
||||
//! state.json; a daily OS-level task (crontab / Task Scheduler) points back
|
||||
//! at 'am schedule-run <id>' which executes the stored command, records the
|
||||
//! result and notifies on failure.
|
||||
|
||||
use super::*;
|
||||
use crate::automation;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::state::ScheduleEntry;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::process::Command;
|
||||
|
||||
pub fn run(app: &App, action: &str, command: Vec<String>, at: Option<&str>) -> Result<i32> {
|
||||
match action {
|
||||
"add" => add(app, command, at),
|
||||
"list" => list(app),
|
||||
"remove" => {
|
||||
let id = command
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("schedule remove requires an id"))?;
|
||||
remove(app, id)
|
||||
}
|
||||
"run" => {
|
||||
let id = command
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("schedule-run requires an id"))?;
|
||||
run_one(app, id)
|
||||
}
|
||||
_ => bail!("usage: am schedule add <cmd...> | am schedule list | am schedule remove <id> | am schedule run <id>"),
|
||||
}
|
||||
}
|
||||
|
||||
fn task_name(id: &str) -> String {
|
||||
format!("am-schedule-{id}")
|
||||
}
|
||||
|
||||
/// Register the OS-level daily task (crontab on unix, schtasks on Windows).
|
||||
/// Best effort: the schedule stays in state.json even if the OS layer
|
||||
/// cannot be reached (warned, not fatal).
|
||||
fn register_os_task(app: &App, entry: &ScheduleEntry) {
|
||||
let am_bin = automation::am_bin_quoted();
|
||||
let id = &entry.id;
|
||||
if cfg!(windows) {
|
||||
let status = automation::schtasks_daily(&entry.at, &am_bin, id, &task_name(id))
|
||||
.status()
|
||||
.unwrap_or_default();
|
||||
if !status.success() {
|
||||
app.log
|
||||
.warn("schtasks failed — the schedule is stored but not registered at OS level");
|
||||
}
|
||||
} else {
|
||||
// Append to the user crontab (dedup first).
|
||||
let existing = Command::new("crontab")
|
||||
.arg("-l")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
if existing.contains(&format!("schedule-run {id}")) {
|
||||
return;
|
||||
}
|
||||
let line = automation::crontab_line(&entry.at, &am_bin, id);
|
||||
let mut next = existing.trim_end().to_string();
|
||||
next.push('\n');
|
||||
next.push_str(&line);
|
||||
next.push('\n');
|
||||
let mut child = Command::new("crontab")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("crontab spawn");
|
||||
use std::io::Write;
|
||||
let _ = child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.map(|s| s.write_all(next.as_bytes()));
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn add(app: &App, command: Vec<String>, at: Option<&str>) -> Result<i32> {
|
||||
if command.is_empty() {
|
||||
bail!("schedule add requires a command — ex: am schedule add update --all");
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!("would schedule: {}", command.join(" ")));
|
||||
return Ok(0);
|
||||
}
|
||||
let id = crate::repl::session_id();
|
||||
let entry = ScheduleEntry {
|
||||
id,
|
||||
cadence: "daily".to_string(),
|
||||
command,
|
||||
at: at.unwrap_or("08:00").to_string(),
|
||||
created_at: crate::installers::now_rfc3339(),
|
||||
last_run: None,
|
||||
last_exit: None,
|
||||
};
|
||||
let mut sf = app.state.load()?;
|
||||
sf.schedules.push(entry.clone());
|
||||
app.state.save(&sf)?;
|
||||
register_os_task(app, &entry);
|
||||
app.emit(
|
||||
&Event::now(EventKind::Config)
|
||||
.with_agent("schedule".to_string())
|
||||
.with_reason(format!("schedule add {} ({})", entry.id, entry.at)),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"scheduled daily at {}: {} (id {})",
|
||||
entry.at,
|
||||
entry.command.join(" "),
|
||||
entry.id
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn list(app: &App) -> Result<i32> {
|
||||
let sf = app.state.load()?;
|
||||
if sf.schedules.is_empty() {
|
||||
app.log.info("no schedule — 'am schedule add <cmd>' to create one (ex: am schedule add update --all)");
|
||||
return Ok(0);
|
||||
}
|
||||
println!("schedules:");
|
||||
for e in &sf.schedules {
|
||||
let last = e
|
||||
.last_run
|
||||
.as_deref()
|
||||
.map(|t| {
|
||||
e.last_exit
|
||||
.map(|c| format!("{t} (exit {c})"))
|
||||
.unwrap_or_else(|| t.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "never".to_string());
|
||||
println!(
|
||||
" {} {} {} cmd: {} last: {}",
|
||||
e.id,
|
||||
e.cadence,
|
||||
e.at,
|
||||
e.command.join(" "),
|
||||
last
|
||||
);
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn remove(app: &App, id: &str) -> Result<i32> {
|
||||
let mut sf = app.state.load()?;
|
||||
let before = sf.schedules.len();
|
||||
sf.schedules.retain(|e| e.id != id);
|
||||
if sf.schedules.len() == before {
|
||||
bail!("unknown schedule '{id}' — 'am schedule list' shows them");
|
||||
}
|
||||
app.state.save(&sf)?;
|
||||
// Best-effort OS cleanup.
|
||||
if cfg!(windows) {
|
||||
let _ = Command::new("schtasks")
|
||||
.args(["/delete", "/tn", &task_name(id), "/f"])
|
||||
.status();
|
||||
} else {
|
||||
let existing = Command::new("crontab")
|
||||
.arg("-l")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
let cleaned: Vec<&str> = existing
|
||||
.lines()
|
||||
.filter(|l| !l.contains(&format!("schedule-run {id}")))
|
||||
.collect();
|
||||
let text = format!("{}\n", cleaned.join("\n"));
|
||||
let mut child = Command::new("crontab")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("crontab spawn");
|
||||
use std::io::Write;
|
||||
let _ = child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.map(|s| s.write_all(text.as_bytes()));
|
||||
let _ = child.wait();
|
||||
}
|
||||
app.log.success(&format!("schedule '{id}' removed"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Execute one schedule (invoked by the OS task). Records last_run /
|
||||
/// last_exit, emits a Schedule event and notifies on failure.
|
||||
fn run_one(app: &App, id: &str) -> Result<i32> {
|
||||
let sf = app.state.load()?;
|
||||
let entry = sf
|
||||
.schedules
|
||||
.iter()
|
||||
.find(|e| e.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("unknown schedule '{id}'"))?;
|
||||
app.log.info(&format!("running schedule {id}: {}", entry.command.join(" ")));
|
||||
let code = automation::schedule_run_command(app, &entry)?;
|
||||
// Record the outcome.
|
||||
let mut sf = app.state.load()?;
|
||||
if let Some(e) = sf.schedules.iter_mut().find(|e| e.id == id) {
|
||||
e.last_run = Some(crate::installers::now_rfc3339());
|
||||
e.last_exit = Some(code);
|
||||
}
|
||||
app.state.save(&sf)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Run)
|
||||
.with_agent("schedule".to_string())
|
||||
.with_exit_code(code)
|
||||
.with_reason(format!("schedule {id}: {}", entry.command.join(" "))),
|
||||
);
|
||||
if code == 0 {
|
||||
app.log.success(&format!("schedule {id} completed"));
|
||||
} else {
|
||||
app.log.error(&format!(
|
||||
"schedule {id} FAILED (exit {code}) — command: {}",
|
||||
entry.command.join(" ")
|
||||
));
|
||||
}
|
||||
Ok(code)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! service: register an agent as a system service with autostart (issue #55).
|
||||
//! systemd user unit on Linux, launchd LaunchAgent on macOS, Task Scheduler
|
||||
//! on Windows. Logs go through the existing 'am start --background' runner.
|
||||
|
||||
use super::*;
|
||||
use crate::automation;
|
||||
use crate::events::{Event, EventKind};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn run(app: &App, action: &str, agent: Option<&str>, autostart: bool) -> Result<i32> {
|
||||
match action {
|
||||
"install" => {
|
||||
let name = agent.ok_or_else(|| anyhow!("service install requires an agent name"))?;
|
||||
install(app, name, autostart)
|
||||
}
|
||||
"uninstall" => {
|
||||
let name = agent.ok_or_else(|| anyhow!("service uninstall requires an agent name"))?;
|
||||
uninstall(app, name)
|
||||
}
|
||||
"status" => {
|
||||
let name = agent.ok_or_else(|| anyhow!("service status requires an agent name"))?;
|
||||
status(app, name)
|
||||
}
|
||||
_ => bail!("usage: am service install|uninstall|status <agent> [--autostart]"),
|
||||
}
|
||||
}
|
||||
|
||||
fn unit_path(agent: &str) -> PathBuf {
|
||||
if cfg!(target_os = "linux") {
|
||||
automation::systemd_unit_path(agent)
|
||||
} else if cfg!(target_os = "macos") {
|
||||
automation::launchd_path(agent)
|
||||
} else {
|
||||
// Windows: no file — the task lives in the Task Scheduler.
|
||||
PathBuf::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn install(app: &App, agent: &str, autostart: bool) -> Result<i32> {
|
||||
require_agent(app, agent)?;
|
||||
if app.dry_run() {
|
||||
app.log
|
||||
.dry(format!("would install '{agent}' as a system service (autostart: {autostart})"));
|
||||
return Ok(0);
|
||||
}
|
||||
let am_bin = automation::am_bin_quoted();
|
||||
let mut wrote = String::new();
|
||||
if cfg!(target_os = "linux") {
|
||||
let path = automation::systemd_unit_path(agent);
|
||||
if let Some(p) = path.parent() {
|
||||
std::fs::create_dir_all(p).with_context(|| format!("cannot create {}", p.display()))?;
|
||||
}
|
||||
std::fs::write(&path, automation::systemd_unit(agent, &am_bin))
|
||||
.with_context(|| format!("cannot write {}", path.display()))?;
|
||||
wrote = path.display().to_string();
|
||||
// Enable + start the user unit.
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "daemon-reload"])
|
||||
.status();
|
||||
if autostart {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "enable", &format!("am-{agent}.service")])
|
||||
.status();
|
||||
}
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "start", &format!("am-{agent}.service")])
|
||||
.status();
|
||||
} else if cfg!(target_os = "macos") {
|
||||
let path = automation::launchd_path(agent);
|
||||
if let Some(p) = path.parent() {
|
||||
std::fs::create_dir_all(p).with_context(|| format!("cannot create {}", p.display()))?;
|
||||
}
|
||||
std::fs::write(&path, automation::launchd_plist(agent, &am_bin))
|
||||
.with_context(|| format!("cannot write {}", path.display()))?;
|
||||
wrote = path.display().to_string();
|
||||
if autostart {
|
||||
let _ = Command::new("launchctl")
|
||||
.args(["load", path.to_str().unwrap_or_default()])
|
||||
.status();
|
||||
}
|
||||
} else {
|
||||
// Windows: prefer a logon task. /sc onlogon needs admin on some
|
||||
// setups; fall back to the Startup folder (no admin required).
|
||||
let task = automation::windows_task_name(agent);
|
||||
let am_bin = std::env::current_exe().unwrap_or_default();
|
||||
let tr = format!("\"{}\" start {} --background --notify", am_bin.display(), agent);
|
||||
let status = Command::new("schtasks")
|
||||
.args(["/create", "/tn", &task, "/sc", "onlogon", "/tr", &tr, "/f"])
|
||||
.status()?;
|
||||
if status.success() {
|
||||
wrote = format!("Task Scheduler: {task}");
|
||||
} else {
|
||||
let startup = crate::config::home_dir()
|
||||
.map(|h| {
|
||||
h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let script = startup.join(format!("am-{agent}.cmd"));
|
||||
if let Some(p) = script.parent() {
|
||||
std::fs::create_dir_all(p)
|
||||
.with_context(|| format!("cannot create {}", p.display()))?;
|
||||
}
|
||||
std::fs::write(&script, format!("@echo off\r\nstart \"\" /b {tr}\r\n"))
|
||||
.with_context(|| format!("cannot write {}", script.display()))?;
|
||||
wrote = format!("Startup folder: {}", script.display());
|
||||
}
|
||||
}
|
||||
app.emit(
|
||||
&Event::now(EventKind::Config)
|
||||
.with_agent(agent.to_string())
|
||||
.with_reason(format!("service install (autostart: {autostart})")),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"service '{agent}' installed{} ({wrote})",
|
||||
if autostart { " with autostart" } else { "" }
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn uninstall(app: &App, agent: &str) -> Result<i32> {
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would remove the service for '{agent}'"));
|
||||
return Ok(0);
|
||||
}
|
||||
let mut removed = String::new();
|
||||
if cfg!(target_os = "linux") {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "stop", &format!("am-{agent}.service")])
|
||||
.status();
|
||||
if automation::systemd_unit_path(agent).exists() {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "disable", &format!("am-{agent}.service")])
|
||||
.status();
|
||||
std::fs::remove_file(automation::systemd_unit_path(agent))?;
|
||||
}
|
||||
let _ = Command::new("systemctl").args(["--user", "daemon-reload"]).status();
|
||||
removed = "systemd unit removed".to_string();
|
||||
} else if cfg!(target_os = "macos") {
|
||||
let path = automation::launchd_path(agent);
|
||||
if path.exists() {
|
||||
let _ = Command::new("launchctl").args(["unload", path.to_str().unwrap_or_default()]).status();
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
removed = "launchd plist removed".to_string();
|
||||
} else {
|
||||
let task = automation::windows_task_name(agent);
|
||||
let _ = Command::new("schtasks")
|
||||
.args(["/delete", "/tn", &task, "/f"])
|
||||
.status();
|
||||
let mut parts = vec![format!("Task Scheduler: {task}")];
|
||||
// Also remove the Startup folder fallback if present.
|
||||
if let Some(h) = crate::config::home_dir() {
|
||||
let script = h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
|
||||
.join(format!("am-{agent}.cmd"));
|
||||
if script.exists() {
|
||||
std::fs::remove_file(&script).with_context(|| {
|
||||
format!("cannot remove {}", script.display())
|
||||
})?;
|
||||
parts.push("Startup script removed".to_string());
|
||||
}
|
||||
}
|
||||
removed = parts.join(" · ");
|
||||
}
|
||||
app.emit(
|
||||
&Event::now(EventKind::Config)
|
||||
.with_agent(agent.to_string())
|
||||
.with_reason("service uninstall"),
|
||||
);
|
||||
app.log.success(&format!("service '{agent}' removed ({removed})"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn status(app: &App, agent: &str) -> Result<i32> {
|
||||
let mut installed = false;
|
||||
if cfg!(target_os = "linux") {
|
||||
installed = automation::systemd_unit_path(agent).exists();
|
||||
} else if cfg!(target_os = "macos") {
|
||||
installed = automation::launchd_path(agent).exists();
|
||||
} else {
|
||||
let task = automation::windows_task_name(agent);
|
||||
let status = Command::new("schtasks")
|
||||
.args(["/query", "/tn", &task])
|
||||
.status()
|
||||
.unwrap_or_default();
|
||||
installed = status.success();
|
||||
if !installed {
|
||||
// Startup folder fallback?
|
||||
installed = crate::config::home_dir()
|
||||
.map(|h| {
|
||||
h.join("AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
|
||||
.join(format!("am-{agent}.cmd"))
|
||||
.exists()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
}
|
||||
}
|
||||
if installed {
|
||||
println!("service '{agent}': installed");
|
||||
Ok(0)
|
||||
} else {
|
||||
app.log.info(&format!("service '{agent}': not installed"));
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,8 @@ mod tests {
|
||||
note: None,
|
||||
hidden: false,
|
||||
platforms: vec![],
|
||||
healthcheck: None,
|
||||
container: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,36 @@ pub static SECTIONS: &[TipSection] = &[
|
||||
options: &[("--json", "rapport machine-readable")],
|
||||
example: "audit --json",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "service install <agent> --autostart",
|
||||
about: "agent en service système (systemd / launchd / tâche Windows) avec démarrage auto",
|
||||
options: &[
|
||||
("status <agent>", "état du service"),
|
||||
("uninstall <agent>", "retire le service"),
|
||||
],
|
||||
example: "service install claude-code --autostart",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "schedule add <cmd...> --at HH:MM",
|
||||
about: "planifie une commande am quotidienne (ex: update --all le matin)",
|
||||
options: &[
|
||||
("schedule list", "liste les planifications"),
|
||||
("schedule remove <id>", "supprime une planification"),
|
||||
],
|
||||
example: "schedule add update --all --at 06:30",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "doctor --watch <secondes>",
|
||||
about: "vérifications de l'environnement en continu, alerte dès qu'un problème apparaît",
|
||||
options: &[("--fix", "répare automatiquement ce qui peut l'être")],
|
||||
example: "doctor --watch 60",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "start group:dev --parallel",
|
||||
about: "démarre un groupe complet (ordre + attente de santé par défaut, --parallel sans attente)",
|
||||
options: &[("run <agent> --container", "exécute l'agent dans son profil conteneur (docker/podman)")],
|
||||
example: "start group:dev --parallel",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "update --rollback",
|
||||
about: "annule la dernière mise à jour (backup automatique avant chaque update)",
|
||||
|
||||
@@ -244,6 +244,46 @@ pub struct AgentDef {
|
||||
/// Restrict the agent to platforms: linux, macos, windows (empty = all).
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
/// Health check waited before the next orchestration step (issue #57).
|
||||
#[serde(default)]
|
||||
pub healthcheck: Option<HealthCheck>,
|
||||
/// Container profile used by 'am run --container' (issue #58).
|
||||
#[serde(default)]
|
||||
pub container: Option<ContainerSpec>,
|
||||
}
|
||||
|
||||
/// Health check for group orchestration (issue #57): a command that must
|
||||
/// exit 0, or a TCP port that must accept connections, within timeout_secs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HealthCheck {
|
||||
/// Shell command to probe (exit 0 = healthy).
|
||||
#[serde(default)]
|
||||
pub command: Option<String>,
|
||||
/// TCP port to connect to (accepts = healthy).
|
||||
#[serde(default)]
|
||||
pub port: Option<u16>,
|
||||
/// Seconds to wait before declaring failure (default 30).
|
||||
#[serde(default)]
|
||||
pub timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// Container profile (issue #58): image, volumes, network and env applied
|
||||
/// by 'am run <agent> --container'.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ContainerSpec {
|
||||
/// Container image (required).
|
||||
pub image: String,
|
||||
/// Extra volumes "host:container[:ro]" on top of state_dir/log_dir.
|
||||
#[serde(default)]
|
||||
pub volumes: Vec<String>,
|
||||
/// Network (default: host on Linux, bridge elsewhere).
|
||||
#[serde(default)]
|
||||
pub network: Option<String>,
|
||||
/// Extra environment variables inside the container.
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
|
||||
+66
-9
@@ -138,6 +138,18 @@ const START_FLAGS: &[HelpFlag] = &[
|
||||
value: "",
|
||||
desc: "Send a desktop notification once the agent has started",
|
||||
},
|
||||
HelpFlag {
|
||||
short: "",
|
||||
long: "--parallel",
|
||||
value: "",
|
||||
desc: "Start every group member back-to-back without waiting (issue #57)",
|
||||
},
|
||||
HelpFlag {
|
||||
short: "",
|
||||
long: "--container",
|
||||
value: "",
|
||||
desc: "Run inside the agent's container profile (issue #58)",
|
||||
},
|
||||
];
|
||||
|
||||
pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
@@ -623,7 +635,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
category: "Commands",
|
||||
usage: "start {flags} <agent>",
|
||||
about: "Start an agent (foreground by default, or detached with --background). --profile applies an environment profile (env + args).",
|
||||
search_terms: &["launch", "begin"],
|
||||
search_terms: &["launch", "begin", "group", "parallel", "healthcheck"],
|
||||
flags: START_FLAGS,
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
@@ -633,7 +645,8 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
examples: &[
|
||||
HelpExample { desc: "Start an agent in the background.", code: "start claude-code --background" },
|
||||
HelpExample { desc: "Start with extra arguments.", code: "start claude-code --args \"--model sonnet\"" },
|
||||
HelpExample { desc: "Start every agent of a group.", code: "start group:dev --background" },
|
||||
HelpExample { desc: "Start every agent of a group, waiting each health check.", code: "start group:dev --background" },
|
||||
HelpExample { desc: "Start a group without waiting.", code: "start group:dev --parallel" },
|
||||
HelpExample { desc: "Start with an environment profile.", code: "start claude-code --profile prod" },
|
||||
],
|
||||
},
|
||||
@@ -687,8 +700,11 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
category: "Commands",
|
||||
usage: "run {flags} <agent> ...(args)",
|
||||
about: "Run the agent command directly with the given arguments (no process management).",
|
||||
search_terms: &["exec", "pass-through"],
|
||||
flags: &[],
|
||||
search_terms: &["exec", "pass-through", "container", "docker", "podman"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Local model to use (issue #71)" },
|
||||
HelpFlag { short: "", long: "--container", value: "", desc: "Run inside the agent's container profile (issue #58)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
|
||||
@@ -704,17 +720,58 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
name: "doctor",
|
||||
category: "Commands",
|
||||
usage: "doctor {flags}",
|
||||
about: "Check the environment: tools, config validity, paths, permissions.",
|
||||
search_terms: &["check", "environment", "repair"],
|
||||
about: "Check the environment; --watch re-runs periodically and alerts on failure.",
|
||||
search_terms: &["health", "watch", "check", "environment", "repair"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--fix", value: "", desc: "Attempt to repair problems (create directories, fix the state file)" },
|
||||
HelpFlag { short: "", long: "--fix", value: "", desc: "Repair what can be repaired automatically" },
|
||||
HelpFlag { short: "", long: "--watch", value: "SECONDS", desc: "Re-run the checks every N seconds; alert + event on failure, recover silently" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Check the environment.", code: "doctor" },
|
||||
HelpExample { desc: "Check and repair.", code: "doctor --fix" },
|
||||
HelpExample { desc: "One-shot environment report.", code: "doctor" },
|
||||
HelpExample { desc: "Watch every 30s, alerting on failure.", code: "doctor --watch 30" },
|
||||
HelpExample { desc: "Repair what can be fixed.", code: "doctor --fix" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "service",
|
||||
category: "Commands",
|
||||
usage: "service install|uninstall|status <agent> {flags}",
|
||||
about: "Register an agent as a system service (systemd / launchd / Task Scheduler).",
|
||||
search_terms: &["systemd", "autostart", "launchd", "task scheduler", "daemon"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--autostart", value: "", desc: "Start the service at boot/logon" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
HelpParam { name: "agent", typ: "string", desc: "Agent name" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Install with autostart.", code: "service install claude-code --autostart" },
|
||||
HelpExample { desc: "Remove the service.", code: "service uninstall claude-code" },
|
||||
HelpExample { desc: "Check the service.", code: "service status claude-code" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "schedule",
|
||||
category: "Commands",
|
||||
usage: "schedule add|list|remove <cmd...> {flags}",
|
||||
about: "Plan am commands (daily) and run them with health reporting.",
|
||||
search_terms: &["cron", "daily", "task scheduler", "scheduled", "planning"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--at", value: "HH:MM", desc: "Time of day (default 08:00)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Daily update of every agent at 06:30.", code: "schedule add update --all --at 06:30" },
|
||||
HelpExample { desc: "List the schedules.", code: "schedule list" },
|
||||
HelpExample { desc: "Remove a schedule.", code: "schedule remove <id>" },
|
||||
HelpExample { desc: "Run a schedule now (used by the OS task).", code: "schedule run <id>" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! * commands — one module per CLI command.
|
||||
|
||||
pub mod app;
|
||||
pub mod automation;
|
||||
pub mod backup;
|
||||
pub mod catalog;
|
||||
pub mod templates;
|
||||
|
||||
+58
-1
@@ -110,6 +110,8 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("catalog", "manage remote catalogs"),
|
||||
("suggest", "recommend an agent for a request"),
|
||||
("audit", "who changed what, when (config checksums)"),
|
||||
("service", "register an agent as a system service (autostart)"),
|
||||
("schedule", "plan am commands (daily) and check the fleet health"),
|
||||
("shell", "show or switch the system shell"),
|
||||
("theme", "show or switch the color theme"),
|
||||
("tip", "cheat sheet of the most useful commands"),
|
||||
@@ -224,6 +226,7 @@ impl AmCompleter {
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "theme",
|
||||
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
|
||||
"profile", "man", "models", "catalog", "suggest", "audit",
|
||||
"service", "schedule",
|
||||
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
||||
],
|
||||
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
||||
@@ -772,6 +775,9 @@ pub fn banner_box(
|
||||
rows.push(inner(
|
||||
" models models · models --prune · catalog · suggest · audit".to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" automate service install · schedule add · doctor --watch".to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" system self-update · self-uninstall · export · import".to_string(),
|
||||
));
|
||||
@@ -1632,12 +1638,63 @@ fn handle_line(
|
||||
}
|
||||
}
|
||||
"audit" => Command::Audit,
|
||||
"doctor" => Command::Doctor { fix: flag("--fix") },
|
||||
"service" => match rest.first().map(|s| s.as_str()) {
|
||||
Some(action) if rest.len() >= 2 => Command::Service(match action {
|
||||
"install" => crate::cli::ServiceCmd::Install {
|
||||
agent: rest[1].clone(),
|
||||
autostart: flag("--autostart"),
|
||||
},
|
||||
"uninstall" => crate::cli::ServiceCmd::Uninstall {
|
||||
agent: rest[1].clone(),
|
||||
},
|
||||
"status" => crate::cli::ServiceCmd::Status {
|
||||
agent: rest[1].clone(),
|
||||
},
|
||||
_ => {
|
||||
app.log
|
||||
.error("usage: service install|uninstall|status <agent> [--autostart]");
|
||||
return Ok(false);
|
||||
}
|
||||
}),
|
||||
_ => {
|
||||
app.log
|
||||
.error("usage: service install|uninstall|status <agent> [--autostart]");
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
"schedule" => match rest.first().map(|s| s.as_str()) {
|
||||
Some("add") => Command::Schedule(crate::cli::ScheduleCmd::Add {
|
||||
command: rest[1..]
|
||||
.iter()
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.cloned()
|
||||
.collect(),
|
||||
at: opt_value("--at"),
|
||||
}),
|
||||
Some("list") | None => Command::Schedule(crate::cli::ScheduleCmd::List),
|
||||
Some("remove") if rest.len() >= 2 => Command::Schedule(
|
||||
crate::cli::ScheduleCmd::Remove {
|
||||
id: rest[1].clone(),
|
||||
},
|
||||
),
|
||||
Some("run") if rest.len() >= 2 => Command::Schedule(crate::cli::ScheduleCmd::Run {
|
||||
id: rest[1].clone(),
|
||||
}),
|
||||
_ => {
|
||||
app.log.error("usage: schedule add <cmd...> | schedule list | schedule remove <id>");
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
"doctor" => Command::Doctor {
|
||||
fix: flag("--fix"),
|
||||
watch: opt_value("--watch").and_then(|v| v.parse().ok()),
|
||||
},
|
||||
"run" => {
|
||||
let agent = need("agent name")?;
|
||||
Command::Run {
|
||||
agent,
|
||||
model: opt_value("--model"),
|
||||
container: flag("--container"),
|
||||
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,31 @@ pub struct StateFile {
|
||||
/// Config file checksums used by 'am audit' (axe 8, issue #63).
|
||||
#[serde(default)]
|
||||
pub config_checksums: BTreeMap<String, ConfigChecksum>,
|
||||
/// Scheduled commands (axe 6, issue #56).
|
||||
#[serde(default)]
|
||||
pub schedules: Vec<ScheduleEntry>,
|
||||
}
|
||||
|
||||
/// One scheduled command (issue #56). Stored in state.json; the OS-level
|
||||
/// task (crontab / Task Scheduler) points back at 'am schedule-run <id>'.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ScheduleEntry {
|
||||
pub id: String,
|
||||
/// "daily" (the only cadence supported today).
|
||||
pub cadence: String,
|
||||
/// Command arguments to run, e.g. ["update", "--all"].
|
||||
pub command: Vec<String>,
|
||||
/// HH:MM local time (default "08:00").
|
||||
#[serde(default)]
|
||||
pub at: String,
|
||||
/// RFC 3339 creation timestamp.
|
||||
pub created_at: String,
|
||||
/// RFC 3339 of the last run (None = never).
|
||||
#[serde(default)]
|
||||
pub last_run: Option<String>,
|
||||
/// Exit code of the last run (None = never ran / running).
|
||||
#[serde(default)]
|
||||
pub last_exit: Option<i32>,
|
||||
}
|
||||
|
||||
/// One recorded config checksum: the baseline 'am audit' compares against
|
||||
@@ -64,6 +89,7 @@ impl Default for StateFile {
|
||||
last_used: None,
|
||||
annotations: BTreeMap::new(),
|
||||
config_checksums: BTreeMap::new(),
|
||||
schedules: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ agents:
|
||||
#[test]
|
||||
fn run_emits_one_run_event_with_exit_code() {
|
||||
let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT);
|
||||
let code = run_cmd::run(&app, "echo-agent", None, &[OsString::from("hello")]).unwrap();
|
||||
let code = run_cmd::run(&app, "echo-agent", None, false, &[OsString::from("hello")]).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
let evs = events(&app);
|
||||
assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs);
|
||||
@@ -58,7 +58,7 @@ fn run_emits_one_run_event_with_exit_code() {
|
||||
#[test]
|
||||
fn dry_run_writes_no_event() {
|
||||
let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT);
|
||||
let code = run_cmd::run(&app, "echo-agent", None, &[]).unwrap();
|
||||
let code = run_cmd::run(&app, "echo-agent", None, false, &[]).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
assert!(events(&app).is_empty(), "dry-run must not append events");
|
||||
assert!(events::journal_files(&app.events_dir()).is_empty());
|
||||
|
||||
@@ -28,6 +28,7 @@ fn installed_state(name: &str, version: &str) -> StateFile {
|
||||
last_used: None,
|
||||
annotations: BTreeMap::new(),
|
||||
config_checksums: BTreeMap::new(),
|
||||
schedules: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user