feat: phase 2 — playbooks (am history --save + am playbook, rejeu pas à pas) (closes #51)
This commit is contained in:
Generated
+1
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user