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:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.4.6"
|
||||
version = "0.4.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.4.6"
|
||||
version = "0.4.7"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
|
||||
@@ -84,7 +84,9 @@ plateforme, il compile automatiquement depuis les sources.
|
||||
| am models --prune | purge des modèles inutilisés (--dry-run : simulation) |
|
||||
| am catalog update / add <url> | catalogue distant : rafraîchit l'officiel ou ajoute un catalogue d'équipe |
|
||||
| am audit | qui a modifié quoi, quand (checksums config + journal) |
|
||||
| am update --rollback | annule la dernière mise à jour (backup automatique avant chaque update) |
|
||||
| am sessions --export <id> | export d'une session (métadonnées + commandes + extrait de log) |
|
||||
| am sessions --retention <jours> | purge des sessions terminées au-delà de N jours |
|
||||
| am init --template <stack> | génère une config pré-remplie (web, python, rust, cli) |
|
||||
|
||||
**Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé
|
||||
sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
|
||||
+2
-2
@@ -433,8 +433,8 @@ moins de 2 secondes.
|
||||
| [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session | 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 |
|
||||
| [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | Templates — am init --template web | M |
|
||||
| [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | am sessions export + rétention configurable | 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 |
|
||||
| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | am web — serveur local + API JSON + graphiques | XL |
|
||||
| [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) | L |
|
||||
| [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (--daily update --all, check santé) | M |
|
||||
|
||||
@@ -24,6 +24,7 @@ settings:
|
||||
catalog_cache_ttl_secs: 3600 # TTL du cache des catalogues distants (#60 #67)
|
||||
catalog_url: null # URL officielle du catalogue distant (défaut: config.yaml du repo) (#60)
|
||||
backups_keep: 5 # nombre de backups conservés pour am update --rollback (#64)
|
||||
sessions_retention_days: 90 # purge des sessions terminées au-delà de N jours (#53)
|
||||
|
||||
# --- Command aliases ----------------------------------------------------------
|
||||
aliases:
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
init \- Generate a local agent\-manager.yaml for the current directory
|
||||
.SH SYNOPSIS
|
||||
\fBinit\fR [\fB\-\-force\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
\fBinit\fR [\fB\-\-force\fR] [\fB\-\-template\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||
.SH DESCRIPTION
|
||||
Generate a local agent\-manager.yaml for the current directory
|
||||
.SH OPTIONS
|
||||
@@ -12,5 +12,8 @@ Generate a local agent\-manager.yaml for the current directory
|
||||
\fB\-\-force\fR
|
||||
Overwrite an existing agent\-manager.yaml
|
||||
.TP
|
||||
\fB\-\-template\fR \fI<TEMPLATE>\fR
|
||||
Use a template (web, python, rust, cli) instead of stack detection; "list" shows the available templates
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
|
||||
+10
-1
@@ -4,7 +4,7 @@
|
||||
.SH NAME
|
||||
sessions \- List the sessions of every agent (and of the interactive shell)
|
||||
.SH SYNOPSIS
|
||||
\fBsessions\fR [\fB\-\-project\fR] [\fB\-\-status\fR] [\fB\-\-show\fR] [\fB\-\-resume\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
\fBsessions\fR [\fB\-\-project\fR] [\fB\-\-status\fR] [\fB\-\-show\fR] [\fB\-\-resume\fR] [\fB\-\-export\fR] [\fB\-\-output\fR] [\fB\-\-retention\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
|
||||
.SH DESCRIPTION
|
||||
List the sessions of every agent (and of the interactive shell)
|
||||
.SH OPTIONS
|
||||
@@ -21,6 +21,15 @@ Show one session in detail (summary + log excerpt)
|
||||
\fB\-\-resume\fR \fI<ID>\fR
|
||||
Resume a finished session: relaunch the agent with its recorded arguments
|
||||
.TP
|
||||
\fB\-\-export\fR \fI<ID>\fR
|
||||
Export one session (metadata, commands, log excerpt) to a JSON file
|
||||
.TP
|
||||
\fB\-\-output\fR \fI<FILE>\fR
|
||||
Destination of the export (default: ./am\-session\-<ID>.json)
|
||||
.TP
|
||||
\fB\-\-retention\fR \fI<DAYS>\fR
|
||||
Purge sessions finished more than N days ago (settings default 90)
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.TP
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am 1 "am 0.4.6"
|
||||
.TH am 1 "am 0.4.7"
|
||||
.SH NAME
|
||||
am \- agent\-manager (am) — manage local AI coding agents
|
||||
.SH SYNOPSIS
|
||||
@@ -192,4 +192,4 @@ Export the configuration and installation state (backup)
|
||||
am\-import(1)
|
||||
Import a previously exported configuration and state
|
||||
.SH VERSION
|
||||
v0.4.6
|
||||
v0.4.7
|
||||
|
||||
+13
@@ -233,6 +233,15 @@ pub enum Command {
|
||||
/// Resume a finished session: relaunch the agent with its recorded arguments
|
||||
#[arg(long, value_name = "ID")]
|
||||
resume: Option<String>,
|
||||
/// Export one session (metadata, commands, log excerpt) to a JSON file
|
||||
#[arg(long, value_name = "ID")]
|
||||
export: Option<String>,
|
||||
/// Destination of the export (default: ./am-session-<ID>.json)
|
||||
#[arg(long, value_name = "FILE")]
|
||||
output: Option<String>,
|
||||
/// Purge sessions finished more than N days ago (settings default 90)
|
||||
#[arg(long, value_name = "DAYS")]
|
||||
retention: Option<u64>,
|
||||
},
|
||||
/// Show usage statistics computed from the event journal
|
||||
Stats {
|
||||
@@ -373,6 +382,10 @@ pub enum Command {
|
||||
/// Overwrite an existing agent-manager.yaml
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Use a template (web, python, rust, cli) instead of stack detection;
|
||||
/// "list" shows the available templates
|
||||
#[arg(long, value_name = "TEMPLATE")]
|
||||
template: Option<String>,
|
||||
},
|
||||
/// Display help: the overview, a command, the command list, or a search
|
||||
Help {
|
||||
|
||||
@@ -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,6 +18,14 @@ pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
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 {
|
||||
let ctx = context::detect(dir);
|
||||
let agents = context::recommended_agents(&ctx.stack);
|
||||
let members = agents
|
||||
@@ -28,7 +38,7 @@ pub fn generate(dir: &Path, force: bool) -> Result<PathBuf> {
|
||||
} else {
|
||||
ctx.stack.join(", ")
|
||||
};
|
||||
let content = format!(
|
||||
format!(
|
||||
"# Généré par 'am init' le {date}
|
||||
# Pile détectée : {stack}
|
||||
version: \"1.0\"
|
||||
@@ -37,13 +47,22 @@ groups:
|
||||
agents: []
|
||||
",
|
||||
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,8 +72,13 @@ 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!(
|
||||
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() {
|
||||
@@ -62,7 +86,8 @@ pub fn run(app: &App, force: bool) -> Result<i32> {
|
||||
} 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
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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…)",
|
||||
|
||||
@@ -121,6 +121,9 @@ pub struct Settings {
|
||||
/// (issue #64, default 5).
|
||||
#[serde(default)]
|
||||
pub backups_keep: Option<u32>,
|
||||
/// Purge sessions finished more than N days ago (issue #53, default 90).
|
||||
#[serde(default)]
|
||||
pub sessions_retention_days: Option<u64>,
|
||||
}
|
||||
|
||||
/// Policy controlling how aggressively 'am update' upgrades installed agents.
|
||||
@@ -645,6 +648,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
|
||||
if o.backups_keep.is_some() {
|
||||
s.backups_keep = o.backups_keep;
|
||||
}
|
||||
if o.sessions_retention_days.is_some() {
|
||||
s.sessions_retention_days = o.sessions_retention_days;
|
||||
}
|
||||
for (k, v) in o.hooks {
|
||||
s.hooks.insert(k, v);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ pub enum EventKind {
|
||||
Backup,
|
||||
/// Rollback of a previous backup (issue #64).
|
||||
Rollback,
|
||||
/// Derived index pruned (sessions retention, issue #53).
|
||||
Prune,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
@@ -61,6 +63,7 @@ impl EventKind {
|
||||
EventKind::Catalog => "catalog",
|
||||
EventKind::Backup => "backup",
|
||||
EventKind::Rollback => "rollback",
|
||||
EventKind::Prune => "prune",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-7
@@ -181,12 +181,16 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
name: "sessions",
|
||||
category: "Commands",
|
||||
usage: "sessions {flags} [agent]",
|
||||
about: "List the sessions of every agent and of the interactive shell.",
|
||||
search_terms: &["session", "history", "log"],
|
||||
about: "List sessions; export one as JSON; enforce retention on old ones.",
|
||||
search_terms: &["session", "history", "log", "export", "retention", "purge", "resume"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--project", value: "PROJECT", desc: "Only sessions of this project" },
|
||||
HelpFlag { short: "", long: "--status", value: "STATUS", desc: "Only sessions with this status (running, stopped, failed, interrupted)" },
|
||||
HelpFlag { short: "", long: "--show", value: "ID", desc: "Show one session in detail (summary + log excerpt)" },
|
||||
HelpFlag { short: "", long: "--export", value: "ID", desc: "Export a session (metadata + commands + log excerpt) to JSON" },
|
||||
HelpFlag { short: "", long: "--output", value: "FILE", desc: "Export destination (default ./am-session-<ID>.json)" },
|
||||
HelpFlag { short: "", long: "--retention", value: "DAYS", desc: "Purge sessions finished more than N days ago (settings default 90)" },
|
||||
HelpFlag { short: "", long: "--resume", value: "ID", desc: "Relaunch a finished agent session with its recorded arguments" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
@@ -195,7 +199,9 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "List recent sessions.", code: "sessions" },
|
||||
HelpExample { desc: "Show one session with its log excerpt.", code: "sessions --show 20260815_143926_a1b2c3" },
|
||||
HelpExample { desc: "Export one session.", code: "sessions --export 20260815_143926_a1b2c3" },
|
||||
HelpExample { desc: "Purge sessions older than 30 days.", code: "sessions --retention 30" },
|
||||
HelpExample { desc: "Resume a finished session.", code: "sessions --resume 20260815_143926_a1b2c3" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
@@ -557,17 +563,19 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
name: "init",
|
||||
category: "Commands",
|
||||
usage: "init {flags}",
|
||||
about: "Generate a local agent-manager.yaml for the current directory, based on the detected stack.",
|
||||
search_terms: &["project", "setup", "scaffold"],
|
||||
about: "Generate a local agent-manager.yaml — stack detection or an embedded template.",
|
||||
search_terms: &["project", "setup", "scaffold", "template"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--force", value: "", desc: "Overwrite an existing agent-manager.yaml" },
|
||||
HelpFlag { short: "", long: "--template", value: "NAME", desc: "web | python | rust | cli ('list' shows them)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Generate a project config.", code: "init" },
|
||||
HelpExample { desc: "Regenerate over an existing one.", code: "init --force" },
|
||||
HelpExample { desc: "Generate a project config (stack detection).", code: "init" },
|
||||
HelpExample { desc: "Generate a web-stack config.", code: "init --template web" },
|
||||
HelpExample { desc: "List the available templates.", code: "init --template list" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
|
||||
@@ -198,6 +198,19 @@ pub fn read_all(app: &App, limit: usize) -> Vec<HistoryEntry> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Read the entries of one session file (oldest first). Missing or corrupt
|
||||
/// files yield an empty list — an export never fails on history.
|
||||
pub fn read_session(app: &App, session: &str) -> Vec<HistoryEntry> {
|
||||
let path = dir(app).join(format!("{session}.jsonl"));
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
text.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|l| serde_json::from_str::<HistoryEntry>(l).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse the session id from a history file name ("20260815_143926.jsonl").
|
||||
pub fn session_of_file(path: &Path) -> Option<String> {
|
||||
path.file_name()
|
||||
|
||||
+15
-2
@@ -14,6 +14,7 @@
|
||||
pub mod app;
|
||||
pub mod backup;
|
||||
pub mod catalog;
|
||||
pub mod templates;
|
||||
pub mod catalog_remote;
|
||||
pub mod cli;
|
||||
pub mod commands;
|
||||
@@ -79,7 +80,18 @@ fn real_main() -> i32 {
|
||||
}
|
||||
let cli = cli::Cli::parse();
|
||||
match app::App::from_cli(cli) {
|
||||
Ok(app) => match commands::execute(&app) {
|
||||
Ok(app) => {
|
||||
// Issue #53: enforce the session retention at every startup
|
||||
// (silent; the primary event journal is never touched).
|
||||
let days = app
|
||||
.config
|
||||
.settings
|
||||
.sessions_retention_days
|
||||
.unwrap_or(sessions::DEFAULT_RETENTION_DAYS);
|
||||
if let Err(e) = sessions::purge_expired(&app, days) {
|
||||
app.log.verbose(&format!("session retention skipped: {e:#}"));
|
||||
}
|
||||
match commands::execute(&app) {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
if app.json() {
|
||||
@@ -93,7 +105,8 @@ fn real_main() -> i32 {
|
||||
}
|
||||
1
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Config could not even be loaded: fall back to plain stderr output.
|
||||
eprintln!("{} {}", "error:".red().bold(), format!("{:#}", err));
|
||||
|
||||
+7
-1
@@ -1519,6 +1519,9 @@ fn handle_line(
|
||||
status: opt_value("--status"),
|
||||
show: opt_value("--show"),
|
||||
resume: opt_value("--resume"),
|
||||
export: opt_value("--export"),
|
||||
output: opt_value("--output"),
|
||||
retention: opt_value("--retention").and_then(|v| v.parse().ok()),
|
||||
},
|
||||
"stats" => Command::Stats {
|
||||
agent: rest.first().cloned(),
|
||||
@@ -1557,7 +1560,10 @@ fn handle_line(
|
||||
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(100),
|
||||
rerun: opt_value("--rerun").and_then(|v| v.parse().ok()),
|
||||
},
|
||||
"init" => Command::Init { force: flag("--force") },
|
||||
"init" => Command::Init {
|
||||
force: flag("--force"),
|
||||
template: opt_value("--template"),
|
||||
},
|
||||
"search" => {
|
||||
let words: Vec<String> = rest
|
||||
.iter()
|
||||
|
||||
+166
@@ -247,6 +247,130 @@ pub fn duration_s(r: &SessionRecord) -> Option<u64> {
|
||||
Some(secs.max(0) as u64)
|
||||
}
|
||||
|
||||
/// Default retention: sessions older than this many days are purged by
|
||||
/// 'am sessions --retention' / startup (issue #53).
|
||||
pub const DEFAULT_RETENTION_DAYS: u64 = 90;
|
||||
|
||||
/// Complete, self-contained export of one session (issue #53): metadata,
|
||||
/// the recorded commands and a log excerpt. Round-trips through
|
||||
/// 'am import' / archiving; the primary event journal is never touched.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SessionExport {
|
||||
pub format: &'static str,
|
||||
pub format_version: u32,
|
||||
pub exported_at: String,
|
||||
pub session: SessionRecord,
|
||||
/// Commands recorded by the REPL during this session (history/<id>.jsonl).
|
||||
pub commands: Vec<crate::history::HistoryEntry>,
|
||||
/// Tail of the agent log file (last ~4 KB), if any.
|
||||
pub log_excerpt: String,
|
||||
pub log_path: Option<String>,
|
||||
/// Human-readable rendering, for manual archiving.
|
||||
pub readable: String,
|
||||
}
|
||||
|
||||
/// Export one session (issue #53). Fails with a clear message when the id
|
||||
/// is unknown.
|
||||
pub fn export_session(app: &App, id: &str) -> Result<SessionExport> {
|
||||
let sf = load(app)?;
|
||||
let session = sf
|
||||
.sessions
|
||||
.get(id)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("unknown session '{id}' — use 'am sessions' to list them"))?;
|
||||
// Commands recorded by the REPL during this session.
|
||||
let commands = crate::history::read_session(app, id);
|
||||
// Log excerpt: last ~4 KB of the agent log file.
|
||||
let (log_excerpt, log_path) = match &session.log {
|
||||
Some(p) if PathBuf::from(p).is_file() => {
|
||||
let text = std::fs::read_to_string(p).unwrap_or_default();
|
||||
let excerpt = text.chars().rev().take(4096).collect::<String>().chars().rev().collect();
|
||||
(excerpt, Some(p.clone()))
|
||||
}
|
||||
other => (String::new(), other.clone()),
|
||||
};
|
||||
let mut readable = format!(
|
||||
"Session {}\n kind={} status={} agent={}\n started={} ended={}\n",
|
||||
session.id,
|
||||
session.kind,
|
||||
session.status,
|
||||
session.agent.as_deref().unwrap_or("-"),
|
||||
session.started_at,
|
||||
session.ended_at.as_deref().unwrap_or("-"),
|
||||
);
|
||||
if let Some(p) = &session.project {
|
||||
readable.push_str(&format!(" project={p}\n"));
|
||||
}
|
||||
if let Some(c) = session.exit_code {
|
||||
readable.push_str(&format!(" exit_code={c}\n"));
|
||||
}
|
||||
readable.push_str(&format!(
|
||||
" duration={}s\n",
|
||||
duration_s(&session).unwrap_or(0)
|
||||
));
|
||||
if !commands.is_empty() {
|
||||
readable.push_str(&format!("\ncommands ({}):\n", commands.len()));
|
||||
for c in commands.iter().rev().take(25) {
|
||||
readable.push_str(&format!(" {}\n", c.cmd));
|
||||
}
|
||||
}
|
||||
if !log_excerpt.is_empty() {
|
||||
readable.push_str(&format!("\nlog excerpt:\n{log_excerpt}\n"));
|
||||
}
|
||||
Ok(SessionExport {
|
||||
format: "am-session-export",
|
||||
format_version: 1,
|
||||
exported_at: crate::installers::now_rfc3339(),
|
||||
session,
|
||||
commands,
|
||||
log_excerpt,
|
||||
log_path,
|
||||
readable,
|
||||
})
|
||||
}
|
||||
|
||||
/// Purge sessions finished more than `retention_days` ago (issue #53).
|
||||
/// Only the derived sessions.json index is pruned — the primary event
|
||||
/// journal is untouched. Purged ids are journaled as EventKind::Prune so
|
||||
/// 'am audit' can explain the disappearance.
|
||||
pub fn purge_expired(app: &App, retention_days: u64) -> Result<usize> {
|
||||
let mut sf = load(app)?;
|
||||
let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64);
|
||||
let mut purged: Vec<String> = Vec::new();
|
||||
sf.sessions.retain(|id, r| {
|
||||
if r.status == "running" {
|
||||
return true; // never purge a live session
|
||||
}
|
||||
let end = r
|
||||
.ended_at
|
||||
.as_deref()
|
||||
.or(Some(r.started_at.as_str()))
|
||||
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
|
||||
.map(|t| t.with_timezone(&chrono::Utc));
|
||||
let keep = match end {
|
||||
Some(e) => e >= cutoff,
|
||||
None => true, // undatable session: keep it
|
||||
};
|
||||
if !keep {
|
||||
purged.push(id.clone());
|
||||
}
|
||||
keep
|
||||
});
|
||||
if !purged.is_empty() {
|
||||
save(app, &sf)?;
|
||||
let _ = app.emit(
|
||||
&crate::events::Event::now(crate::events::EventKind::Prune)
|
||||
.with_reason("sessions-retention")
|
||||
.with_args(vec![
|
||||
format!("{retention_days}d"),
|
||||
purged.len().to_string(),
|
||||
purged.join(","),
|
||||
]),
|
||||
);
|
||||
}
|
||||
Ok(purged.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -336,4 +460,46 @@ mod tests {
|
||||
assert_eq!(stopped[0].exit_code, Some(1));
|
||||
assert_eq!(stopped[0].status, "failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_is_complete_and_reproducible() {
|
||||
let app = test_app("export");
|
||||
let id = start_agent(&app, "claude-code", 4242, Some("/tmp".into()), None, vec![], vec![], Some("/nonexistent.log".into())).unwrap();
|
||||
finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
|
||||
let ex = export_session(&app, &id).unwrap();
|
||||
assert_eq!(ex.format, "am-session-export");
|
||||
assert_eq!(ex.session.id, id);
|
||||
assert!(ex.readable.contains("claude-code"), "readable rendering");
|
||||
// Unknown id fails clearly.
|
||||
assert!(export_session(&app, "nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_removes_only_finished_old_sessions() {
|
||||
let app = test_app("purge");
|
||||
// Finished 200 days ago: candidate.
|
||||
let id_old = start_agent(&app, "a", 1, None, None, vec![], vec![], None).unwrap();
|
||||
finish_agent(&app, "a", Some(1), 0, true).unwrap();
|
||||
// Finished yesterday: kept.
|
||||
let id_recent = start_agent(&app, "b", 2, None, None, vec![], vec![], None).unwrap();
|
||||
finish_agent(&app, "b", Some(2), 0, true).unwrap();
|
||||
// Still running: never purged even if old.
|
||||
let id_running = start_agent(&app, "c", 3, None, None, vec![], vec![], None).unwrap();
|
||||
|
||||
// Rewind the dates in one load-modify-save pass.
|
||||
let mut sf = load(&app).unwrap();
|
||||
let past = (chrono::Utc::now() - chrono::Duration::days(200)).to_rfc3339();
|
||||
sf.sessions.get_mut(&id_old).unwrap().ended_at = Some(past.clone());
|
||||
sf.sessions.get_mut(&id_recent).unwrap().ended_at =
|
||||
Some((chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339());
|
||||
sf.sessions.get_mut(&id_running).unwrap().started_at = past;
|
||||
save(&app, &sf).unwrap();
|
||||
|
||||
let purged = purge_expired(&app, 90).unwrap();
|
||||
assert_eq!(purged, 1, "only the 200-day-old finished session");
|
||||
let sf = load(&app).unwrap();
|
||||
assert!(!sf.sessions.contains_key(&id_old));
|
||||
assert!(sf.sessions.contains_key(&id_recent));
|
||||
assert!(sf.sessions.contains_key(&id_running));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Embedded project templates for 'am init --template' (issue #52).
|
||||
//! Each template renders an agent-manager.yaml pre-filled for a stack:
|
||||
//! a dev group, a default profile and project hooks. Variables {project}
|
||||
//! and {dir} are substituted at render time.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
/// (name, description) of every available template.
|
||||
pub const TEMPLATES: &[(&str, &str)] = &[
|
||||
("web", "frontend/web full-stack : node, npm, tests, serveur local"),
|
||||
("python", "python : uv/pip, pytest, ruff, mypy"),
|
||||
("rust", "rust : cargo, clippy, tests, rustfmt"),
|
||||
("cli", "générique CLI : commandes shell, scripts, git"),
|
||||
];
|
||||
|
||||
/// The four embedded templates.
|
||||
const WEB: &str = r#"# Généré par 'am init --template web' — projet {project}
|
||||
# Répertoire : {dir}
|
||||
version: "1.0"
|
||||
groups:
|
||||
dev: [claude-code, opencode, gemini-cli]
|
||||
profiles:
|
||||
dev:
|
||||
agent: claude-code
|
||||
env:
|
||||
NODE_ENV: development
|
||||
CI: "1"
|
||||
args: []
|
||||
settings:
|
||||
hooks:
|
||||
on_install: ["npm install"]
|
||||
on_start: ["npm run dev"]
|
||||
agents: []
|
||||
"#;
|
||||
|
||||
const PYTHON: &str = r#"# Généré par 'am init --template python' — projet {project}
|
||||
# Répertoire : {dir}
|
||||
version: "1.0"
|
||||
groups:
|
||||
dev: [claude-code, codex, aider]
|
||||
profiles:
|
||||
dev:
|
||||
agent: claude-code
|
||||
env:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
args: []
|
||||
settings:
|
||||
hooks:
|
||||
on_install: ["uv sync --all-extras"]
|
||||
on_start: ["uv run pytest -q"]
|
||||
agents: []
|
||||
"#;
|
||||
|
||||
const RUST: &str = r#"# Généré par 'am init --template rust' — projet {project}
|
||||
# Répertoire : {dir}
|
||||
version: "1.0"
|
||||
groups:
|
||||
dev: [claude-code, codex, jcode]
|
||||
profiles:
|
||||
dev:
|
||||
agent: claude-code
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: "1"
|
||||
args: []
|
||||
settings:
|
||||
hooks:
|
||||
on_install: ["cargo fetch"]
|
||||
on_start: ["cargo test"]
|
||||
agents: []
|
||||
"#;
|
||||
|
||||
const CLI: &str = r#"# Généré par 'am init --template cli' — projet {project}
|
||||
# Répertoire : {dir}
|
||||
version: "1.0"
|
||||
groups:
|
||||
dev: [claude-code, opencode]
|
||||
profiles:
|
||||
dev:
|
||||
agent: claude-code
|
||||
env: {}
|
||||
args: []
|
||||
agents: []
|
||||
"#;
|
||||
|
||||
fn template_of(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"web" => Some(WEB),
|
||||
"python" => Some(PYTHON),
|
||||
"rust" => Some(RUST),
|
||||
"cli" => Some(CLI),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a template for a project. `project` is the project name (used in
|
||||
/// the header comment), `dir` the absolute directory path. Unknown template
|
||||
/// names fail with the available list.
|
||||
pub fn render(name: &str, project: &str, dir: &str) -> Result<String> {
|
||||
let Some(tpl) = template_of(name) else {
|
||||
let list = TEMPLATES
|
||||
.iter()
|
||||
.map(|(n, _)| *n)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
bail!("unknown template '{name}' — available: {list}");
|
||||
};
|
||||
Ok(tpl.replace("{project}", project).replace("{dir}", dir))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn renders_all_templates_with_substitution() {
|
||||
for (name, _) in TEMPLATES {
|
||||
let out = render(name, "mon-app", "/work/mon-app").unwrap();
|
||||
assert!(out.contains("mon-app"), "{name}: project substituted");
|
||||
assert!(out.contains("/work/mon-app"), "{name}: dir substituted");
|
||||
assert!(!out.contains("{project}"), "{name}: no leftover var");
|
||||
assert!(!out.contains("{dir}"), "{name}: no leftover var");
|
||||
// The rendered config must parse and validate.
|
||||
let cfg: crate::config::Config = serde_yaml::from_str(&out).unwrap_or_else(|e| {
|
||||
panic!("{name}: invalid YAML: {e}\n{out}")
|
||||
});
|
||||
assert!(crate::config::validate(&cfg).is_empty(), "{name}: invalid config");
|
||||
assert!(!cfg.groups.is_empty(), "{name}: a dev group is expected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_template_fails_with_list() {
|
||||
let err = render("cobol", "x", "/tmp").unwrap_err();
|
||||
assert!(err.to_string().contains("web"), "{err}");
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -19,21 +19,21 @@ fn context_detects_stack_from_markers() {
|
||||
fn generate_writes_valid_config_and_refuses_overwrite() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
|
||||
let path = init_cmd::generate(dir.path(), false).unwrap();
|
||||
let path = init_cmd::generate(dir.path(), false, None).unwrap();
|
||||
assert!(path.ends_with("agent-manager.yaml"));
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains(r#"version: "1.0""#), "config valide: {text}");
|
||||
assert!(text.contains("smelt"), "agent recommande pour rust: {text}");
|
||||
// Un second appel sans --force échoue.
|
||||
assert!(init_cmd::generate(dir.path(), false).is_err());
|
||||
assert!(init_cmd::generate(dir.path(), false, None).is_err());
|
||||
// Avec --force, il écrase.
|
||||
assert!(init_cmd::generate(dir.path(), true).is_ok());
|
||||
assert!(init_cmd::generate(dir.path(), true, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_uses_default_for_empty_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = init_cmd::generate(dir.path(), false).unwrap();
|
||||
let path = init_cmd::generate(dir.path(), false, None).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("claude-code"), "default agent: {text}");
|
||||
}
|
||||
@@ -41,6 +41,6 @@ fn generate_uses_default_for_empty_dir() {
|
||||
#[test]
|
||||
fn command_runs_dry() {
|
||||
let app = common::test_app("initdry", &["init", "--dry-run"]);
|
||||
let code = init_cmd::run(&app, false).unwrap();
|
||||
let code = init_cmd::run(&app, false, None).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ fn agent_session_roundtrip_and_counter() {
|
||||
fn command_lists_sessions() {
|
||||
let app = common::test_app("sesscmd", &["sessions"]);
|
||||
sessions::start_agent(&app, "aider", 4242, None, None, vec![], vec![], None).unwrap();
|
||||
let code = sessions_cmd::run(&app, None, None, None, None, None).unwrap();
|
||||
let code = sessions_cmd::run(&app, None, None, None, None, None, None, None, None).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
// --json path returns the row set without error.
|
||||
sessions::finish_agent(&app, "aider", Some(4242), 0, true).unwrap();
|
||||
@@ -67,9 +67,9 @@ fn command_lists_sessions() {
|
||||
#[test]
|
||||
fn resume_unknown_or_repl_session_errors() {
|
||||
let app = common::test_app("resume", &["sessions"]);
|
||||
assert!(sessions_cmd::run(&app, None, None, None, None, Some("inconnue")).is_err());
|
||||
assert!(sessions_cmd::run(&app, None, None, None, None, Some("inconnue"), None, None, None).is_err());
|
||||
sessions::start_repl(&app, "r1", None).unwrap();
|
||||
assert!(sessions_cmd::run(&app, None, None, None, None, Some("r1")).is_err());
|
||||
assert!(sessions_cmd::run(&app, None, None, None, None, Some("r1"), None, None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -77,7 +77,7 @@ fn resume_dry_run_announces() {
|
||||
let app = common::test_app("resumedry", &["sessions", "--dry-run"]);
|
||||
let id = sessions::start_agent(&app, "claude-code", 4242, None, None, vec![], vec![], None).unwrap();
|
||||
sessions::finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
|
||||
let code = sessions_cmd::run(&app, None, None, None, None, Some(&id)).unwrap();
|
||||
let code = sessions_cmd::run(&app, None, None, None, None, Some(&id), None, None, None).unwrap();
|
||||
assert_eq!(code, 0, "dry-run resume");
|
||||
}
|
||||
|
||||
@@ -85,6 +85,6 @@ fn resume_dry_run_announces() {
|
||||
fn show_one_accepts_known_id() {
|
||||
let app = common::test_app("sessshow", &["sessions"]);
|
||||
let id = sessions::start_agent(&app, "jcode", 99, None, None, vec![], vec![], None).unwrap();
|
||||
let code = sessions_cmd::run(&app, None, None, None, Some(&id), None).unwrap();
|
||||
let code = sessions_cmd::run(&app, None, None, None, Some(&id), None, None, None, None).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user