feat: phase 2 — playbooks (am history --save + am playbook, rejeu pas à pas) (closes #51)

This commit is contained in:
2026-08-18 13:30:29 -04:00
parent e82979e6a3
commit aa0d8e43eb
17 changed files with 630 additions and 16 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]] [[package]]
name = "agent-manager" name = "agent-manager"
version = "0.5.2" version = "0.5.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "0.5.2" version = "0.5.3"
edition = "2021" edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT" license = "MIT"
+1
View File
@@ -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 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 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 --search --failed --rerun N | historique des commandes (am + shell), filtrable et réexécutable |
| am history 12..25 --save <f> · am playbook <f> | exporte une plage en playbook YAML et la rejoue pas à pas ({{var}}) |
| am logs <agent> --follow | tail du log d'un agent, en direct | | am logs <agent> --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) | | am dashboard | tableau de bord TUI temps réel : vue d'ensemble, activité, stats, sessions, projets (Tab/←/→ : onglets, j/k : défilement, q : quitter) |
+2 -2
View File
@@ -188,7 +188,7 @@ taux de succès** — et **combien ça coûte**.
| am history — filtres --kind, --cwd, --search, --failed, --session | M | P0 | | am history — filtres --kind, --cwd, --search, --failed, --session | M | P0 |
| Ctrl-R + !! + !install + !42 + ^old^new dans le REPL | M | P1 | | Ctrl-R + !! + !install + !42 + ^old^new dans le REPL | M | P1 |
| am history --rerun 42 (réexécution confirmée) | S | 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 --search "install" --failed
am history --rerun 42 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 | | [#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 | | [#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 | | [#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 | | [#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 | | [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | am web — serveur local + API JSON + graphiques | XL |
+7 -1
View File
@@ -4,7 +4,7 @@
.SH NAME .SH NAME
history \- Search the command history (am and shell commands) history \- Search the command history (am and shell commands)
.SH SYNOPSIS .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 .SH DESCRIPTION
Search the command history (am and shell commands) Search the command history (am and shell commands)
.SH OPTIONS .SH OPTIONS
@@ -30,5 +30,11 @@ Maximum number of entries (default: 100)
\fB\-\-rerun\fR \fI<N>\fR \fB\-\-rerun\fR \fI<N>\fR
Re\-run the Nth most recent entry (with confirmation) Re\-run the Nth most recent entry (with confirmation)
.TP .TP
\fB\-\-save\fR \fI<FILE>\fR
Export the range as a playbook YAML file (issue #51)
.TP
\fB\-h\fR, \fB\-\-help\fR \fB\-h\fR, \fB\-\-help\fR
Print help Print help
.TP
[\fIRANGE\fR]
Range to export as a playbook: "12..25" (1\-based, newest first)
+19
View File
@@ -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<NAME=VALUE>\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)
+5 -2
View File
@@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq .ie \n(.g .ds Aq \(aq
.el .ds Aq ' .el .ds Aq '
.TH am 1 "am 0.5.2" .TH am 1 "am 0.5.3"
.SH NAME .SH NAME
am \- agent\-manager (am) — manage local AI coding agents am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS .SH SYNOPSIS
@@ -168,6 +168,9 @@ Search the catalog by keyword (name, description, category, tags)
am\-history(1) am\-history(1)
Search the command history (am and shell commands) Search the command history (am and shell commands)
.TP .TP
am\-playbook(1)
Replay a saved playbook step by step with confirmation (issue #51)
.TP
am\-info(1) am\-info(1)
Show detailed information about one agent Show detailed information about one agent
.TP .TP
@@ -207,4 +210,4 @@ Export the configuration and installation state (backup)
am\-import(1) am\-import(1)
Import a previously exported configuration and state Import a previously exported configuration and state
.SH VERSION .SH VERSION
v0.5.2 v0.5.3
+15
View File
@@ -407,6 +407,21 @@ pub enum Command {
/// Re-run the Nth most recent entry (with confirmation) /// Re-run the Nth most recent entry (with confirmation)
#[arg(long, value_name = "N")] #[arg(long, value_name = "N")]
rerun: Option<usize>, rerun: Option<usize>,
/// Range to export as a playbook: "12..25" (1-based, newest first)
#[arg(value_name = "RANGE")]
range: Option<String>,
/// Export the range as a playbook YAML file (issue #51)
#[arg(long, value_name = "FILE")]
save: Option<PathBuf>,
},
/// 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<String>,
}, },
/// Show detailed information about one agent /// Show detailed information about one agent
Info { Info {
+45
View File
@@ -42,6 +42,37 @@ fn rerun_entry(app: &App, n: usize) -> Result<i32> {
} }
} }
/// 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 <from>..<to> --save <file>");
}
if let Some((a, b)) = r.split_once("..") {
let from = a
.trim()
.parse::<usize>()
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
let to = b
.trim()
.parse::<usize>()
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
if from == 0 || to == 0 {
anyhow::bail!("history entries start at 1");
}
Ok((from, to))
} else {
let n = r
.parse::<usize>()
.map_err(|_| anyhow!("invalid range '{r}' — expected <from>..<to>"))?;
if n == 0 {
anyhow::bail!("history entries start at 1");
}
Ok((n, n))
}
}
/// Filter history entries; returns them most recent first. /// Filter history entries; returns them most recent first.
pub fn filter( pub fn filter(
app: &App, app: &App,
@@ -96,11 +127,25 @@ pub fn run(
session: Option<&str>, session: Option<&str>,
limit: usize, limit: usize,
rerun: Option<usize>, rerun: Option<usize>,
range: Option<&str>,
save: Option<&Path>,
) -> Result<i32> { ) -> Result<i32> {
history::migrate(app)?; history::migrate(app)?;
if let Some(n) = rerun { if let Some(n) = rerun {
return rerun_entry(app, n); return rerun_entry(app, n);
} }
// Issue #51: 'am history 12..25 --save <file>' exports a playbook.
if let Some(r) = range {
let file = save.ok_or_else(|| anyhow!("--save <file> 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 <from>..<to> --save <file>");
}
let rows = filter(app, kind, cwd, search, failed, session, limit); let rows = filter(app, kind, cwd, search, failed, session, limit);
if app.json() { if app.json() {
let payload: Vec<serde_json::Value> = rows let payload: Vec<serde_json::Value> = rows
+6
View File
@@ -37,6 +37,7 @@ pub mod stats_cmd;
pub mod status_cmd; pub mod status_cmd;
pub mod suggest_cmd; pub mod suggest_cmd;
pub mod sync_cmd; pub mod sync_cmd;
pub mod playbook_cmd;
pub mod theme_cmd; pub mod theme_cmd;
pub mod timeline_cmd; pub mod timeline_cmd;
pub mod tip_cmd; pub mod tip_cmd;
@@ -179,6 +180,8 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
session, session,
limit, limit,
rerun, rerun,
range,
save,
} => history_cmd::run( } => history_cmd::run(
app, app,
kind.as_deref(), kind.as_deref(),
@@ -188,7 +191,10 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
session.as_deref(), session.as_deref(),
*limit, *limit,
*rerun, *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::Info { agent } => info_cmd::run(app, agent),
Command::Init { force, template } => { Command::Init { force, template } => {
init_cmd::run(app, *force, template.as_deref()) init_cmd::run(app, *force, template.as_deref())
+20
View File
@@ -0,0 +1,20 @@
//! playbook: replay a saved playbook step by step (issue #51).
//! Playbooks are created with 'am history <from>..<to> --save <file>'.
use super::*;
use anyhow::Result;
use std::collections::BTreeMap;
use std::path::Path;
pub fn run(app: &App, path: &Path, vars: &[String]) -> Result<i32> {
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)
}
+6
View File
@@ -254,6 +254,12 @@ pub static SECTIONS: &[TipSection] = &[
options: &[("--export", "crée agent-manager-migrate.amx"), ("--output <f>", "chemin du bundle")], options: &[("--export", "crée agent-manager-migrate.amx"), ("--output <f>", "chemin du bundle")],
example: "migrate --export --output backup.amx", 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 <f>", "fichier playbook"), ("playbook <f>", "rejoue pas à pas avec confirmation")],
example: "history 12..25 --save deploy.yaml && playbook deploy.yaml",
},
TipEntry { TipEntry {
usage: "timeline", usage: "timeline",
about: "une vue chronologique de toute l'activité", about: "une vue chronologique de toute l'activité",
+46
View File
@@ -293,6 +293,52 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "JSON stream for scripts.", code: "monitor --json --interval 10" }, 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 <file> {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 { HelpSpec {
name: "top", name: "top",
category: "Commands", category: "Commands",
+1
View File
@@ -45,6 +45,7 @@ pub mod sessions;
pub mod shell; pub mod shell;
pub mod state; pub mod state;
pub mod sync; pub mod sync;
pub mod playbook;
pub mod tables; pub mod tables;
pub mod theme; pub mod theme;
pub mod toolchain; pub mod toolchain;
+425
View File
@@ -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<String>,
}
/// 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<String>,
#[serde(default)]
pub steps: Vec<PlaybookStep>,
}
/// Directory where playbooks live: <state_dir>/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<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = 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<PlaybookStep> = 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<String> {
let mut vars: Vec<String> = 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, String>) -> 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<String, String>,
) -> Result<i32> {
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<i32> {
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::<String>::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<String> = (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<String> = (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 <state>/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);
}
}
+26 -5
View File
@@ -115,6 +115,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("monitor", "real-time TUI of managed processes (cpu/rss/uptime)"), ("monitor", "real-time TUI of managed processes (cpu/rss/uptime)"),
("sync", "push the state (journal, sessions, config) into a git repo"), ("sync", "push the state (journal, sessions, config) into a git repo"),
("migrate", "export/import a machine transfer bundle (config + state)"), ("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"), ("shell", "show or switch the system shell"),
("theme", "show or switch the color theme"), ("theme", "show or switch the color theme"),
("tip", "cheat sheet of the most useful commands"), ("tip", "cheat sheet of the most useful commands"),
@@ -229,7 +230,7 @@ impl AmCompleter {
"self-update", "self-uninstall", "export", "import", "shell", "theme", "self-update", "self-uninstall", "export", "import", "shell", "theme",
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", "tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"profile", "man", "models", "catalog", "suggest", "audit", "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", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
], ],
config_sub: vec!["show", "path", "edit", "validate", "add"], 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(), " models models · models --prune · catalog · suggest · audit".to_string(),
)); ));
rows.push(inner( 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( rows.push(inner(
" system self-update · self-uninstall · export · import".to_string(), " system self-update · self-uninstall · export · import".to_string(),
@@ -1597,10 +1598,24 @@ fn handle_line(
kind: opt_value("--kind"), kind: opt_value("--kind"),
cwd: opt_value("--cwd"), cwd: opt_value("--cwd"),
search: opt_value("--search"), search: opt_value("--search"),
failed: flag("--failed"), failed: flag("--failed"), session: opt_value("--session"),
session: opt_value("--session"),
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(100), limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(100),
rerun: opt_value("--rerun").and_then(|v| v.parse().ok()), 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 <file> — 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 { "init" => Command::Init {
force: flag("--force"), 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 /// Whether a word is one of the am commands (everything else is executed by
/// the system shell). /// the system shell).
fn is_am_command(word: &str) -> bool { pub fn is_am_command(word: &str) -> bool {
matches!( matches!(
word, word,
"list" "list"
@@ -1843,6 +1858,12 @@ fn is_am_command(word: &str) -> bool {
| "catalog" | "catalog"
| "suggest" | "suggest"
| "audit" | "audit"
| "service"
| "schedule"
| "monitor"
| "sync"
| "migrate"
| "playbook"
) )
} }
+4 -4
View File
@@ -89,7 +89,7 @@ fn command_runs_and_migrates() {
let txt = app.paths.state_file.with_file_name("history.txt"); let txt = app.paths.state_file.with_file_name("history.txt");
std::fs::write(&txt, "install jcode std::fs::write(&txt, "install jcode
").unwrap(); ").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); assert_eq!(code, 0);
// Migration created the structured file. // Migration created the structured file.
assert!(history::dir(&app).join("migrated.jsonl").exists()); 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()), &HistoryEntry::now("echo rerun-ok").with_kind("shell".to_string()),
) )
.unwrap(); .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); assert_eq!(code, 0);
// Numéro hors plage : erreur claire. // 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. // 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] #[test]