From aa0d8e43eb36e6b234f5f3139c18ad5d74989910 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 18 Aug 2026 13:30:29 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20phase=202=20=E2=80=94=20playbooks=20(am?= =?UTF-8?q?=20history=20--save=20+=20am=20playbook,=20rejeu=20pas=20=C3=A0?= =?UTF-8?q?=20pas)=20(closes=20#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 1 + ROADMAP.md | 4 +- man/am-history.1 | 8 +- man/am-playbook.1 | 19 ++ man/am.1 | 7 +- src/cli.rs | 15 ++ src/commands/history_cmd.rs | 45 ++++ src/commands/mod.rs | 6 + src/commands/playbook_cmd.rs | 20 ++ src/commands/tip_cmd.rs | 6 + src/help.rs | 46 ++++ src/lib.rs | 1 + src/playbook.rs | 425 +++++++++++++++++++++++++++++++++++ src/repl.rs | 31 ++- tests/history_test.rs | 8 +- 17 files changed, 630 insertions(+), 16 deletions(-) create mode 100644 man/am-playbook.1 create mode 100644 src/commands/playbook_cmd.rs create mode 100644 src/playbook.rs diff --git a/Cargo.lock b/Cargo.lock index 8d0259e..4f518dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index b1853ae..ca76ab6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.5.2" +version = "0.5.3" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/README.md b/README.md index 1a8854b..31e8a6d 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop) | am projects [nom] | quel agent travaille sur quel projet (agent dominant, sessions, durées) | | am sessions [agent] --status --show --resume | registre des sessions + reprise d'une session arrêtée | | am history --search --failed --rerun N | historique des commandes (am + shell), filtrable et réexécutable | +| am history 12..25 --save · am playbook | exporte une plage en playbook YAML et la rejoue pas à pas ({{var}}) | | am logs --follow | tail du log d'un agent, en direct | | am dashboard | tableau de bord TUI temps réel : vue d'ensemble, activité, stats, sessions, projets (Tab/←/→ : onglets, j/k : défilement, q : quitter) | diff --git a/ROADMAP.md b/ROADMAP.md index a0e8b69..6311127 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -188,7 +188,7 @@ taux de succès** — et **combien ça coûte**. | am history — filtres --kind, --cwd, --search, --failed, --session | M | P0 | | Ctrl-R + !! + !install + !42 + ^old^new dans le REPL | M | P1 | | am history --rerun 42 (réexécution confirmée) | S | P1 | -| Playbooks : am history 12..25 --save deploy.yaml puis am playbook deploy.yaml | L | P2 | +| Playbooks : am history 12..25 --save deploy.yaml puis am playbook deploy.yaml ✅ #51 | L | P2 | am history --search "install" --failed am history --rerun 42 @@ -432,7 +432,7 @@ moins de 2 secondes. |---|---|---| | [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session (stats --costs, cost_models) ✅ | M | | [#50](https://git.dracodev.net/Projets/agent-manager/issues/50) | am monitor — TUI temps réel (CPU/mémoire par PID, alertes) ✅ | L | -| [#51](https://git.dracodev.net/Projets/agent-manager/issues/51) | Playbooks — am history --save + am playbook | L | +| [#51](https://git.dracodev.net/Projets/agent-manager/issues/51) | ✅ Playbooks — am history --save + am playbook | L | | [#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 | diff --git a/man/am-history.1 b/man/am-history.1 index 56b86c2..2b6230f 100644 --- a/man/am-history.1 +++ b/man/am-history.1 @@ -4,7 +4,7 @@ .SH NAME history \- Search the command history (am and shell commands) .SH SYNOPSIS -\fBhistory\fR [\fB\-\-kind\fR] [\fB\-\-cwd\fR] [\fB\-\-search\fR] [\fB\-\-failed\fR] [\fB\-\-session\fR] [\fB\-\-limit\fR] [\fB\-\-rerun\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBhistory\fR [\fB\-\-kind\fR] [\fB\-\-cwd\fR] [\fB\-\-search\fR] [\fB\-\-failed\fR] [\fB\-\-session\fR] [\fB\-\-limit\fR] [\fB\-\-rerun\fR] [\fB\-\-save\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIRANGE\fR] .SH DESCRIPTION Search the command history (am and shell commands) .SH OPTIONS @@ -30,5 +30,11 @@ Maximum number of entries (default: 100) \fB\-\-rerun\fR \fI\fR Re\-run the Nth most recent entry (with confirmation) .TP +\fB\-\-save\fR \fI\fR +Export the range as a playbook YAML file (issue #51) +.TP \fB\-h\fR, \fB\-\-help\fR Print help +.TP +[\fIRANGE\fR] +Range to export as a playbook: "12..25" (1\-based, newest first) diff --git a/man/am-playbook.1 b/man/am-playbook.1 new file mode 100644 index 0000000..0c75afd --- /dev/null +++ b/man/am-playbook.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH am-playbook 1 "playbook " +.SH NAME +playbook \- Replay a saved playbook step by step with confirmation (issue #51) +.SH SYNOPSIS +\fBplaybook\fR [\fB\-\-var\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIFILE\fR> +.SH DESCRIPTION +Replay a saved playbook step by step with confirmation (issue #51) +.SH OPTIONS +.TP +\fB\-\-var\fR \fI\fR +Variable values, repeatable: \-\-var name=value +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIFILE\fR> +Playbook file (state_dir/playbooks/ when a bare name is given) diff --git a/man/am.1 b/man/am.1 index 6eb2792..74dd335 100644 --- a/man/am.1 +++ b/man/am.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH am 1 "am 0.5.2" +.TH am 1 "am 0.5.3" .SH NAME am \- agent\-manager (am) — manage local AI coding agents .SH SYNOPSIS @@ -168,6 +168,9 @@ Search the catalog by keyword (name, description, category, tags) am\-history(1) Search the command history (am and shell commands) .TP +am\-playbook(1) +Replay a saved playbook step by step with confirmation (issue #51) +.TP am\-info(1) Show detailed information about one agent .TP @@ -207,4 +210,4 @@ Export the configuration and installation state (backup) am\-import(1) Import a previously exported configuration and state .SH VERSION -v0.5.2 +v0.5.3 diff --git a/src/cli.rs b/src/cli.rs index 9330c23..229ff39 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -407,6 +407,21 @@ pub enum Command { /// Re-run the Nth most recent entry (with confirmation) #[arg(long, value_name = "N")] rerun: Option, + /// Range to export as a playbook: "12..25" (1-based, newest first) + #[arg(value_name = "RANGE")] + range: Option, + /// Export the range as a playbook YAML file (issue #51) + #[arg(long, value_name = "FILE")] + save: Option, + }, + /// Replay a saved playbook step by step with confirmation (issue #51) + Playbook { + /// Playbook file (state_dir/playbooks/ when a bare name is given) + #[arg(value_name = "FILE")] + path: PathBuf, + /// Variable values, repeatable: --var name=value + #[arg(long, value_name = "NAME=VALUE")] + var: Vec, }, /// Show detailed information about one agent Info { diff --git a/src/commands/history_cmd.rs b/src/commands/history_cmd.rs index 30a4db5..6c1222b 100644 --- a/src/commands/history_cmd.rs +++ b/src/commands/history_cmd.rs @@ -42,6 +42,37 @@ fn rerun_entry(app: &App, n: usize) -> Result { } } +/// Filter history entries; returns them most recent first. +/// Parse "12..25" (inclusive bounds, 1-based) or a single "12". +fn parse_range(r: &str) -> Result<(usize, usize)> { + let r = r.trim(); + if r.is_empty() { + anyhow::bail!("empty range — usage: am history .. --save "); + } + if let Some((a, b)) = r.split_once("..") { + let from = a + .trim() + .parse::() + .map_err(|_| anyhow!("invalid range '{r}' — expected .."))?; + let to = b + .trim() + .parse::() + .map_err(|_| anyhow!("invalid range '{r}' — expected .."))?; + if from == 0 || to == 0 { + anyhow::bail!("history entries start at 1"); + } + Ok((from, to)) + } else { + let n = r + .parse::() + .map_err(|_| anyhow!("invalid range '{r}' — expected .."))?; + if n == 0 { + anyhow::bail!("history entries start at 1"); + } + Ok((n, n)) + } +} + /// Filter history entries; returns them most recent first. pub fn filter( app: &App, @@ -96,11 +127,25 @@ pub fn run( session: Option<&str>, limit: usize, rerun: Option, + range: Option<&str>, + save: Option<&Path>, ) -> Result { history::migrate(app)?; if let Some(n) = rerun { return rerun_entry(app, n); } + // Issue #51: 'am history 12..25 --save ' exports a playbook. + if let Some(r) = range { + let file = save.ok_or_else(|| anyhow!("--save is required with a range"))?; + let (from, to) = parse_range(r)?; + let (n, path) = crate::playbook::export_range(app, from, to, file)?; + app.log + .success(&format!("playbook saved: {} ({n} step(s))", path.display())); + return Ok(0); + } + if save.is_some() { + anyhow::bail!("usage: am history .. --save "); + } let rows = filter(app, kind, cwd, search, failed, session, limit); if app.json() { let payload: Vec = rows diff --git a/src/commands/mod.rs b/src/commands/mod.rs index cf1082c..9498157 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -37,6 +37,7 @@ pub mod stats_cmd; pub mod status_cmd; pub mod suggest_cmd; pub mod sync_cmd; +pub mod playbook_cmd; pub mod theme_cmd; pub mod timeline_cmd; pub mod tip_cmd; @@ -179,6 +180,8 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { session, limit, rerun, + range, + save, } => history_cmd::run( app, kind.as_deref(), @@ -188,7 +191,10 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { session.as_deref(), *limit, *rerun, + range.as_deref(), + save.as_deref(), ), + Command::Playbook { path, var } => playbook_cmd::run(app, path, var), Command::Info { agent } => info_cmd::run(app, agent), Command::Init { force, template } => { init_cmd::run(app, *force, template.as_deref()) diff --git a/src/commands/playbook_cmd.rs b/src/commands/playbook_cmd.rs new file mode 100644 index 0000000..8d20ac3 --- /dev/null +++ b/src/commands/playbook_cmd.rs @@ -0,0 +1,20 @@ +//! playbook: replay a saved playbook step by step (issue #51). +//! Playbooks are created with 'am history .. --save '. + +use super::*; +use anyhow::Result; +use std::collections::BTreeMap; +use std::path::Path; + +pub fn run(app: &App, path: &Path, vars: &[String]) -> Result { + let mut values = BTreeMap::new(); + for v in vars { + let Some((k, val)) = v.split_once('=') else { + anyhow::bail!("--var expects name=value, got '{v}'"); + }; + values.insert(k.trim().to_string(), val.trim().to_string()); + } + let resolved = crate::playbook::resolve_dest(app, path); + let used = if resolved.exists() { resolved } else { path.to_path_buf() }; + crate::playbook::run_playbook(app, &used, &values) +} diff --git a/src/commands/tip_cmd.rs b/src/commands/tip_cmd.rs index 6351a3f..8a2efc6 100644 --- a/src/commands/tip_cmd.rs +++ b/src/commands/tip_cmd.rs @@ -254,6 +254,12 @@ pub static SECTIONS: &[TipSection] = &[ options: &[("--export", "crée agent-manager-migrate.amx"), ("--output ", "chemin du bundle")], example: "migrate --export --output backup.amx", }, + TipEntry { + usage: "history 12..25 --save deploy.yaml", + about: "exporte une plage d'historique en playbook YAML (état_dir/playbooks/)", + options: &[("--save ", "fichier playbook"), ("playbook ", "rejoue pas à pas avec confirmation")], + example: "history 12..25 --save deploy.yaml && playbook deploy.yaml", + }, TipEntry { usage: "timeline", about: "une vue chronologique de toute l'activité", diff --git a/src/help.rs b/src/help.rs index eaea14c..986660b 100644 --- a/src/help.rs +++ b/src/help.rs @@ -293,6 +293,52 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "JSON stream for scripts.", code: "monitor --json --interval 10" }, ], }, + HelpSpec { + name: "history", + category: "Commands", + usage: "history {flags} [range]", + about: "Structured history of every REPL command, with search, re-run and playbook export (issue #51).", + search_terms: &["history", "search", "rerun", "playbook", "save", "replay"], + flags: &[ + HelpFlag { short: "", long: "--kind", value: "KIND", desc: "Only commands of this kind (am or shell)" }, + HelpFlag { short: "", long: "--cwd", value: "DIR", desc: "Only commands run in this directory" }, + HelpFlag { short: "", long: "--search", value: "TEXT", desc: "Full-text search in the command line" }, + HelpFlag { short: "", long: "--failed", value: "", desc: "Only failed commands" }, + HelpFlag { short: "", long: "--session", value: "ID", desc: "Only commands of this session" }, + HelpFlag { short: "", long: "--limit", value: "N", desc: "Maximum entries (default 100)" }, + HelpFlag { short: "", long: "--rerun", value: "N", desc: "Re-run the Nth most recent entry" }, + HelpFlag { short: "", long: "--save", value: "FILE", desc: "Export the range as a playbook YAML (issue #51)" }, + ], + subcommands: &[], + parameters: &[ + HelpParam { name: "range", typ: "from..to", desc: "1-based inclusive range, newest first (e.g. 12..25)" }, + ], + io: None, + examples: &[ + HelpExample { desc: "List the last commands.", code: "history" }, + HelpExample { desc: "Re-run the 5th most recent entry.", code: "history --rerun 5" }, + HelpExample { desc: "Save entries 12..25 as a playbook.", code: "history 12..25 --save deploy.yaml" }, + ], + }, + HelpSpec { + name: "playbook", + category: "Commands", + usage: "playbook {flags}", + about: "Replay a saved playbook step by step with confirmation; stops with a non-zero exit code on failure (issue #51).", + search_terms: &["playbook", "replay", "rejouer", "steps", "automation"], + flags: &[ + HelpFlag { short: "", long: "--var", value: "NAME=VALUE", desc: "Variable value, repeatable ({{name}} in the steps)" }, + ], + subcommands: &[], + parameters: &[ + HelpParam { name: "file", typ: "path", desc: "Playbook file (bare name resolves under state_dir/playbooks/)" }, + ], + io: None, + examples: &[ + HelpExample { desc: "Replay with confirmation for each step.", code: "playbook deploy.yaml" }, + HelpExample { desc: "Replay without asking, with a variable.", code: "playbook deploy.yaml --yes --var agent=claude-code" }, + ], + }, HelpSpec { name: "top", category: "Commands", diff --git a/src/lib.rs b/src/lib.rs index 0b5acba..01beb7b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,7 @@ pub mod sessions; pub mod shell; pub mod state; pub mod sync; +pub mod playbook; pub mod tables; pub mod theme; pub mod toolchain; diff --git a/src/playbook.rs b/src/playbook.rs new file mode 100644 index 0000000..3e868bf --- /dev/null +++ b/src/playbook.rs @@ -0,0 +1,425 @@ +//! Playbooks (issue #51): export a range of REPL history to a re-runnable +//! YAML playbook, then replay it step by step with per-step confirmation. +//! Variables `{{var}}` are substituted at replay time; a failing step stops +//! the playbook with a non-zero exit code. + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// One playbook step: a command plus the directory it runs in. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlaybookStep { + /// Short human label (defaults to the command when absent). + #[serde(default)] + pub name: String, + /// The full REPL line: an am command or a shell command. + pub command: String, + /// Working directory of the step (absent = current directory). + #[serde(default)] + pub cwd: Option, +} + +/// A playbook document (YAML file format of `am history --save`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Playbook { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub steps: Vec, +} + +/// Directory where playbooks live: /playbooks/. +pub fn dir(app: &crate::app::App) -> PathBuf { + app.events_dir().join("playbooks") +} + +/// Resolve the destination path: a bare file name goes into the playbooks +/// directory, an explicit path is honored as-is. +pub fn resolve_dest(app: &crate::app::App, dest: &Path) -> PathBuf { + let has_sep = dest + .components() + .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir)) + || dest.components().count() > 1; + if has_sep { + dest.to_path_buf() + } else { + dir(app).join(dest) + } +} + +/// Split a command line into words, honoring single and double quotes. +pub fn split_words(line: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = None; + let mut started = false; + for c in line.chars() { + match quote { + Some(q) if c == q => quote = None, + Some(_) => cur.push(c), + None => match c { + '\'' | '"' => { + quote = Some(c); + started = true; + } + c if c.is_whitespace() => { + if started { + out.push(std::mem::take(&mut cur)); + started = false; + } + } + c => { + cur.push(c); + started = true; + } + }, + } + } + if started { + out.push(cur); + } + out +} + +/// Export the history range [from..=to] (1-based indices, newest first, as +/// shown by `am history`) into a playbook file. Steps are written in +/// chronological order. Returns (step count, written path). +pub fn export_range( + app: &crate::app::App, + from: usize, + to: usize, + dest: &Path, +) -> Result<(usize, PathBuf)> { + let all = crate::history::read_all(app, 0); + if all.is_empty() { + anyhow::bail!("history is empty — nothing to export"); + } + let n = all.len(); + let lo = from.min(n).max(1); + let hi = to.min(n).max(lo); + let mut slice: Vec<&crate::history::HistoryEntry> = all[lo - 1..=hi - 1] + .iter() + .collect(); + slice.reverse(); // chronological order (oldest first) + if slice.is_empty() { + anyhow::bail!("range {from}..{to} is outside the history (1..={n})"); + } + + let steps: Vec = slice + .iter() + .enumerate() + .map(|(i, e)| PlaybookStep { + name: format!("step {}", i + 1), + command: e.cmd.clone(), + cwd: e.cwd.clone(), + }) + .collect(); + let step_count = steps.len(); + let pb = Playbook { + name: dest + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "playbook".to_string()), + description: Some(format!("exported from am history {from}..{to}")), + steps, + }; + let yaml = serde_yaml::to_string(&pb)?; + let path = resolve_dest(app, dest); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("cannot create {}", parent.display()))?; + } + std::fs::write(&path, yaml).with_context(|| format!("cannot write {}", path.display()))?; + Ok((step_count, path)) +} + +/// Collect every `{{var}}` placeholder in the playbook. +fn collect_vars(pb: &Playbook) -> Vec { + let mut vars: Vec = Vec::new(); + for s in &pb.steps { + for text in [&s.command, s.cwd.as_deref().unwrap_or("")] { + let mut rest: &str = text; + while let Some(start) = rest.find("{{") { + if let Some(end) = rest[start + 2..].find("}}") { + let name = rest[start + 2..start + 2 + end].trim().to_string(); + if !name.is_empty() && !vars.contains(&name) { + vars.push(name); + } + rest = &rest[start + 2 + end + 2..]; + } else { + break; + } + } + } + } + vars +} + +/// Substitute `{{var}}` placeholders with the provided values. +fn substitute(text: &str, values: &BTreeMap) -> String { + let mut out = text.to_string(); + for (k, v) in values { + out = out.replace(&format!("{{{{{k}}}}}"), v); + } + out +} + +/// Replay a playbook: each step is confirmed (unless --yes) and executed. +/// Returns the exit code: 0 when every step succeeded, otherwise the +/// non-zero exit code of the failing step. +pub fn run_playbook( + app: &crate::app::App, + path: &Path, + vars: &BTreeMap, +) -> Result { + if !path.exists() { + anyhow::bail!("playbook not found: {}", path.display()); + } + let text = std::fs::read_to_string(path) + .with_context(|| format!("cannot read {}", path.display()))?; + let mut pb: Playbook = serde_yaml::from_str(&text) + .with_context(|| format!("{}: invalid playbook YAML", path.display()))?; + if pb.steps.is_empty() { + anyhow::bail!("{}: playbook has no steps", path.display()); + } + + // Resolve variables: explicit values first, then ask on stdin. + let mut values = vars.clone(); + for v in collect_vars(&pb) { + if values.contains_key(&v) { + continue; + } + eprint!("value for {v}: "); + std::io::Write::flush(&mut std::io::stderr()).ok(); + let mut line = String::new(); + std::io::stdin().read_line(&mut line).ok(); + let val = line.trim().to_string(); + values.insert(v.clone(), val); + } + + app.log + .info(&format!("playbook '{}' — {} step(s)", pb.name, pb.steps.len())); + let total = pb.steps.len(); + for (i, step) in pb.steps.iter_mut().enumerate() { + step.command = substitute(&step.command, &values); + step.cwd = step.cwd.as_deref().map(|c| substitute(c, &values)); + let label = if step.name.is_empty() { + step.command.clone() + } else { + format!("{} — {}", step.name, step.command) + }; + app.log.info(&format!("▶ step {}/{}: {label}", i + 1, total)); + if !app.confirm("run this step?")? { + app.log.info("playbook stopped by user"); + return Ok(2); + } + let code = execute_step(app, step)?; + if code != 0 { + app.log + .error(&format!("step {} failed (exit {code}) — playbook stopped", i + 1)); + return Ok(code); + } + } + app.log.success(&format!("playbook '{}' finished", pb.name)); + Ok(0) +} + +/// Run one step: an am command is re-invoked through the current binary, +/// anything else goes through the system shell in the step directory. +fn execute_step(app: &crate::app::App, step: &PlaybookStep) -> Result { + let cwd = step.cwd.as_deref().map(PathBuf::from); + if cwd.as_ref().is_some_and(|p| !p.is_dir()) { + anyhow::bail!("step cwd does not exist: {}", cwd.as_ref().unwrap().display()); + } + let words = split_words(&step.command); + let first = words.first().map(|s| s.as_str()).unwrap_or(""); + let status = if crate::repl::is_am_command(first) { + // Re-invoke the am binary itself (same behavior as the REPL). + let mut cmd = Command::new(std::env::current_exe()?); + cmd.args(&words); + if let Some(d) = &cwd { + cmd.current_dir(d); + } + cmd.status() + } else { + // System shell, like the REPL gateway. + #[cfg(windows)] + let mut cmd = { + let mut c = Command::new("cmd"); + c.arg("/C"); + c.arg(&step.command); + c + }; + #[cfg(not(windows))] + let mut cmd = { + let mut c = Command::new("sh"); + c.arg("-c"); + c.arg(&step.command); + c + }; + if let Some(d) = &cwd { + cmd.current_dir(d); + } + cmd.status() + }; + let status = status.with_context(|| format!("cannot execute '{}'", step.command))?; + Ok(status.code().unwrap_or(1)) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_words_handles_quotes() { + assert_eq!(split_words("stats --costs"), vec!["stats", "--costs"]); + assert_eq!(split_words("echo \"hello world\""), vec!["echo", "hello world"]); + assert_eq!(split_words("run claude-code --args 'a b'"), vec!["run", "claude-code", "--args", "a b"]); + assert_eq!(split_words(" ls -la "), vec!["ls", "-la"]); + assert_eq!(split_words(""), Vec::::new()); + } + + #[test] + fn collect_and_substitute_vars() { + let pb = Playbook { + name: "t".into(), + description: None, + steps: vec![ + PlaybookStep { name: "a".into(), command: "run {{agent}}".into(), cwd: None }, + PlaybookStep { name: "b".into(), command: "cd {{dir}} && ls".into(), cwd: Some("{{dir}}".into()) }, + PlaybookStep { name: "c".into(), command: "no vars".into(), cwd: None }, + ], + }; + let vars = collect_vars(&pb); + assert_eq!(vars, vec!["agent".to_string(), "dir".to_string()]); + let mut values = BTreeMap::new(); + values.insert("agent".to_string(), "claude-code".to_string()); + values.insert("dir".to_string(), "/tmp".to_string()); + assert_eq!(substitute("run {{agent}}", &values), "run claude-code"); + assert_eq!(substitute("cd {{dir}} && ls", &values), "cd /tmp && ls"); + assert_eq!(substitute("no vars", &values), "no vars"); + } + + fn test_app(tag: &str) -> 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(), "list"]); + let mut app = crate::app::App::from_cli(cli).expect("app should build"); + app.paths.state_file = dir.path().join(format!("state-{tag}/state.json")); + app.paths.config_dir = Some(dir.path().to_path_buf()); + app + } + + #[test] + fn export_range_writes_valid_playbook() { + let app = test_app("exp"); + let state = app.events_dir(); + std::fs::create_dir_all(state.join("history")).unwrap(); + // Write 5 history entries (file lines are read oldest first). + let lines: Vec = (1..=5) + .map(|i| { + format!( + "{{\"ts\":\"2026-08-18T10:0{i}:00Z\",\"cmd\":\"cmd {i}\",\"cwd\":\"/tmp/p{i}\"}}" + ) + }) + .collect(); + std::fs::write( + state.join("history/20260818.jsonl"), + lines.join("\n") + "\n", + ) + .unwrap(); + // read_all returns newest first: [cmd5..cmd1]; range 2..4 = cmd4,cmd3,cmd2. + let dest = state.join("pb.yaml"); + let (n, path) = export_range(&app, 2, 4, &dest).unwrap(); + assert_eq!(n, 3); + let text = std::fs::read_to_string(&path).unwrap(); + let pb: Playbook = serde_yaml::from_str(&text).unwrap(); + assert_eq!(pb.name, "pb"); + assert_eq!(pb.steps.len(), 3); + // Chronological order: cmd2 first, then cmd3, then cmd4. + assert_eq!(pb.steps[0].command, "cmd 2"); + assert_eq!(pb.steps[1].command, "cmd 3"); + assert_eq!(pb.steps[2].command, "cmd 4"); + assert_eq!(pb.steps[2].cwd.as_deref(), Some("/tmp/p4")); + } + + #[test] + fn export_range_bounds_are_clamped() { + let app = test_app("clamp"); + let state = app.events_dir(); + std::fs::create_dir_all(state.join("history")).unwrap(); + let lines: Vec = (1..=3) + .map(|i| format!("{{\"ts\":\"2026-08-18T10:0{i}:00Z\",\"cmd\":\"c{i}\"}}")) + .collect(); + std::fs::write(state.join("history/20260818.jsonl"), lines.join("\n") + "\n").unwrap(); + let dest = state.join("pb2.yaml"); + // Range beyond the history size: clamped to the available entries. + let (n, _) = export_range(&app, 1, 99, &dest).unwrap(); + assert_eq!(n, 3); + // Invalid range (from > to) is clamped too. + let (n2, _) = export_range(&app, 3, 1, &dest).unwrap(); + assert_eq!(n2, 1); + } + + #[test] + fn replay_runs_steps_and_stops_on_failure() { + let app = test_app("replay"); + // --yes so confirmations pass automatically. + let mut yes_app = app; + yes_app.cli.yes = true; + + // Playbook with two harmless shell steps. + let dir = std::env::temp_dir().join(format!("am-pb-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let pb_path = dir.join("ok.yaml"); + std::fs::write( + &pb_path, + "name: ok\ndescription: t\nsteps:\n - name: one\n command: \"echo one\"\n - name: two\n command: \"echo two\"\n", + ) + .unwrap(); + let code = run_playbook(&yes_app, &pb_path, &BTreeMap::new()).unwrap(); + assert_eq!(code, 0); + + // A failing step stops the playbook with its exit code. + let bad_path = dir.join("bad.yaml"); + std::fs::write( + &bad_path, + "name: bad\nsteps:\n - name: ok\n command: \"echo hi\"\n - name: boom\n command: \"exit 3\"\n - name: never\n command: \"echo never\"\n", + ) + .unwrap(); + let code = run_playbook(&yes_app, &bad_path, &BTreeMap::new()).unwrap(); + assert_eq!(code, 3); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn bare_dest_goes_to_playbooks_dir() { + let app = test_app("dest"); + // A bare file name is resolved under /playbooks/. + let resolved = resolve_dest(&app, std::path::Path::new("deploy.yaml")); + assert_eq!(resolved, app.events_dir().join("playbooks").join("deploy.yaml")); + // An explicit path is honored as-is. + let abs = std::path::PathBuf::from("C:/tmp/x.yaml"); + assert_eq!(resolve_dest(&app, &abs), abs); + } +} diff --git a/src/repl.rs b/src/repl.rs index 45f9865..f06ec0f 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -115,6 +115,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("monitor", "real-time TUI of managed processes (cpu/rss/uptime)"), ("sync", "push the state (journal, sessions, config) into a git repo"), ("migrate", "export/import a machine transfer bundle (config + state)"), + ("playbook", "replay a saved history sequence step by step (am history --save)"), ("shell", "show or switch the system shell"), ("theme", "show or switch the color theme"), ("tip", "cheat sheet of the most useful commands"), @@ -229,7 +230,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", "monitor", "sync", "migrate", + "service", "schedule", "monitor", "sync", "migrate", "playbook", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", ], config_sub: vec!["show", "path", "edit", "validate", "add"], @@ -779,7 +780,7 @@ pub fn banner_box( " models models · models --prune · catalog · suggest · audit".to_string(), )); rows.push(inner( - " automate service install · schedule add · doctor --watch · monitor · sync · migrate".to_string(), + " automate service install · schedule add · doctor --watch · monitor · sync · migrate · playbook".to_string(), )); rows.push(inner( " system self-update · self-uninstall · export · import".to_string(), @@ -1597,10 +1598,24 @@ fn handle_line( kind: opt_value("--kind"), cwd: opt_value("--cwd"), search: opt_value("--search"), - failed: flag("--failed"), - session: opt_value("--session"), + failed: flag("--failed"), session: opt_value("--session"), limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(100), rerun: opt_value("--rerun").and_then(|v| v.parse().ok()), + range: rest.first().cloned(), + save: opt_value("--save").map(std::path::PathBuf::from), + }, + "playbook" => { + if rest.is_empty() { + app.log.error("usage: playbook — ex: playbook deploy.yaml"); + return Ok(false); + } + Command::Playbook { + path: std::path::PathBuf::from(rest[0].clone()), + var: rest + .iter() + .filter_map(|t| t.strip_prefix("--var=").map(|v| v.to_string())) + .collect(), + } }, "init" => Command::Init { force: flag("--force"), @@ -1794,7 +1809,7 @@ fn completion_pool_mutating(first: &str) -> bool { /// Whether a word is one of the am commands (everything else is executed by /// the system shell). -fn is_am_command(word: &str) -> bool { +pub fn is_am_command(word: &str) -> bool { matches!( word, "list" @@ -1843,6 +1858,12 @@ fn is_am_command(word: &str) -> bool { | "catalog" | "suggest" | "audit" + | "service" + | "schedule" + | "monitor" + | "sync" + | "migrate" + | "playbook" ) } diff --git a/tests/history_test.rs b/tests/history_test.rs index 77641d4..3da432d 100644 --- a/tests/history_test.rs +++ b/tests/history_test.rs @@ -89,7 +89,7 @@ fn command_runs_and_migrates() { let txt = app.paths.state_file.with_file_name("history.txt"); std::fs::write(&txt, "install jcode ").unwrap(); - let code = history_cmd::run(&app, None, None, None, false, None, 100, None).unwrap(); + let code = history_cmd::run(&app, None, None, None, false, None, 100, None, None, None).unwrap(); assert_eq!(code, 0); // Migration created the structured file. assert!(history::dir(&app).join("migrated.jsonl").exists()); @@ -104,12 +104,12 @@ fn rerun_executes_shell_entry_with_yes() { &HistoryEntry::now("echo rerun-ok").with_kind("shell".to_string()), ) .unwrap(); - let code = history_cmd::run(&app, None, None, None, false, None, 100, Some(1)).unwrap(); + let code = history_cmd::run(&app, None, None, None, false, None, 100, Some(1), None, None).unwrap(); assert_eq!(code, 0); // Numéro hors plage : erreur claire. - assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(9)).is_err()); + assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(9), None, None).is_err()); // Zéro : erreur claire. - assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(0)).is_err()); + assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(0), None, None).is_err()); } #[test]