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]]
name = "agent-manager"
version = "0.5.2"
version = "0.5.3"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -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"
+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 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 <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 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 |
| 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 |
+7 -1
View File
@@ -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<N>\fR
Re\-run the Nth most recent entry (with confirmation)
.TP
\fB\-\-save\fR \fI<FILE>\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)
+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
.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
+15
View File
@@ -407,6 +407,21 @@ pub enum Command {
/// Re-run the Nth most recent entry (with confirmation)
#[arg(long, value_name = "N")]
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
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.
pub fn filter(
app: &App,
@@ -96,11 +127,25 @@ pub fn run(
session: Option<&str>,
limit: usize,
rerun: Option<usize>,
range: Option<&str>,
save: Option<&Path>,
) -> Result<i32> {
history::migrate(app)?;
if let Some(n) = rerun {
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);
if app.json() {
let payload: Vec<serde_json::Value> = rows
+6
View File
@@ -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<i32> {
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<i32> {
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())
+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")],
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 {
usage: "timeline",
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" },
],
},
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 {
name: "top",
category: "Commands",
+1
View File
@@ -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;
+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)"),
("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 <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 {
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"
)
}
+4 -4
View File
@@ -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]