feat: phase 2 — sessions export + rétention, init --template (closes #52 #53)

Axe 3 (#52) : templates embarqués dans le binaire (web, python, rust, cli)
- am init --template <stack> génère une config pré-remplie : groupe dev,
  profil par défaut, hooks projet (npm install / uv sync / cargo fetch)
- substitution {project} {dir} au rendu ; --template list pour lister
- fallback détection automatique de stack quand aucun template
- config générée validée (parse YAML + validate) par les tests

Axe 4 (#53) : am sessions export + rétention configurable
- am sessions --export <id> [--output <fichier>] : JSON complet et
  reproductible (métadonnées + commandes history/<id>.jsonl + extrait de
  log 4 Ko + rendu lisible 'readable'), compatible archivage manuel
- settings.sessions_retention_days (défaut 90) : purge au démarrage
  (silencieuse) et am sessions --retention <jours>
- purge limitée à l'index dérivé sessions.json — le journal d'événements
  primaire n'est jamais touché ; EventKind::Prune journalisé (visibilité
  am audit)
- sessions en cours (running) jamais purgées ; erreur claire si id inconnu

REPL : sessions --export/--retention/--output + init --template ; help
specs, tip cheat sheet, config.yaml, README, man pages régénérées.
Version 0.4.7, ROADMAP cochée, 276 tests verts.
This commit is contained in:
2026-08-18 10:08:20 -04:00
parent d9ffc34156
commit 6a6676fc5d
23 changed files with 561 additions and 71 deletions
+54 -29
View File
@@ -1,5 +1,6 @@
//! init: generate a local agent-manager.yaml for the current directory,
//! based on the detected technology stack.
//! based on the detected technology stack or an explicit template
//! (issue #52: 'am init --template web|python|rust|cli').
use super::*;
use crate::context;
@@ -7,8 +8,9 @@ use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
/// Generate a local config file in dir; refuses to overwrite unless force.
/// Returns the written path.
pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
/// `template` renders a pre-filled config for a stack ('am init --template');
/// None falls back on the automatic stack detection. Returns the written path.
pub fn generate(dir: &Path, force: bool, template: Option<&str>) -> Result<PathBuf> {
let target = dir.join(crate::config::LOCAL_CONFIG_NAME);
if target.exists() && !force {
return Err(anyhow!(
@@ -16,34 +18,51 @@ pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
target.display()
));
}
let ctx = context::detect(dir);
let agents = context::recommended_agents(&ctx.stack);
let members = agents
.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(", ");
let stack = if ctx.stack.is_empty() {
"aucune détectée".to_string()
let project = dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "project".to_string());
let dir_str = dir.to_string_lossy().to_string();
let content = if let Some(t) = template {
crate::templates::render(t, &project, &dir_str)?
} else {
ctx.stack.join(", ")
};
let content = format!(
"# Généré par 'am init' le {date}
let ctx = context::detect(dir);
let agents = context::recommended_agents(&ctx.stack);
let members = agents
.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(", ");
let stack = if ctx.stack.is_empty() {
"aucune détectée".to_string()
} else {
ctx.stack.join(", ")
};
format!(
"# Généré par 'am init' le {date}
# Pile détectée : {stack}
version: \"1.0\"
groups:
dev: [{members}]
agents: []
",
date = crate::installers::now_rfc3339(),
);
date = crate::installers::now_rfc3339(),
)
};
std::fs::write(&target, content)
.with_context(|| format!("cannot write {}", target.display()))?;
Ok(target)
}
pub fn run(app: &App, force: bool) -> Result<i32> {
pub fn run(app: &App, force: bool, template: Option<&str>) -> Result<i32> {
// 'am init --template list' prints the available templates.
if template == Some("list") {
println!("available templates:");
for (name, desc) in crate::templates::TEMPLATES {
println!(" {name:<8} {desc}");
}
return Ok(0);
}
if app.dry_run() {
app.log.dry(format!(
"would generate {} in the current directory",
@@ -53,16 +72,22 @@ pub fn run(app: &App, force: bool) -> Result<i32> {
}
let cwd = std::env::current_dir().context("cannot determine the current directory")?;
let ctx = context::detect(&cwd);
let path = generate(&cwd, force)?;
app.log.success(&format!(
"wrote {} (stack: {})",
path.display(),
if ctx.stack.is_empty() {
"aucune détectée".to_string()
} else {
ctx.stack.join(", ")
}
));
let path = generate(&cwd, force, template)?;
match template {
Some(t) => app.log.success(&format!(
"wrote {} (template: {t})",
path.display()
)),
None => app.log.success(&format!(
"wrote {} (stack: {})",
path.display(),
if ctx.stack.is_empty() {
"aucune détectée".to_string()
} else {
ctx.stack.join(", ")
}
)),
}
app.log.info(&format!(
"group 'dev' prêt : am start group:dev — ajustez ensuite la config générée"
));
+9 -1
View File
@@ -130,6 +130,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
status,
show,
resume,
export,
output,
retention,
} => sessions_cmd::run(
app,
agent.as_deref(),
@@ -137,6 +140,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
status.as_deref(),
show.as_deref(),
resume.as_deref(),
export.as_deref(),
output.as_deref(),
*retention,
),
Command::Install {
agent,
@@ -170,7 +176,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
*rerun,
),
Command::Info { agent } => info_cmd::run(app, agent),
Command::Init { force } => init_cmd::run(app, *force),
Command::Init { force, template } => {
init_cmd::run(app, *force, template.as_deref())
}
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
Command::Version => version_cmd::run(app),
Command::Tip { random } => tip_cmd::run(app, *random),
+61
View File
@@ -4,6 +4,7 @@ use super::*;
use crate::output::{print_json, status_style, Table};
use crate::sessions::{self, SessionRecord};
use serde::Serialize;
use std::path::PathBuf;
#[derive(Serialize)]
struct SessionRow {
@@ -45,7 +46,16 @@ pub fn run(
status: Option<&str>,
show: Option<&str>,
resume: Option<&str>,
export: Option<&str>,
output: Option<&str>,
retention: Option<u64>,
) -> Result<i32> {
if let Some(id) = export {
return export_one(app, id, output);
}
if let Some(days) = retention {
return purge(app, days);
}
if let Some(id) = show {
return show_one(app, id);
}
@@ -181,6 +191,57 @@ fn short_id(id: &str) -> String {
id.chars().take(15).collect()
}
/// Issue #53: 'am sessions export <id> [--output <file>]' — write a
/// complete, self-contained JSON export (metadata, commands, log excerpt)
/// plus a human-readable rendering. Default destination:
/// ./am-session-<id>.json in the current directory.
fn export_one(app: &App, id: &str, output: Option<&str>) -> Result<i32> {
let export = sessions::export_session(app, id)?;
let dest = match output {
Some(o) => PathBuf::from(o),
None => std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(format!("am-session-{}.json", export.session.id)),
};
let text = serde_json::to_string_pretty(&export)?;
std::fs::write(&dest, text).map_err(|e| {
anyhow::anyhow!("cannot write export {}: {e}", dest.display())
})?;
app.log.success(&format!(
"exported session {} ({} command(s), {} log bytes) to {}",
export.session.id,
export.commands.len(),
export.log_excerpt.len(),
dest.display()
));
app.log.info(&format!(
"JSON complet + rendu lisible ('readable') — compatible archivage manuel / am import"
));
Ok(0)
}
/// Issue #53: 'am sessions --retention <days>' — purge finished sessions
/// older than the threshold. The primary event journal is untouched; the
/// purge is journaled as EventKind::Prune.
fn purge(app: &App, days: u64) -> Result<i32> {
if days == 0 {
bail!("retention must be at least 1 day");
}
if app.dry_run() {
app.log
.dry(format!("would purge sessions finished more than {days} days ago"));
return Ok(0);
}
let purged = sessions::purge_expired(app, days)?;
match purged {
0 => app.log.info(&format!(
"no session finished more than {days} days ago — nothing to purge"
)),
n => app.log.success(&format!("purged {n} session(s) older than {days} days")),
}
Ok(0)
}
fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
+15
View File
@@ -134,6 +134,21 @@ pub static SECTIONS: &[TipSection] = &[
],
example: "update --rollback list",
},
TipEntry {
usage: "init --template <stack>",
about: "génère une config projet pré-remplie (web, python, rust, cli)",
options: &[("--template list", "liste les templates disponibles")],
example: "init --template python",
},
TipEntry {
usage: "sessions --export <id>",
about: "exporte une session : métadonnées + commandes + extrait de log (JSON)",
options: &[
("--output <fichier>", "destination (défaut ./am-session-<id>.json)"),
("--retention <jours>", "purge les sessions terminées au-delà de N jours"),
],
example: "sessions --export 20260815_143926 --output session.json",
},
TipEntry {
usage: "install <agent>",
about: "installe l'agent et ses dépendances (Node, Python, Rust…)",