hooks : on_install/on_start/on_stop/on_update (issue #34) #83

Merged
bruno merged 1 commits from feat/hooks into main 2026-08-17 08:55:50 -04:00
6 changed files with 144 additions and 0 deletions
+5
View File
@@ -143,6 +143,11 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -
.with_agent(agent.name.clone())
.with_args(vec![format!("method={}", method.kind)]),
);
crate::hooks::run_hooks(
app,
"on_install",
&std::env::current_dir().unwrap_or_default(),
);
app.log.success(&format!(
"installed {} (v{version}) in {}",
agent.title(),
+6
View File
@@ -61,6 +61,8 @@ fn start_one(
let bin = PathBuf::from(&exec.program);
let is_managed = app.state.get(&agent.name).ok().flatten().is_some();
let ctx = crate::context::detect(&std::env::current_dir().unwrap_or_default());
let current = std::env::current_dir().unwrap_or_default();
crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(&current));
if background {
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env, cwd)?;
if app.dry_run() {
@@ -194,6 +196,8 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
);
let _ = sessions::finish_agent(app, &agent.name, Some(pid), 0, true);
app.state.update_pid(&agent.name, None, None)?;
let current = std::env::current_dir().unwrap_or_default();
crate::hooks::run_hooks(app, "on_stop", &current);
return Ok(0);
}
app.log.info(&format!("stopping '{}' (pid {pid})", agent.name));
@@ -210,6 +214,8 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
app.emit(&ev);
let _ = sessions::finish_agent(app, &agent.name, Some(pid), if stopped { 0 } else { 1 }, stopped);
app.state.update_pid(&agent.name, None, None)?;
let current = std::env::current_dir().unwrap_or_default();
crate::hooks::run_hooks(app, "on_stop", &current);
if stopped {
app.log.success(&format!("{} stopped", agent.title()));
Ok(0)
+5
View File
@@ -102,6 +102,11 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
.with_agent(agent.name.clone())
.with_args(vec![format!("method={}", method.kind)]),
);
crate::hooks::run_hooks(
app,
"on_update",
&std::env::current_dir().unwrap_or_default(),
);
app.log.success(&format!(
"updated {} to v{version}",
agent.title()
+7
View File
@@ -73,6 +73,10 @@ pub struct Settings {
/// --theme flag overrides it).
#[serde(default)]
pub theme: Option<String>,
/// Lifecycle hooks: on_install / on_start / on_stop / on_update command
/// lines (global; project profiles can add their own).
#[serde(default)]
pub hooks: BTreeMap<String, Vec<String>>,
}
/// A project profile: everything am needs to know about one project.
@@ -497,6 +501,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.self_update_repo.is_some() {
s.self_update_repo = o.self_update_repo;
}
for (k, v) in o.hooks {
s.hooks.insert(k, v);
}
for (k, v) in overlay.aliases {
base.aliases.insert(k, v);
}
+120
View File
@@ -0,0 +1,120 @@
//! Hooks: user-defined commands run around agent lifecycle events
//! (on_install, on_start, on_stop, on_update). Global settings.hooks run
//! first, then the hooks of the project profile containing the cwd.
use crate::app::App;
use crate::runner::SystemRunner;
use std::collections::BTreeMap;
use std::path::Path;
/// Run every hook registered for an event, in order. Failures are warnings
/// and never break the main command; --dry-run announces them without
/// executing anything.
pub fn run_hooks(app: &App, hook: &str, cwd: &Path) {
let mut cmds: Vec<String> = Vec::new();
if let Some(global) = app.config.settings.hooks.get(hook) {
cmds.extend(global.iter().cloned());
}
if let Some((_, profile)) = crate::projects::profile_for(app, cwd) {
if let Some(list) = profile.hooks.get(hook) {
cmds.extend(list.iter().cloned());
}
}
if cmds.is_empty() {
return;
}
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
for cmd in &cmds {
let tokens = shell_words::split(cmd).unwrap_or_else(|_| vec![cmd.clone()]);
if tokens.is_empty() {
continue;
}
let prog = tokens[0].clone();
let args: Vec<String> = tokens[1..].to_vec();
app.log.info(&format!("hook {hook}: {cmd}"));
if let Err(e) = crate::installers::sh(&runner, &prog, &args, &BTreeMap::new(), Some(cwd)) {
app.log.warn(&format!("hook {hook} failed: {e:#}"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn app_with(dir: &std::path::Path) -> App {
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
"agents: []\n",
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.install_dir = dir.join("agents");
p.bin_dir = dir.join("agents").join("bin");
p.log_dir = dir.join("logs");
p.state_file = dir.join("state.json");
p.probe_cache_file = dir.join("probe-cache.json");
p.config_dir = Some(dir.join("config"));
app.paths = p;
app
}
#[test]
fn hook_runs_and_writes_marker() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let marker = dir.join("marker.txt");
let mut app = app_with(&dir);
let cmd = format!("sh -c \"echo ran > {}\"", marker.display().to_string().replace("\\", "/"));
app.config.settings.hooks.insert("on_start".to_string(), vec![cmd]);
run_hooks(&app, "on_start", &dir);
assert!(marker.exists(), "hook a ecrit le marqueur");
}
#[test]
fn failing_hook_warns_without_panicking() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let mut app = app_with(&dir);
app.config.settings.hooks.insert("on_start".to_string(), vec!["sh -c exit 3".to_string()]);
run_hooks(&app, "on_start", &dir); // ne panique pas
run_hooks(&app, "on_update", &dir); // aucun hook : no-op
}
#[test]
fn dry_run_announces_without_executing() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let marker = dir.join("marker2.txt");
let mut app = app_with(&dir);
let cmd = format!("sh -c \"echo ran > {}\"", marker.display().to_string().replace("\\", "/"));
app.config.settings.hooks.insert("on_start".to_string(), vec![cmd]);
app.cli.dry_run = true;
run_hooks(&app, "on_start", &dir);
assert!(!marker.exists(), "dry-run ne doit rien executer");
}
#[test]
fn settings_hooks_parse_from_yaml() {
let cfg: crate::config::Config = serde_yaml::from_str(
"version: \"1.0\"\nsettings:\n hooks:\n on_start: [\"sh -c echo hi\"]\nagents: []\n",
)
.unwrap();
assert_eq!(
cfg.settings.hooks["on_start"],
vec!["sh -c echo hi".to_string()]
);
}
}
+1
View File
@@ -23,6 +23,7 @@ pub mod download;
pub mod events;
pub mod help;
pub mod history;
pub mod hooks;
pub mod installers;
pub mod nav;
pub mod output;