Compare commits

...
15 Commits
Author SHA1 Message Date
bruno 6ecfa0d21c v0.3.0 : Phase 0 du ROADMAP livree - journal d'evenements, sessions, stats, log, historique structure, am init, quick wins; README et ROADMAP a jour 2026-08-16 22:48:53 -04:00
bruno 3902bbcb37 Merge pull request 'quick wins #9-#13 et #16 : logs, list --sort, config set, alias, dedup, open' (#24) from feat/quick-wins into main 2026-08-16 22:45:27 -04:00
bruno d4a6aeae1d quick wins (issues #9 #10 #11 #12 #13 #16) : am logs (tail+follow), am list --running/--sort, am config set, am alias add/remove/list, dedup historique, am open 2026-08-16 22:45:11 -04:00
bruno 38d0729cb3 Merge pull request 'init + contexte : am init et détection de projet (issue #8)' (#23) from feat/init-context into main 2026-08-16 22:40:15 -04:00
bruno 85d44f9e56 init+contexte : am init genere un agent-manager.yaml selon la stack detectee + contexte projet (git root, branche, stack) attache aux evenements et sessions (issue #8) 2026-08-16 22:40:00 -04:00
bruno 48e85555f7 Merge pull request 'history : historique structuré + am history (issue #7)' (#22) from feat/history into main 2026-08-16 22:35:01 -04:00
bruno c9e2543f50 history : historique structure par session + am history (issue #7) - migration one-shot de history.txt, filtres kind/cwd/search/failed/session, enregistrement REPL avec duree et exit 2026-08-16 22:34:48 -04:00
bruno 9f4a13eae2 Merge pull request 'stats + log : observabilité du journal (issue #6)' (#21) from feat/stats-log into main 2026-08-16 22:30:54 -04:00
bruno 33748bea36 stats+log : am stats (agregats par agent, periodes 7d/30d/90d, barres ASCII) et am log (filtres agent/kind/date, limite) (issue #6) 2026-08-16 22:30:41 -04:00
bruno 19e77fd261 Merge pull request 'sessions : registre central + am sessions (issue #5)' (#20) from feat/sessions into main 2026-08-16 22:22:23 -04:00
bruno 38b7a3a915 sessions : registre des sessions + commande am sessions (issue #5) - lifecycle agent/repl, reconciliation des sessions interrompues, compteur sessions_count, filtres agent/projet/statut, show avec extrait de log 2026-08-16 22:22:09 -04:00
bruno 3af0834bae Merge pull request 'state : v2 — sessions_count et last_used (issue #4)' (#19) from feat/state-v2 into main 2026-08-16 22:16:56 -04:00
bruno c6518bcfd5 state : v2 avec sessions_count et last_used (issue #4) - migration additive persistee au premier contact, touch() sur start/run, fixture v1 testee 2026-08-16 22:16:44 -04:00
bruno a672880e34 Merge pull request 'events : instrumentation des commandes (issue #3)' (#18) from feat/instrumentation into main 2026-08-16 22:14:03 -04:00
bruno 310da96a63 events : instrumentation start/stop/run/install/update/uninstall/doctor/repl/shell (issue #3) - un evenement JSONL par action reelle, dry-run sans evenement, session id sur start, duree et exit code sur stop/run 2026-08-16 22:13:49 -04:00
36 changed files with 3029 additions and 54 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "0.2.14" version = "0.3.0"
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"
+21 -1
View File
@@ -90,6 +90,9 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
| am install <agent> | installe (dépendances vérifiées et proposées) | | am install <agent> | installe (dépendances vérifiées et proposées) |
| am install <agent> --method pip | choisit une méthode d'installation | | am install <agent> --method pip | choisit une méthode d'installation |
| am update <agent> | met à jour vers la dernière version connue | | am update <agent> | met à jour vers la dernière version connue |
| am config set settings.default_shell pwsh | modifie la configuration sans éditeur |
| am alias add cc claude-code | raccourci pour un agent (list / remove aussi) |
| am init | génère un agent-manager.yaml selon la pile du projet (Node/Rust/Python…) |
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) — gère aussi les agents externes (npm/pip/uv/cargo/bun, sinon suppression des fichiers) | | am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) — gère aussi les agents externes (npm/pip/uv/cargo/bun, sinon suppression des fichiers) |
| am export / am import | sauvegarde et restaure config + état | | am export / am import | sauvegarde et restaure config + état |
@@ -103,13 +106,24 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
| am restart <agent> | arrête puis redémarre | | am restart <agent> | arrête puis redémarre |
| am run <agent> [args…] | exécution directe, sans gestion de processus | | am run <agent> [args…] | exécution directe, sans gestion de processus |
### 📊 Observer & retrouver
| Commande | Rôle |
|----------|------|
| am stats [agent] --period 7d/30d/90d | statistiques d'utilisation : lancements, durées, échecs, barres ASCII |
| am log [agent] --kind --since | journal des événements : tout ce que am a fait |
| am sessions [agent] --status --show | registre des sessions (agents + shell interactif) |
| am history --search --failed | historique des commandes (am + shell), filtrable |
| am logs <agent> --follow | tail du log d'un agent, en direct |
### 🧰 Système ### 🧰 Système
| Commande | Rôle | | Commande | Rôle |
|----------|------| |----------|------|
| am doctor | vérifie outils, config, chemins — avec commandes d'installation | | am doctor | vérifie outils, config, chemins — avec commandes d'installation |
| am doctor --fix | répare ce qui peut l'être, propose d'installer les outils manquants | | am doctor --fix | répare ce qui peut l'être, propose d'installer les outils manquants |
| am config show / edit / add | gère votre configuration | | am config show / edit / add / set | gère votre configuration |
| am open <agent> | ouvre le répertoire d'installation dans l'explorateur |
| am completion <shell> | script de complétion (bash, zsh, fish, powershell) | | am completion <shell> | script de complétion (bash, zsh, fish, powershell) |
| am self-update | met à jour am lui-même (si configuré) | | am self-update | met à jour am lui-même (si configuré) |
| am self-uninstall | désinstalle am et tout ce qu'il a créé (confirmation) | | am self-uninstall | désinstalle am et tout ce qu'il a créé (confirmation) |
@@ -294,6 +308,12 @@ multi-fichiers pris en charge ; configuration validée au démarrage.
(Windows) — l'état et les logs dans ~/.local/state/agent-manager (Linux) ou (Windows) — l'état et les logs dans ~/.local/state/agent-manager (Linux) ou
%LOCALAPPDATA%\\agent-manager (Windows). %LOCALAPPDATA%\\agent-manager (Windows).
**Où vivent les statistiques, les sessions et l'historique ?** Dans le
répertoire d'état, au format JSON lisible et 100 % local : events-YYYYMM.jsonl
(journal de chaque action), sessions.json (registre des sessions),
history/ (historique structuré du shell). Interrogeables avec am stats,
am log, am sessions et am history.
**Comment mettre à jour am ?** Relancez l'installeur one-liner (ou **Comment mettre à jour am ?** Relancez l'installeur one-liner (ou
am self-update si le dépôt de release est configuré). am self-update si le dépôt de release est configuré).
+4 -1
View File
@@ -1,7 +1,10 @@
# 🗺️ ROADMAP agent-manager — vers le « super outil » # 🗺️ ROADMAP agent-manager — vers le « super outil »
> **Version du document : 2.0** · Statut : propositions, non engagées > **Version du document : 2.1** · Statut : propositions, non engagées
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime) > Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
>
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
> 157 tests, release publiée sur Gitea. Les phases 1 à 3 restent à planifier.
--- ---
+17
View File
@@ -121,6 +121,23 @@ impl App {
self.theme.set(theme); self.theme.set(theme);
} }
/// Directory of the event journal (the state directory).
pub fn events_dir(&self) -> PathBuf {
self.paths
.state_file
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| self.paths.state_file.clone())
}
/// Append an event to the journal; failures are never fatal (logged at
/// verbose level only).
pub fn emit(&self, event: &crate::events::Event) {
if let Err(e) = crate::events::append(&self.events_dir(), event) {
self.log.verbose(&format!("cannot write event journal: {e:#}"));
}
}
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui. /// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
pub fn confirm(&self, question: &str) -> Result<bool> { pub fn confirm(&self, question: &str) -> Result<bool> {
if self.cli.yes { if self.cli.yes {
+114
View File
@@ -68,7 +68,32 @@ pub enum Command {
/// Only show agents of this category /// Only show agents of this category
#[arg(long, value_name = "CATEGORY")] #[arg(long, value_name = "CATEGORY")]
category: Option<String>, category: Option<String>,
/// Only show running agents
#[arg(long)]
running: bool,
/// Sort by name, version or status (default: name)
#[arg(long, value_name = "KEY")]
sort: Option<String>,
}, },
/// Tail the log file of an agent (--follow for a live view)
Logs {
/// Agent name or alias
agent: String,
/// Number of lines to show (default: 20)
#[arg(long, value_name = "N", default_value = "20")]
lines: usize,
/// Follow the log as it grows
#[arg(long)]
follow: bool,
},
/// Open an agent's installation directory in the file manager
Open {
/// Agent name or alias
agent: String,
},
/// Manage command aliases (cc -> claude-code)
#[command(subcommand)]
Alias(AliasCmd),
/// Start an agent (foreground by default, or detached with --background) /// Start an agent (foreground by default, or detached with --background)
Start(StartArgs), Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout) /// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
@@ -98,6 +123,42 @@ pub enum Command {
/// Agent name or alias (omit to show all installed agents) /// Agent name or alias (omit to show all installed agents)
agent: Option<String>, agent: Option<String>,
}, },
/// List the sessions of every agent (and of the interactive shell)
Sessions {
/// Only sessions of this agent
agent: Option<String>,
/// Only sessions of this project
#[arg(long, value_name = "PROJECT")]
project: Option<String>,
/// Only sessions with this status (running, stopped, failed, interrupted)
#[arg(long, value_name = "STATUS")]
status: Option<String>,
/// Show one session in detail (summary + log excerpt)
#[arg(long, value_name = "ID")]
show: Option<String>,
},
/// Show usage statistics computed from the event journal
Stats {
/// Agent name or alias (omit for the global view)
agent: Option<String>,
/// Only events of the last period (7d, 30d, 90d, all)
#[arg(long, value_name = "PERIOD")]
period: Option<String>,
},
/// Read the event journal: everything am did
Log {
/// Only events of this agent
agent: Option<String>,
/// Only these event kinds (comma separated: start,stop,run,install,...)
#[arg(long, value_name = "KINDS")]
kind: Option<String>,
/// Only events at or after this date (YYYY-MM-DD or RFC 3339)
#[arg(long, value_name = "DATE")]
since: Option<String>,
/// Maximum number of events (default: 50)
#[arg(long, value_name = "N", default_value = "50")]
limit: usize,
},
/// Install an agent and its dependencies /// Install an agent and its dependencies
Install { Install {
/// Agent name or alias /// Agent name or alias
@@ -133,11 +194,38 @@ pub enum Command {
#[arg(long, value_name = "CATEGORY")] #[arg(long, value_name = "CATEGORY")]
category: Option<String>, category: Option<String>,
}, },
/// Search the command history (am and shell commands)
History {
/// Only commands of this kind (am or shell)
#[arg(long, value_name = "KIND")]
kind: Option<String>,
/// Only commands run in this directory
#[arg(long, value_name = "DIR")]
cwd: Option<String>,
/// Full-text search in the command line
#[arg(long, value_name = "TEXT")]
search: Option<String>,
/// Only failed commands
#[arg(long)]
failed: bool,
/// Only commands of this session
#[arg(long, value_name = "ID")]
session: Option<String>,
/// Maximum number of entries (default: 100)
#[arg(long, value_name = "N", default_value = "100")]
limit: usize,
},
/// Show detailed information about one agent /// Show detailed information about one agent
Info { Info {
/// Agent name or alias /// Agent name or alias
agent: String, agent: String,
}, },
/// Generate a local agent-manager.yaml for the current directory
Init {
/// Overwrite an existing agent-manager.yaml
#[arg(long)]
force: bool,
},
/// Display help: the overview, a command, the command list, or a search /// Display help: the overview, a command, the command list, or a search
Help { Help {
/// Command name (also accepts an agent name or alias); "commands" lists every command /// Command name (also accepts an agent name or alias); "commands" lists every command
@@ -221,6 +309,25 @@ pub struct StartArgs {
pub notify: bool, pub notify: bool,
} }
/// Alias management subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum AliasCmd {
/// Add an alias (am alias add cc claude-code)
Add {
/// Short name of the alias
name: String,
/// Agent name (or another alias) it points to
target: String,
},
/// Remove an alias
Remove {
/// Alias to remove
name: String,
},
/// List every alias
List,
}
/// Configuration management subcommands. /// Configuration management subcommands.
#[derive(Subcommand, Debug, Clone)] #[derive(Subcommand, Debug, Clone)]
pub enum ConfigCmd { pub enum ConfigCmd {
@@ -237,4 +344,11 @@ pub enum ConfigCmd {
/// YAML (or JSON) file defining agents to include /// YAML (or JSON) file defining agents to include
file: PathBuf, file: PathBuf,
}, },
/// Set one configuration key without an editor (config set settings.default_shell pwsh)
Set {
/// Dotted key path, e.g. settings.default_shell
key: String,
/// New value (booleans and integers keep their type)
value: String,
},
} }
+103
View File
@@ -0,0 +1,103 @@
//! alias: manage command aliases (cc -> claude-code) in the user config.
use super::*;
use crate::cli::AliasCmd;
use crate::output::print_json;
use anyhow::{anyhow, Context, Result};
use std::path::PathBuf;
/// Ensure the user config file exists and return its path.
fn user_config(app: &App) -> Result<PathBuf> {
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)?;
let path = dir.join(crate::config::CONFIG_FILE_NAME);
if !path.exists() {
std::fs::write(&path, concat!("version: \"1.0\"\n", "agents: []\n"))
.with_context(|| format!("cannot create {}", path.display()))?;
}
Ok(path)
}
/// Read the aliases map from the user config file.
fn read_aliases(app: &App) -> Result<(PathBuf, serde_yaml::Mapping)> {
let path = user_config(app)?;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?;
let mapping = value
.as_mapping_mut()
.ok_or_else(|| anyhow!("{} must contain a YAML mapping", path.display()))?;
let aliases = mapping
.entry(serde_yaml::Value::String("aliases".to_string()))
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()))
.as_mapping()
.cloned()
.unwrap_or_default();
Ok((path, aliases))
}
fn write_aliases(app: &App, path: &std::path::Path, aliases: serde_yaml::Mapping) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?;
if let Some(mapping) = value.as_mapping_mut() {
mapping.insert(
serde_yaml::Value::String("aliases".to_string()),
serde_yaml::Value::Mapping(aliases),
);
}
std::fs::write(path, serde_yaml::to_string(&value)?)
.with_context(|| format!("cannot write {}", path.display()))
}
pub fn run(app: &App, sub: &AliasCmd) -> Result<i32> {
match sub {
AliasCmd::Add { name, target } => {
// The target must be a known agent (or another alias).
require_agent(app, target)?;
let (path, mut aliases) = read_aliases(app)?;
aliases.insert(
serde_yaml::Value::String(name.clone()),
serde_yaml::Value::String(target.clone()),
);
write_aliases(app, &path, aliases)?;
app.log.success(&format!("alias '{name}' -> '{target}'"));
Ok(0)
}
AliasCmd::Remove { name } => {
let (path, mut aliases) = read_aliases(app)?;
let removed = aliases
.remove(&serde_yaml::Value::String(name.clone()))
.is_some();
if removed {
write_aliases(app, &path, aliases)?;
app.log.success(&format!("alias '{name}' removed"));
} else {
app.log.info(&format!("alias '{name}' does not exist"));
}
Ok(0)
}
AliasCmd::List => {
if app.json() {
print_json(&app.config.aliases);
return Ok(0);
}
if app.config.aliases.is_empty() {
app.log.info("no aliases defined — 'am alias add cc claude-code'");
return Ok(0);
}
let mut table = crate::output::Table::new(vec!["ALIAS", "TARGET"]);
for (k, v) in &app.config.aliases {
table.row(vec![k.clone(), v.clone()]);
}
print!("{}", table.render());
Ok(0)
}
}
}
+55
View File
@@ -5,6 +5,7 @@ use crate::cli::ConfigCmd;
use crate::config; use crate::config;
use crate::output::print_json; use crate::output::print_json;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use clap::Parser;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
@@ -94,9 +95,63 @@ pub fn run(app: &App, cmd: &ConfigCmd) -> Result<i32> {
Ok(status.code().unwrap_or(1)) Ok(status.code().unwrap_or(1))
} }
ConfigCmd::Add { file } => add_file(app, file), ConfigCmd::Add { file } => add_file(app, file),
ConfigCmd::Set { key, value } => set_value(app, key, value),
} }
} }
/// config set: set one dotted key (e.g. settings.default_shell) in the user
/// configuration file, then validate the result.
pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
let path = user_config_path(app)?;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut root: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?;
let parts: Vec<&str> = key.split('.').collect();
if parts.is_empty() || parts.iter().any(|p| p.is_empty()) {
return Err(anyhow!("invalid key '{key}' — expected a dotted path like settings.default_shell"));
}
let parsed = parse_scalar(value);
let mut node = root
.as_mapping_mut()
.ok_or_else(|| anyhow!("{} must contain a YAML mapping", path.display()))?;
for part in &parts[..parts.len() - 1] {
let entry = node
.entry(serde_yaml::Value::String(part.to_string()))
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()));
node = entry
.as_mapping_mut()
.ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?;
}
node.insert(
serde_yaml::Value::String(parts.last().unwrap().to_string()),
parsed,
);
std::fs::write(&path, serde_yaml::to_string(&root)?)
.with_context(|| format!("cannot write {}", path.display()))?;
// Validate the new configuration end to end.
let cli = crate::cli::Cli::parse_from(["am", "--config", path.to_str().unwrap_or_default()]);
config::load(&cli).map_err(|e| {
anyhow!("the new value makes the configuration invalid: {e:#}")
})?;
app.log.success(&format!("{key} = {value}"));
Ok(0)
}
/// Parse a scalar: booleans and integers keep their type, everything else
/// stays a string.
fn parse_scalar(value: &str) -> serde_yaml::Value {
if let Ok(b) = value.parse::<bool>() {
return serde_yaml::Value::Bool(b);
}
if let Ok(i) = value.parse::<i64>() {
return serde_yaml::Value::Number(i.into());
}
serde_yaml::Value::String(value.to_string())
}
fn user_config_path_if_any(app: &App) -> Option<PathBuf> { fn user_config_path_if_any(app: &App) -> Option<PathBuf> {
app.paths app.paths
.config_dir .config_dir
+7
View File
@@ -2,6 +2,7 @@
use super::*; use super::*;
use crate::doctor::{self, Status}; use crate::doctor::{self, Status};
use crate::events::{Event, EventKind};
use crate::output::{boxed_table, print_json, status_style}; use crate::output::{boxed_table, print_json, status_style};
use crate::runner::SystemRunner; use crate::runner::SystemRunner;
use anyhow::Result; use anyhow::Result;
@@ -22,6 +23,12 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
// The doctor report probes real tools, even under --dry-run. // The doctor report probes real tools, even under --dry-run.
let runner = SystemRunner::new(false, false, &app.log); let runner = SystemRunner::new(false, false, &app.log);
let report = doctor::run(app, &runner); let report = doctor::run(app, &runner);
if !app.dry_run() {
app.emit(
&Event::now(EventKind::Doctor)
.with_exit_code(if report.ok() { 0 } else { 1 }),
);
}
if app.json() { if app.json() {
print_json(&report.checks); print_json(&report.checks);
return Ok(if report.ok() { 0 } else { 1 }); return Ok(if report.ok() { 0 } else { 1 });
+96
View File
@@ -0,0 +1,96 @@
//! history: search and reuse the structured command history.
use super::*;
use crate::history::{self, HistoryEntry};
use crate::output::print_json;
/// Filter history entries; returns them most recent first.
pub fn filter(
app: &App,
kind: Option<&str>,
cwd: Option<&str>,
search: Option<&str>,
failed: bool,
session: Option<&str>,
limit: usize,
) -> Vec<(String, HistoryEntry)> {
let _ = session; // session filtering happens by reading one file directly
let mut out: Vec<(String, HistoryEntry)> = Vec::new();
let dir = history::dir(app);
for f in history::files(&dir).iter().rev() {
let sid = history::session_of_file(f).unwrap_or_default();
if let Some(s) = session {
if sid != s {
continue;
}
}
let Ok(text) = std::fs::read_to_string(f) else {
continue;
};
let mut entries: Vec<HistoryEntry> = text
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<HistoryEntry>(l).ok())
.collect();
entries.reverse();
for e in entries {
if kind.map_or(true, |k| e.kind == k)
&& cwd.map_or(true, |c| e.cwd.as_deref() == Some(c))
&& search.map_or(true, |s| e.cmd.to_lowercase().contains(&s.to_lowercase()))
&& (!failed || e.exit_code.is_some())
{
out.push((sid.clone(), e));
if limit > 0 && out.len() >= limit {
return out;
}
}
}
}
out
}
pub fn run(
app: &App,
kind: Option<&str>,
cwd: Option<&str>,
search: Option<&str>,
failed: bool,
session: Option<&str>,
limit: usize,
) -> Result<i32> {
history::migrate(app)?;
let rows = filter(app, kind, cwd, search, failed, session, limit);
if app.json() {
let payload: Vec<serde_json::Value> = rows
.iter()
.enumerate()
.map(|(i, (sid, e))| {
serde_json::json!({
"n": rows.len() - i,
"ts": e.ts,
"cmd": e.cmd,
"kind": e.kind,
"cwd": e.cwd,
"exit_code": e.exit_code,
"duration_ms": e.duration_ms,
"session": sid,
})
})
.collect();
print_json(&payload);
return Ok(0);
}
for (i, (_sid, e)) in rows.iter().enumerate() {
let n = rows.len() - i;
let ts = e.ts.get(0..16).unwrap_or(&e.ts);
let flag = match e.exit_code {
Some(_) => "✗".to_string(),
None => " ".to_string(),
};
println!("{n:>4} {flag} {ts} {}", e.cmd);
}
if rows.is_empty() {
app.log.info("no matching history — run 'am' and type some commands");
}
Ok(0)
}
+70
View File
@@ -0,0 +1,70 @@
//! init: generate a local agent-manager.yaml for the current directory,
//! based on the detected technology stack.
use super::*;
use crate::context;
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> {
let target = dir.join(crate::config::LOCAL_CONFIG_NAME);
if target.exists() && !force {
return Err(anyhow!(
"{} already exists — use --force to overwrite it",
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()
} else {
ctx.stack.join(", ")
};
let content = 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(),
);
std::fs::write(&target, content)
.with_context(|| format!("cannot write {}", target.display()))?;
Ok(target)
}
pub fn run(app: &App, force: bool) -> Result<i32> {
if app.dry_run() {
app.log.dry(format!(
"would generate {} in the current directory",
crate::config::LOCAL_CONFIG_NAME
));
return Ok(0);
}
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(", ")
}
));
app.log.info(&format!(
"group 'dev' prêt : am start group:dev — ajustez ensuite la config générée"
));
Ok(0)
}
+6
View File
@@ -2,6 +2,7 @@
//! installer, and record the installation in the state database. //! installer, and record the installation in the state database.
use super::*; use super::*;
use crate::events::{Event, EventKind};
use crate::installers; use crate::installers;
use crate::runner::SystemRunner; use crate::runner::SystemRunner;
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
@@ -137,6 +138,11 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -
let entry = installers::make_entry(agent, method, &outcome, app); let entry = installers::make_entry(agent, method, &outcome, app);
let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string()); let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string());
app.state.set(&entry)?; app.state.set(&entry)?;
app.emit(
&Event::now(EventKind::Install)
.with_agent(agent.name.clone())
.with_args(vec![format!("method={}", method.kind)]),
);
app.log.success(&format!( app.log.success(&format!(
"installed {} (v{version}) in {}", "installed {} (v{version}) in {}",
agent.title(), agent.title(),
+16 -2
View File
@@ -25,7 +25,13 @@ struct AgentRow {
} }
/// run: installed agents only, unless all is set (whole catalog). /// run: installed agents only, unless all is set (whole catalog).
pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> { pub fn run(
app: &App,
all: bool,
category: Option<&str>,
running: bool,
sort: Option<&str>,
) -> Result<i32> {
let entries = app.state.all().unwrap_or_default(); let entries = app.state.all().unwrap_or_default();
let probe = crate::probe::detect_externals(app); let probe = crate::probe::detect_externals(app);
let mut rows: Vec<AgentRow> = Vec::new(); let mut rows: Vec<AgentRow> = Vec::new();
@@ -43,7 +49,15 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
if !all { if !all {
rows.retain(|r| r.status == "running" || r.status == "installed" || r.status == "external"); rows.retain(|r| r.status == "running" || r.status == "installed" || r.status == "external");
} }
rows.sort_by(|a, b| a.name.cmp(&b.name)); if running {
rows.retain(|r| r.status == "running");
}
match sort {
Some("version") => rows.sort_by(|a, b| b.version.cmp(&a.version)),
Some("status") => rows.sort_by(|a, b| a.status.cmp(&b.status)),
Some(other) => bail!("invalid sort key '{other}' — expected name, version or status"),
None => rows.sort_by(|a, b| a.name.cmp(&b.name)),
}
if app.json() { if app.json() {
print_json(&rows); print_json(&rows);
+120
View File
@@ -0,0 +1,120 @@
//! log: a filtered view of the event journal — everything am did.
use super::*;
use crate::events::{self, Event};
use crate::output::print_json;
use serde::Serialize;
/// Filter journal events: agent, kind list, since date, and a max count.
/// Returns events in reverse chronological order (most recent first).
pub fn filter_events(
app: &App,
agent: Option<&str>,
kinds: Option<&str>,
since: Option<&str>,
limit: usize,
) -> Result<Vec<Event>> {
let mut events = events::read_events(&app.events_dir(), 0);
if let Some(a) = agent {
events.retain(|e| e.agent.as_deref() == Some(a));
}
if let Some(list) = kinds {
let wanted: Vec<&str> = list.split(',').map(|s| s.trim()).collect();
events.retain(|e| wanted.iter().any(|w| e.kind.as_str() == *w));
}
if let Some(s) = since {
let cut = parse_since(s)?;
events.retain(|e| {
chrono::DateTime::parse_from_rfc3339(&e.ts)
.map(|t| t >= cut)
.unwrap_or(false)
});
}
events.reverse(); // most recent first
events.truncate(limit);
Ok(events)
}
fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::FixedOffset>> {
if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) {
return Ok(t);
}
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|_| anyhow!("invalid date '{s}' — expected YYYY-MM-DD or an RFC 3339 timestamp"))?
.and_hms_opt(0, 0, 0)
.map(|d| chrono::DateTime::from_naive_utc_and_offset(d, chrono::FixedOffset::east_opt(0).unwrap()))
.ok_or_else(|| anyhow!("invalid date '{s}'"))
}
#[derive(Serialize)]
struct LogRow {
ts: String,
kind: String,
agent: Option<String>,
pid: Option<u32>,
exit_code: Option<i32>,
duration_s: Option<u64>,
session: Option<String>,
}
impl From<&Event> for LogRow {
fn from(e: &Event) -> Self {
LogRow {
ts: e.ts.clone(),
kind: e.kind.as_str().to_string(),
agent: e.agent.clone(),
pid: e.pid,
exit_code: e.exit_code,
duration_s: e.duration_s,
session: e.session.clone(),
}
}
}
pub fn run(
app: &App,
agent: Option<&str>,
kinds: Option<&str>,
since: Option<&str>,
limit: usize,
) -> Result<i32> {
let events = filter_events(app, agent, kinds, since, limit)?;
if app.json() {
let rows: Vec<LogRow> = events.iter().map(LogRow::from).collect();
print_json(&rows);
return Ok(0);
}
for e in &events {
let mut parts = vec![e.ts.clone(), e.kind.as_str().to_string()];
if let Some(a) = &e.agent {
parts.push(a.clone());
}
if let Some(p) = e.pid {
parts.push(format!("pid={p}"));
}
if let Some(c) = e.exit_code {
parts.push(format!("exit={c}"));
}
if let Some(d) = e.duration_s {
parts.push(format!("dur={d}s"));
}
println!("{}", parts.join(" "));
}
if events.is_empty() {
app.log.info("no matching events in the journal");
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_since_accepts_date_and_timestamp() {
let d = parse_since("2026-08-01").unwrap();
assert_eq!(d.format("%Y-%m-%d").to_string(), "2026-08-01");
assert!(parse_since("2026-08-01T00:00:00Z").is_ok());
assert!(parse_since("hier").is_err());
}
}
+56
View File
@@ -0,0 +1,56 @@
//! logs: tail the log file of an agent (--follow for a live view).
use super::*;
use crate::output::print_json;
use anyhow::{bail, Result};
use std::path::PathBuf;
/// Resolve the log path of an agent (validates the agent first).
pub fn log_path(app: &App, agent_name: &str) -> Result<PathBuf> {
let agent = require_agent(app, agent_name)?;
Ok(crate::process::agent_log_path(app, &agent.name))
}
pub fn run(app: &App, agent: &str, lines: usize, follow: bool) -> Result<i32> {
let path = log_path(app, agent)?;
if !path.exists() {
bail!(
"no log yet for '{}' — start it with 'am start {} --background'",
agent,
agent
);
}
if app.json() {
let tail = crate::process::last_lines(&path, lines);
print_json(&serde_json::json!({
"agent": agent,
"log": path.display().to_string(),
"tail": tail,
}));
return Ok(0);
}
if !follow {
print!("{}", crate::process::last_lines(&path, lines));
return Ok(0);
}
// Live follow: print the initial tail, then new bytes as they arrive.
let mut seen: usize = std::fs::metadata(&path).map(|m| m.len() as usize).unwrap_or(0);
loop {
if let Ok(text) = std::fs::read_to_string(&path) {
let bytes = text.as_bytes();
if bytes.len() > seen {
// Slicer sans risque de couper un caractere UTF-8.
let tail: String = text
.char_indices()
.filter(|(i, _)| *i >= seen)
.map(|(_, c)| c)
.collect();
print!("{tail}");
use std::io::Write;
let _ = std::io::stdout().flush();
}
seen = bytes.len();
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
+57 -1
View File
@@ -1,16 +1,24 @@
//! Command implementations and dispatch. //! Command implementations and dispatch.
pub mod alias_cmd;
pub mod completion_cmd; pub mod completion_cmd;
pub mod config_cmd; pub mod config_cmd;
pub mod doctor_cmd; pub mod doctor_cmd;
pub mod export_import; pub mod export_import;
pub mod help_cmd; pub mod help_cmd;
pub mod history_cmd;
pub mod info_cmd; pub mod info_cmd;
pub mod init_cmd;
pub mod install_cmd; pub mod install_cmd;
pub mod list_cmd; pub mod list_cmd;
pub mod log_cmd;
pub mod logs_cmd;
pub mod open_cmd;
pub mod run_cmd; pub mod run_cmd;
pub mod search_cmd; pub mod search_cmd;
pub mod self_uninstall; pub mod self_uninstall;
pub mod sessions_cmd;
pub mod stats_cmd;
pub mod self_update; pub mod self_update;
pub mod status_cmd; pub mod status_cmd;
pub mod uninstall_cmd; pub mod uninstall_cmd;
@@ -35,7 +43,19 @@ pub fn execute(app: &App) -> Result<i32> {
/// Execute one parsed command (also used by the interactive shell). /// Execute one parsed command (also used by the interactive shell).
pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> { pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
match cmd { match cmd {
Command::List { all, category } => list_cmd::run(app, *all, category.as_deref()), Command::List {
all,
category,
running,
sort,
} => list_cmd::run(app, *all, category.as_deref(), *running, sort.as_deref()),
Command::Logs {
agent,
lines,
follow,
} => logs_cmd::run(app, agent, *lines, *follow),
Command::Open { agent } => open_cmd::run(app, agent),
Command::Alias(sub) => alias_cmd::run(app, sub),
Command::Start(a) => run_cmd::start(app, a), Command::Start(a) => run_cmd::start(app, a),
Command::Stop { Command::Stop {
agent, agent,
@@ -48,6 +68,25 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
timeout, timeout,
} => run_cmd::restart(app, start, *force, *timeout), } => run_cmd::restart(app, start, *force, *timeout),
Command::Status { agent } => status_cmd::run(app, agent.as_deref()), Command::Status { agent } => status_cmd::run(app, agent.as_deref()),
Command::Stats { agent, period } => stats_cmd::run(app, agent.as_deref(), period.as_deref()),
Command::Log {
agent,
kind,
since,
limit,
} => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit),
Command::Sessions {
agent,
project,
status,
show,
} => sessions_cmd::run(
app,
agent.as_deref(),
project.as_deref(),
status.as_deref(),
show.as_deref(),
),
Command::Install { Command::Install {
agent, agent,
method, method,
@@ -56,7 +95,24 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge), Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all), Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()), Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()),
Command::History {
kind,
cwd,
search,
failed,
session,
limit,
} => history_cmd::run(
app,
kind.as_deref(),
cwd.as_deref(),
search.as_deref(),
*failed,
session.as_deref(),
*limit,
),
Command::Info { agent } => info_cmd::run(app, agent), Command::Info { agent } => info_cmd::run(app, agent),
Command::Init { force } => init_cmd::run(app, *force),
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()), Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
Command::Version => version_cmd::run(app), Command::Version => version_cmd::run(app),
Command::Config(sub) => config_cmd::run(app, sub), Command::Config(sub) => config_cmd::run(app, sub),
+42
View File
@@ -0,0 +1,42 @@
//! open: open an agent's installation directory in the file manager.
use super::*;
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
/// Resolve the directory to open: the managed install dir, or the parent of
/// the detected external binary.
pub fn resolve_target(app: &App, agent_name: &str) -> Result<PathBuf> {
let agent = require_agent(app, agent_name)?;
if let Some(entry) = app.state.get(&agent.name).ok().flatten() {
return Ok(PathBuf::from(&entry.install_dir));
}
let probe = crate::probe::detect_externals(app);
if let Some(bin) = probe.path_of(&agent.name) {
return Ok(bin
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| bin.clone()));
}
bail!("agent '{}' is not installed — run 'am install {}' first", agent_name, agent_name);
}
pub fn run(app: &App, agent: &str) -> Result<i32> {
let target = resolve_target(app, agent)?;
if app.dry_run() {
app.log.dry(format!("would open {}", target.display()));
return Ok(0);
}
if !target.exists() {
bail!("directory {} does not exist", target.display());
}
#[cfg(windows)]
let opened = std::process::Command::new("explorer").arg(&target).status();
#[cfg(target_os = "macos")]
let opened = std::process::Command::new("open").arg(&target).status();
#[cfg(all(unix, not(target_os = "macos")))]
let opened = std::process::Command::new("xdg-open").arg(&target).status();
opened
.with_context(|| format!("cannot open {}", target.display()))?;
Ok(0)
}
+86 -2
View File
@@ -2,7 +2,9 @@
use super::*; use super::*;
use crate::cli::StartArgs; use crate::cli::StartArgs;
use crate::events::{self, Event, EventKind};
use crate::process; use crate::process;
use crate::sessions;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::ffi::OsString; use std::ffi::OsString;
@@ -37,6 +39,7 @@ fn start_one(
let exec = resolve_exec(app, agent, extra_args, extra_env)?; let exec = resolve_exec(app, agent, extra_args, extra_env)?;
let bin = PathBuf::from(&exec.program); let bin = PathBuf::from(&exec.program);
let is_managed = app.state.get(&agent.name).ok().flatten().is_some(); let is_managed = app.state.get(&agent.name).ok().flatten().is_some();
let ctx = crate::context::detect(&std::env::current_dir().unwrap_or_default());
if background { if background {
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env)?; let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env)?;
if app.dry_run() { if app.dry_run() {
@@ -51,7 +54,30 @@ fn start_one(
agent.name agent.name
)); ));
} }
app.emit(
&Event::now(EventKind::Start)
.with_agent(agent.name.clone())
.with_pid(pid)
.with_session(crate::repl::session_id())
.with_cwd(cwd_string())
.with_project(ctx.root.clone())
.with_args(exec.args.clone())
.with_env_keys(events::env_keys(&exec.env)),
);
if is_managed {
let _ = app.state.touch(&agent.name);
}
let log = process::agent_log_path(app, &agent.name); let log = process::agent_log_path(app, &agent.name);
let _ = sessions::start_agent(
app,
&agent.name,
pid,
cwd_string(),
ctx.root.clone(),
exec.args.clone(),
events::env_keys(&exec.env),
Some(log.display().to_string()),
);
app.log.success(&format!( app.log.success(&format!(
"{} started (pid {pid}); log: {}", "{} started (pid {pid}); log: {}",
agent.title(), agent.title(),
@@ -70,6 +96,7 @@ fn start_one(
)); ));
return Ok(0); return Ok(0);
} }
let started = std::time::Instant::now();
let (prog, prefix) = crate::runner::resolve_program(&exec.program); let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix; let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned()); full_args.extend(exec.args.iter().cloned());
@@ -78,7 +105,19 @@ fn start_one(
.envs(&exec.env) .envs(&exec.env)
.status() .status()
.with_context(|| format!("failed to run {}", exec.program))?; .with_context(|| format!("failed to run {}", exec.program))?;
Ok(status.code().unwrap_or(1)) let code = status.code().unwrap_or(1);
app.emit(
&Event::now(EventKind::Run)
.with_agent(agent.name.clone())
.with_cwd(cwd_string())
.with_project(ctx.root.clone())
.with_args(exec.args.clone())
.with_env_keys(events::env_keys(&exec.env))
.with_exit_code(code)
.with_duration(started.elapsed().as_secs()),
);
let _ = app.state.touch(&agent.name);
Ok(code)
} }
} }
@@ -123,11 +162,29 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
"stale PID {pid} for '{}' (process no longer running); cleaning up", "stale PID {pid} for '{}' (process no longer running); cleaning up",
agent.name agent.name
)); ));
app.emit(
&Event::now(EventKind::Stop)
.with_agent(agent.name.clone())
.with_pid(pid)
.with_reason("stale"),
);
let _ = sessions::finish_agent(app, &agent.name, Some(pid), 0, true);
app.state.update_pid(&agent.name, None, None)?; app.state.update_pid(&agent.name, None, None)?;
return Ok(0); return Ok(0);
} }
app.log.info(&format!("stopping '{}' (pid {pid})", agent.name)); app.log.info(&format!("stopping '{}' (pid {pid})", agent.name));
let stopped = process::stop_pid(pid, force, timeout); let stopped = process::stop_pid(pid, force, timeout);
let duration = seconds_since(entry.started_at.as_deref());
let mut ev = Event::now(EventKind::Stop)
.with_agent(agent.name.clone())
.with_pid(pid)
.with_reason(if force { "force" } else { "signal" })
.with_exit_code(if stopped { 0 } else { 1 });
if let Some(d) = duration {
ev = ev.with_duration(d);
}
app.emit(&ev);
let _ = sessions::finish_agent(app, &agent.name, Some(pid), if stopped { 0 } else { 1 }, stopped);
app.state.update_pid(&agent.name, None, None)?; app.state.update_pid(&agent.name, None, None)?;
if stopped { if stopped {
app.log.success(&format!("{} stopped", agent.title())); app.log.success(&format!("{} stopped", agent.title()));
@@ -175,6 +232,7 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
)); ));
return Ok(0); return Ok(0);
} }
let started = std::time::Instant::now();
let (prog, prefix) = crate::runner::resolve_program(&exec.program); let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix; let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned()); full_args.extend(exec.args.iter().cloned());
@@ -183,5 +241,31 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
.envs(&exec.env) .envs(&exec.env)
.status() .status()
.with_context(|| format!("failed to run {}", exec.program))?; .with_context(|| format!("failed to run {}", exec.program))?;
Ok(status.code().unwrap_or(1)) let code = status.code().unwrap_or(1);
app.emit(
&Event::now(EventKind::Run)
.with_agent(agent.name.clone())
.with_cwd(cwd_string())
.with_args(exec.args.clone())
.with_env_keys(events::env_keys(&exec.env))
.with_exit_code(code)
.with_duration(started.elapsed().as_secs()),
);
let _ = app.state.touch(&agent.name);
Ok(code)
}
/// Current directory as a string (None when unavailable).
fn cwd_string() -> Option<String> {
std::env::current_dir().ok().map(|p| p.display().to_string())
}
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
fn seconds_since(ts: Option<&str>) -> Option<u64> {
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
let now = chrono::Utc::now();
let secs = now
.signed_duration_since(t.with_timezone(&chrono::Utc))
.num_seconds();
Some(secs.max(0) as u64)
} }
+146
View File
@@ -0,0 +1,146 @@
//! sessions: the registry of every agent and REPL session.
use super::*;
use crate::output::{print_json, status_style, Table};
use crate::sessions::{self, SessionRecord};
use serde::Serialize;
#[derive(Serialize)]
struct SessionRow {
id: String,
kind: String,
agent: Option<String>,
status: String,
pid: Option<u32>,
cwd: Option<String>,
project: Option<String>,
started_at: String,
ended_at: Option<String>,
duration_s: Option<u64>,
exit_code: Option<i32>,
log: Option<String>,
}
fn row_of(r: &SessionRecord) -> SessionRow {
SessionRow {
id: r.id.clone(),
kind: r.kind.clone(),
agent: r.agent.clone(),
status: r.status.clone(),
pid: r.pid,
cwd: r.cwd.clone(),
project: r.project.clone(),
started_at: r.started_at.clone(),
ended_at: r.ended_at.clone(),
duration_s: sessions::duration_s(r),
exit_code: r.exit_code,
log: r.log.clone(),
}
}
pub fn run(
app: &App,
agent: Option<&str>,
project: Option<&str>,
status: Option<&str>,
show: Option<&str>,
) -> Result<i32> {
if let Some(id) = show {
return show_one(app, id);
}
sessions::reconcile(app)?;
let sf = sessions::load(app)?;
let mut rows: Vec<SessionRow> = sf
.sessions
.values()
.filter(|r| agent.map_or(true, |a| r.agent.as_deref() == Some(a)))
.filter(|r| project.map_or(true, |p| r.project.as_deref() == Some(p)))
.filter(|r| status.map_or(true, |s| r.status == s))
.map(row_of)
.collect();
rows.sort_by(|a, b| b.started_at.cmp(&a.started_at));
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let theme = app.theme();
let mut table = Table::new(vec!["SESSION", "AGENT", "STATUS", "DURATION", "EXIT"]);
for r in &rows {
table.row(vec![
short_id(&r.id),
r.agent
.clone()
.unwrap_or_else(|| format!("({})", r.kind)),
status_style(&r.status, theme, color),
r.duration_s
.map(format_duration)
.unwrap_or_else(|| "—".to_string()),
r.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
if rows.is_empty() {
app.log
.info("no sessions recorded yet — 'am start <agent> --background' or run 'am'");
}
Ok(0)
}
fn show_one(app: &App, id: &str) -> Result<i32> {
sessions::reconcile(app)?;
let sf = sessions::load(app)?;
let Some(r) = sf.sessions.get(id).cloned() else {
bail!("unknown session '{id}' — 'am sessions' lists every session");
};
if app.json() {
print_json(&row_of(&r));
return Ok(0);
}
println!("session {}", r.id);
println!(" kind: {}", r.kind);
println!(" agent: {}", r.agent.as_deref().unwrap_or("—"));
println!(" status: {}", r.status);
println!(" pid: {}", r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()));
println!(" cwd: {}", r.cwd.as_deref().unwrap_or("—"));
println!(" project: {}", r.project.as_deref().unwrap_or("—"));
println!(" started: {}", r.started_at);
println!(" ended: {}", r.ended_at.as_deref().unwrap_or("—"));
if let Some(d) = sessions::duration_s(&r) {
println!(" duration: {}", format_duration(d));
}
if let Some(c) = r.exit_code {
println!(" exit code: {c}");
}
if let Some(l) = &r.log {
let p = std::path::PathBuf::from(l);
if p.exists() {
println!(" log: {l}");
let tail = crate::process::last_lines(&p, 8);
if !tail.is_empty() {
println!(" --- log excerpt ---");
println!("{tail}");
}
} else {
println!(" log: {l} (missing)");
}
}
Ok(0)
}
/// "20260815_143926_a1b2c3" -> "20260815_143926"
fn short_id(id: &str) -> String {
id.chars().take(15).collect()
}
fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
+212
View File
@@ -0,0 +1,212 @@
//! stats: usage statistics computed from the event journal.
use super::*;
use crate::events::{self, EventKind};
use crate::output::{print_json, Table};
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Default, Serialize)]
pub struct AgentStats {
pub agent: String,
pub launches: u64,
pub total_duration_s: u64,
pub avg_duration_s: u64,
pub failures: u64,
pub installs: u64,
pub updates: u64,
pub last_used: Option<String>,
}
/// Cutoff instant for a period: 7d / 30d / 90d, or None for everything.
pub fn cutoff(period: Option<&str>) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
let days = match period {
None | Some("all") => return Ok(None),
Some("7d") => 7,
Some("30d") => 30,
Some("90d") => 90,
Some(other) => bail!("invalid period '{other}' — expected 7d, 30d, 90d or all"),
};
Ok(Some(chrono::Utc::now() - chrono::Duration::days(days)))
}
/// Aggregate journal events into per-agent statistics.
pub fn aggregate(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<Vec<AgentStats>> {
let cut = cutoff(period)?;
let events = events::read_events(&app.events_dir(), 0);
let mut map: BTreeMap<String, AgentStats> = BTreeMap::new();
let mut dur_count: BTreeMap<String, u64> = BTreeMap::new();
for ev in events {
if let Some(c) = cut {
let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&ev.ts) else {
continue;
};
if ts.with_timezone(&chrono::Utc) < c {
continue;
}
}
let Some(name) = ev.agent else {
continue;
};
if let Some(a) = agent {
if name != a {
continue;
}
}
let s = map.entry(name.clone()).or_default();
s.agent = name.clone();
match ev.kind {
EventKind::Start | EventKind::Run => s.launches += 1,
EventKind::Install => s.installs += 1,
EventKind::Update => s.updates += 1,
_ => {}
}
if let Some(d) = ev.duration_s {
s.total_duration_s += d;
*dur_count.entry(name.clone()).or_default() += 1;
}
if let Some(code) = ev.exit_code {
if code != 0 {
s.failures += 1;
}
}
s.last_used = Some(ev.ts);
}
for (name, s) in map.iter_mut() {
let n = dur_count.get(name).copied().unwrap_or(0);
s.avg_duration_s = if n > 0 { s.total_duration_s / n } else { 0 };
}
Ok(map.into_values().collect())
}
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<i32> {
let rows = aggregate(app, agent, period)?;
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let theme = app.theme();
if rows.is_empty() {
app.log.info("no usage recorded yet — run or start an agent first");
return Ok(0);
}
let max_launches = rows.iter().map(|r| r.launches).max().unwrap_or(1);
let mut table = Table::new(vec!["AGENT", "LAUNCHES", "DURATION", "AVG", "FAIL", "LAST"]);
for r in &rows {
table.row(vec![
r.agent.clone(),
format!("{} {}", r.launches, bar(r.launches, max_launches, 8)),
format_duration(r.total_duration_s),
format_duration(r.avg_duration_s),
if r.failures > 0 {
crate::output::status_style("error", theme, color)
} else {
r.failures.to_string()
},
r.last_used
.as_deref()
.map(|t| t[..t.len().min(10)].to_string())
.unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
Ok(0)
}
/// ASCII bar: relative weight of v against max.
fn bar(v: u64, max: u64, width: usize) -> String {
if max == 0 {
return String::new();
}
let n = ((v * width as u64) / max).max(if v > 0 { 1 } else { 0 }) as usize;
format!("{}{}", "█".repeat(n), "░".repeat(width.saturating_sub(n)))
}
fn format_duration(secs: u64) -> String {
if secs == 0 {
return "—".to_string();
}
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn app_with_events(events_jsonl: &str) -> crate::app::App {
// The TempDir guard would delete the directory when this helper
// returns; leak it on purpose so the App keeps a live events dir.
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
"agents: []\n",
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = crate::app::App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.install_dir = dir.join("agents");
p.bin_dir = dir.join("agents").join("bin");
p.log_dir = dir.join("logs");
p.state_file = dir.join("state.json");
p.probe_cache_file = dir.join("probe-cache.json");
p.config_dir = Some(dir.join("config"));
app.paths = p;
std::fs::write(dir.join("events-test.jsonl"), events_jsonl).unwrap();
// Rename into the right month file so read_events picks it up.
let month = chrono::Utc::now().format("%Y%m");
std::fs::rename(
dir.join("events-test.jsonl"),
dir.join(format!("events-{month}.jsonl")),
)
.unwrap();
app
}
#[test]
fn aggregates_counts_durations_and_failures() {
let lines = [
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}"#,
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":30}"#,
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":1,"duration_s":5}"#,
r#"{"ts":"2026-08-03T11:00:00Z","kind":"install","agent":"a"}"#,
];
let app = app_with_events(&lines.join("\n"));
let rows = aggregate(&app, None, None).unwrap();
let a = rows.iter().find(|r| r.agent == "a").unwrap();
assert_eq!(a.launches, 2);
assert_eq!(a.total_duration_s, 40);
assert_eq!(a.avg_duration_s, 20);
assert_eq!(a.failures, 0);
assert_eq!(a.installs, 1);
let b = rows.iter().find(|r| r.agent == "b").unwrap();
assert_eq!(b.failures, 1);
assert_eq!(b.launches, 1);
}
#[test]
fn period_cuts_old_events() {
let app = app_with_events(
r#"{"ts":"2020-01-01T10:00:00Z","kind":"run","agent":"old","exit_code":0}"#,
);
assert!(aggregate(&app, None, Some("30d")).unwrap().is_empty());
assert_eq!(aggregate(&app, None, None).unwrap().len(), 1);
assert!(cutoff(Some("bad")).is_err());
}
}
+7
View File
@@ -5,6 +5,7 @@
//! executables found on PATH otherwise. //! executables found on PATH otherwise.
use super::*; use super::*;
use crate::events::{Event, EventKind};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command as ProcessCommand; use std::process::Command as ProcessCommand;
@@ -57,6 +58,7 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
remove_from_user_config(app, &agent.name)?; remove_from_user_config(app, &agent.name)?;
} }
app.emit(&Event::now(EventKind::Uninstall).with_agent(agent.name.clone()));
app.log.success(&format!( app.log.success(&format!(
"uninstalled {} (files removed{})", "uninstalled {} (files removed{})",
agent.title(), agent.title(),
@@ -178,6 +180,11 @@ fn uninstall_external(app: &App, agent: &AgentDef) -> Result<i32> {
.and_then(|t| which::which(t).ok()) .and_then(|t| which::which(t).ok())
.is_none(); .is_none();
if gone { if gone {
app.emit(
&Event::now(EventKind::Uninstall)
.with_agent(agent.name.clone())
.with_reason("external"),
);
app.log app.log
.success(&format!("uninstalled {} (external)", agent.title())); .success(&format!("uninstalled {} (external)", agent.title()));
} else { } else {
+6
View File
@@ -2,6 +2,7 @@
//! reinstall when an update exists. //! reinstall when an update exists.
use super::*; use super::*;
use crate::events::{Event, EventKind};
use crate::installers; use crate::installers;
use crate::runner::{Runner, SystemRunner}; use crate::runner::{Runner, SystemRunner};
use anyhow::{bail, Result}; use anyhow::{bail, Result};
@@ -96,6 +97,11 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
new_entry.updated_at = Some(installers::now_rfc3339()); new_entry.updated_at = Some(installers::now_rfc3339());
let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string()); let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string());
app.state.set(&new_entry)?; app.state.set(&new_entry)?;
app.emit(
&Event::now(EventKind::Update)
.with_agent(agent.name.clone())
.with_args(vec![format!("method={}", method.kind)]),
);
app.log.success(&format!( app.log.success(&format!(
"updated {} to v{version}", "updated {} to v{version}",
agent.title() agent.title()
+153
View File
@@ -0,0 +1,153 @@
//! Project context detection: git root + branch and the technology
//! stack of a directory (marker files). Cached per process so repeated
//! calls (event emission, sessions) never re-run subprocesses.
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct ProjectContext {
/// Root of the enclosing git repository (or None).
pub root: Option<String>,
/// Current branch (or None).
pub branch: Option<String>,
/// Detected technology stack, e.g. ["rust", "node"].
pub stack: Vec<String>,
}
/// Marker file -> stack label. Checked walking up from the directory to the
/// filesystem root; the first directory containing a marker wins.
const MARKERS: &[(&str, &str)] = &[
("Cargo.toml", "rust"),
("package.json", "node"),
("pyproject.toml", "python"),
("requirements.txt", "python"),
("setup.py", "python"),
("go.mod", "go"),
("bun.lockb", "bun"),
("bun.lock", "bun"),
("Gemfile", "ruby"),
];
static CACHE: OnceLock<Mutex<HashMap<PathBuf, ProjectContext>>> = OnceLock::new();
fn cache() -> &'static Mutex<HashMap<PathBuf, ProjectContext>> {
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Detect the project context of a directory (cached per process).
pub fn detect(cwd: &Path) -> ProjectContext {
if let Some(hit) = cache().lock().unwrap().get(cwd).cloned() {
return hit;
}
let ctx = detect_uncached(cwd);
cache().lock().unwrap().insert(cwd.to_path_buf(), ctx.clone());
ctx
}
fn detect_uncached(cwd: &Path) -> ProjectContext {
let (root, branch) = git_info(cwd);
let stack = detect_stack(cwd);
ProjectContext {
root,
branch,
stack,
}
}
/// Git root and branch via git subprocesses (best effort, silent).
fn git_info(cwd: &Path) -> (Option<String>, Option<String>) {
let root = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let branch = std::process::Command::new("git")
.args(["branch", "--show-current"])
.current_dir(cwd)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|b| !b.is_empty());
(root, branch)
}
/// Walk up from cwd collecting stack labels from marker files.
pub fn detect_stack(cwd: &Path) -> Vec<String> {
let mut dir = Some(cwd);
let mut out: Vec<String> = Vec::new();
while let Some(d) = dir {
for (marker, label) in MARKERS {
if d.join(marker).is_file() && !out.iter().any(|s| s == label) {
out.push(label.to_string());
}
}
dir = d.parent();
}
out.sort();
out
}
/// Agents recommended for a detected stack (catalog slugs).
pub fn recommended_agents(stack: &[String]) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
if stack.contains(&"rust".to_string()) {
out.push("smelt");
}
if stack.contains(&"python".to_string()) {
out.push("aider");
}
if stack.contains(&"node".to_string()) {
out.push("claude-code");
}
if out.is_empty() {
out.push("claude-code");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_stack_from_markers() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
let stack = detect_stack(dir.path());
assert_eq!(stack, vec!["node".to_string(), "rust".to_string()]);
assert_eq!(recommended_agents(&stack), vec!["smelt", "claude-code"]);
}
#[test]
fn walks_up_to_parent_directories() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("go.mod"), "").unwrap();
let sub = dir.path().join("src/deep");
std::fs::create_dir_all(&sub).unwrap();
let stack = detect_stack(&sub);
assert_eq!(stack, vec!["go".to_string()]);
}
#[test]
fn empty_dir_recommends_default() {
let dir = tempfile::tempdir().unwrap();
let stack = detect_stack(dir.path());
assert!(stack.is_empty());
assert_eq!(recommended_agents(&stack), vec!["claude-code"]);
}
#[test]
fn cache_returns_same_context() {
let dir = tempfile::tempdir().unwrap();
let a = detect(dir.path());
let b = detect(dir.path());
assert_eq!(a, b);
}
}
+64 -36
View File
@@ -32,6 +32,24 @@ pub enum EventKind {
Shell, Shell,
} }
impl EventKind {
pub fn as_str(&self) -> &'static str {
match self {
EventKind::Start => "start",
EventKind::Stop => "stop",
EventKind::Run => "run",
EventKind::Restart => "restart",
EventKind::Install => "install",
EventKind::Update => "update",
EventKind::Uninstall => "uninstall",
EventKind::Doctor => "doctor",
EventKind::Config => "config",
EventKind::Repl => "repl",
EventKind::Shell => "shell",
}
}
}
/// One journal entry. Optional fields are omitted from the JSON line. /// One journal entry. Optional fields are omitted from the JSON line.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Event { pub struct Event {
@@ -90,13 +108,13 @@ impl Event {
self self
} }
pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self { pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
self.cwd = Some(cwd.into()); self.cwd = cwd;
self self
} }
pub fn with_project(mut self, project: impl Into<String>) -> Self { pub fn with_project(mut self, project: Option<String>) -> Self {
self.project = Some(project.into()); self.project = project;
self self
} }
@@ -209,6 +227,46 @@ pub fn read_events(dir: &Path, limit: usize) -> Vec<Event> {
out out
} }
/// Append one event as a single JSON line to the journal file for the
/// event's month (created under `dir`). Returns the file written to.
/// Retries briefly when the file is locked (Windows).
pub fn append(dir: &Path, event: &Event) -> anyhow::Result<PathBuf> {
let path = dir.join(file_name_for_month(&month_of(&event.ts)));
append_to(&path, event)?;
Ok(path)
}
/// Append one event to a specific journal file.
pub fn append_to(path: &Path, event: &Event) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let line = serde_json::to_string(event)?;
let mut last_err: Option<std::io::Error> = None;
for attempt in 0..5 {
match std::fs::OpenOptions::new().create(true).append(true).open(path) {
Ok(mut f) => {
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
return Ok(());
}
Err(e) => {
last_err = Some(e);
if attempt < 4 {
std::thread::sleep(std::time::Duration::from_millis(20 * (attempt + 1)));
}
}
}
}
Err(anyhow::anyhow!(
"cannot append to journal {}: {}",
path.display(),
last_err
.map(|e| e.to_string())
.unwrap_or_else(|| "unknown error".to_string())
))
}
/// Append-only journal living in a directory (the state directory). /// Append-only journal living in a directory (the state directory).
pub struct EventLog { pub struct EventLog {
dir: PathBuf, dir: PathBuf,
@@ -247,41 +305,11 @@ impl EventLog {
} }
/// Append one event as a single JSON line; returns the file written to. /// Append one event as a single JSON line; returns the file written to.
/// Retries briefly when the file is locked (Windows).
pub fn append(&mut self, event: &Event) -> anyhow::Result<PathBuf> { pub fn append(&mut self, event: &Event) -> anyhow::Result<PathBuf> {
let month = month_of(&event.ts); let month = month_of(&event.ts);
let path = self.file_for(&month); let path = self.file_for(&month);
if let Some(parent) = path.parent() { append_to(&path, event)?;
let _ = std::fs::create_dir_all(parent); Ok(path)
}
let line = serde_json::to_string(event)?;
let mut last_err: Option<std::io::Error> = None;
for attempt in 0..5 {
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
Ok(mut f) => {
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
return Ok(path);
}
Err(e) => {
last_err = Some(e);
if attempt < 4 {
std::thread::sleep(std::time::Duration::from_millis(20 * (attempt + 1)));
}
}
}
}
Err(anyhow::anyhow!(
"cannot append to journal {}: {}",
path.display(),
last_err
.map(|e| e.to_string())
.unwrap_or_else(|| "unknown error".to_string())
))
} }
} }
+162 -1
View File
@@ -177,6 +177,143 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Show one agent.", code: "status claude-code" }, HelpExample { desc: "Show one agent.", code: "status claude-code" },
], ],
}, },
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"],
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)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Only sessions of this agent" },
],
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" },
],
},
HelpSpec {
name: "stats",
category: "Commands",
usage: "stats {flags} [agent]",
about: "Show usage statistics computed from the event journal.",
search_terms: &["usage", "metrics", "duration"],
flags: &[
HelpFlag { short: "", long: "--period", value: "PERIOD", desc: "Only events of the last period (7d, 30d, 90d, all)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias (omit for the global view)" },
],
io: None,
examples: &[
HelpExample { desc: "Global usage statistics.", code: "stats" },
HelpExample { desc: "One agent over the last month.", code: "stats claude-code --period 30d" },
],
},
HelpSpec {
name: "log",
category: "Commands",
usage: "log {flags} [agent]",
about: "Read the event journal: everything am did.",
search_terms: &["journal", "events", "history"],
flags: &[
HelpFlag { short: "", long: "--kind", value: "KINDS", desc: "Only these event kinds (comma separated)" },
HelpFlag { short: "", long: "--since", value: "DATE", desc: "Only events at or after this date (YYYY-MM-DD or RFC 3339)" },
HelpFlag { short: "", long: "--limit", value: "N", desc: "Maximum number of events (default: 50)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Only events of this agent" },
],
io: None,
examples: &[
HelpExample { desc: "The last 50 events.", code: "log" },
HelpExample { desc: "Starts of one agent since a date.", code: "log claude-code --kind start --since 2026-08-01" },
],
},
HelpSpec {
name: "logs",
category: "Commands",
usage: "logs {flags} <agent>",
about: "Tail the log file of an agent.",
search_terms: &["tail", "follow", "output"],
flags: &[
HelpFlag { short: "", long: "--lines", value: "N", desc: "Number of lines to show (default: 20)" },
HelpFlag { short: "", long: "--follow", value: "", desc: "Follow the log as it grows" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
],
io: None,
examples: &[
HelpExample { desc: "The last 20 lines.", code: "logs claude-code" },
HelpExample { desc: "A live view.", code: "logs claude-code --follow" },
],
},
HelpSpec {
name: "open",
category: "Commands",
usage: "open {flags} <agent>",
about: "Open an agent's installation directory in the file manager.",
search_terms: &["explorer", "folder", "directory"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
],
io: None,
examples: &[
HelpExample { desc: "Open the install directory.", code: "open claude-code" },
],
},
HelpSpec {
name: "alias",
category: "Commands",
usage: "alias {flags} <add|remove|list> [name] [target]",
about: "Manage command aliases (cc -> claude-code) without editing the YAML.",
search_terms: &["shortcut", "rename"],
flags: &[],
subcommands: &[
("add", "Add an alias pointing to an agent"),
("remove", "Remove an alias"),
],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Shortcut for claude-code.", code: "alias add cc claude-code" },
HelpExample { desc: "List every alias.", code: "alias list" },
],
},
HelpSpec {
name: "history",
category: "Commands",
usage: "history {flags}",
about: "Search the command history (am and shell commands).",
search_terms: &["past", "commands", "rerun"],
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" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "The last 100 commands.", code: "history" },
HelpExample { desc: "Find past installs.", code: "history --search install" },
HelpExample { desc: "Failed commands only.", code: "history --failed" },
],
},
HelpSpec { HelpSpec {
name: "search", name: "search",
category: "Commands", category: "Commands",
@@ -213,6 +350,23 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Use an alias.", code: "info cc" }, HelpExample { desc: "Use an alias.", code: "info cc" },
], ],
}, },
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"],
flags: &[
HelpFlag { short: "", long: "--force", value: "", desc: "Overwrite an existing agent-manager.yaml" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Generate a project config.", code: "init" },
HelpExample { desc: "Regenerate over an existing one.", code: "init --force" },
],
},
HelpSpec { HelpSpec {
name: "install", name: "install",
category: "Commands", category: "Commands",
@@ -1380,7 +1534,14 @@ mod tests {
assert!(text.contains("allocator")); assert!(text.contains("allocator"));
assert_eq!(info.version, env!("CARGO_PKG_VERSION")); assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
assert_eq!(info.major, 0); assert_eq!(info.major, 0);
assert_eq!(info.minor, 2); // The minor version follows Cargo.toml: parse it instead of pinning it.
let minor: u64 = env!("CARGO_PKG_VERSION")
.split('.')
.nth(1)
.unwrap()
.parse()
.unwrap();
assert_eq!(info.minor, minor);
} }
#[test] #[test]
+280
View File
@@ -0,0 +1,280 @@
//! Structured command history: one JSONL file per REPL session under
//! history/<session>.jsonl. Replaces the flat history.txt (imported once).
use crate::app::App;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HistoryEntry {
/// RFC 3339 timestamp.
pub ts: String,
pub cmd: String,
/// "am" (agent-manager command) or "shell" (system gateway command).
#[serde(default = "kind_am")]
pub kind: String,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub exit_code: Option<i32>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub agent: Option<String>,
}
fn kind_am() -> String {
"am".to_string()
}
impl HistoryEntry {
pub fn now(cmd: impl Into<String>) -> Self {
HistoryEntry {
ts: crate::installers::now_rfc3339(),
cmd: cmd.into(),
kind: "am".to_string(),
cwd: None,
exit_code: None,
duration_ms: None,
agent: None,
}
}
pub fn with_kind(mut self, kind: impl Into<String>) -> Self {
self.kind = kind.into();
self
}
pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
self.cwd = cwd;
self
}
pub fn with_exit_code(mut self, code: Option<i32>) -> Self {
self.exit_code = code;
self
}
pub fn with_duration_ms(mut self, ms: u64) -> Self {
self.duration_ms = Some(ms);
self
}
pub fn with_agent(mut self, agent: Option<String>) -> Self {
self.agent = agent;
self
}
}
/// Directory holding the per-session history files.
pub fn dir(app: &App) -> PathBuf {
app.events_dir().join("history")
}
/// Append one entry to the session file of a REPL session.
pub fn record(app: &App, session: &str, entry: &HistoryEntry) -> Result<()> {
let d = dir(app);
std::fs::create_dir_all(&d).ok();
let path = d.join(format!("{session}.jsonl"));
let line = serde_json::to_string(entry)?;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("cannot open history file {}", path.display()))?;
use std::io::Write;
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
Ok(())
}
/// One-shot migration: import the flat history.txt into the structured
/// directory. Returns the number of imported entries.
pub fn migrate(app: &App) -> Result<usize> {
let txt = app.paths.state_file.with_file_name("history.txt");
if !txt.exists() {
return Ok(0);
}
let d = dir(app);
let already = d.join("migrated.jsonl").exists();
if already {
return Ok(0);
}
let text = std::fs::read_to_string(&txt)
.with_context(|| format!("cannot read {}", txt.display()))?;
let mut count = 0;
let mut previous: Option<String> = None;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Déduplication des répétitions consécutives (quick win #13).
if previous.as_deref() == Some(trimmed) {
continue;
}
previous = Some(trimmed.to_string());
let entry = HistoryEntry::now(trimmed.to_string())
.with_kind("am".to_string())
.with_cwd(None);
record(app, "migrated", &entry)?;
count += 1;
}
Ok(count)
}
/// History files of a directory, oldest first.
pub fn files(dir: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("jsonl"))
.collect()
})
.unwrap_or_default();
out.sort();
out
}
/// Read all entries across sessions, most recent first. Corrupt lines are
/// skipped silently.
pub fn read_all(app: &App, limit: usize) -> Vec<HistoryEntry> {
let d = dir(app);
let mut out: Vec<HistoryEntry> = Vec::new();
for f in files(&d).iter().rev() {
if let Ok(text) = std::fs::read_to_string(f) {
let mut entries: Vec<HistoryEntry> = text
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<HistoryEntry>(l).ok())
.collect();
entries.reverse(); // newest first within one file
out.extend(entries);
if limit > 0 && out.len() >= limit {
break;
}
}
}
if limit > 0 {
out.truncate(limit);
}
out
}
/// Parse the session id from a history file name ("20260815_143926.jsonl").
pub fn session_of_file(path: &Path) -> Option<String> {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n.trim_end_matches(".jsonl").to_string())
}
/// Corrupt-history guard: a session file that cannot be parsed is reported,
/// never fatal.
pub fn validate_file(path: &Path) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read history file {}", path.display()))?;
for (i, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
serde_json::from_str::<HistoryEntry>(line).map_err(|e| {
anyhow!("history file {} line {} is corrupt: {e}", path.display(), i + 1)
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn test_app(tag: &str) -> App {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
"agents: []\n",
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.install_dir = dir.join("agents");
p.bin_dir = dir.join("agents").join("bin");
p.log_dir = dir.join("logs");
p.state_file = dir.join("state.json");
p.probe_cache_file = dir.join("probe-cache.json");
p.config_dir = Some(dir.join("config"));
app.paths = p;
let _ = tag;
app
}
#[test]
fn record_and_read_back() {
let app = test_app("hist");
record(&app, "s1", &HistoryEntry::now("install jcode")).unwrap();
record(
&app,
"s1",
&HistoryEntry::now("ls").with_kind("shell".to_string()).with_duration_ms(12),
)
.unwrap();
let all = read_all(&app, 0);
assert_eq!(all.len(), 2);
assert_eq!(all[0].cmd, "ls", "most recent first");
assert_eq!(all[0].kind, "shell");
assert_eq!(all[0].duration_ms, Some(12));
assert_eq!(all[1].cmd, "install jcode");
}
#[test]
fn migration_imports_flat_file_once() {
let app = test_app("mig");
let txt = app.paths.state_file.with_file_name("history.txt");
std::fs::write(&txt, "install jcode
start group:dev
").unwrap();
assert_eq!(migrate(&app).unwrap(), 2);
assert_eq!(migrate(&app).unwrap(), 0, "second run is a no-op");
let all = read_all(&app, 0);
assert_eq!(all.len(), 2);
assert_eq!(all[0].cmd, "start group:dev");
}
#[test]
fn corrupt_lines_are_skipped() {
let app = test_app("corrupt");
record(&app, "s1", &HistoryEntry::now("list")).unwrap();
// Append garbage to the session file.
let d = dir(&app);
let f = d.join("s1.jsonl");
let mut text = std::fs::read_to_string(&f).unwrap();
text.push_str("not json at all
");
std::fs::write(&f, text).unwrap();
let all = read_all(&app, 0);
assert_eq!(all.len(), 1);
assert!(validate_file(&f).is_err());
}
#[test]
fn session_of_file_extracts_id() {
assert_eq!(
session_of_file(Path::new("20260815_143926.jsonl")),
Some("20260815_143926".to_string())
);
}
}
+3
View File
@@ -16,11 +16,13 @@ pub mod catalog;
pub mod cli; pub mod cli;
pub mod commands; pub mod commands;
pub mod config; pub mod config;
pub mod context;
pub mod deps; pub mod deps;
pub mod doctor; pub mod doctor;
pub mod download; pub mod download;
pub mod events; pub mod events;
pub mod help; pub mod help;
pub mod history;
pub mod installers; pub mod installers;
pub mod nav; pub mod nav;
pub mod output; pub mod output;
@@ -29,6 +31,7 @@ pub mod ps;
pub mod process; pub mod process;
pub mod repl; pub mod repl;
pub mod runner; pub mod runner;
pub mod sessions;
pub mod shell; pub mod shell;
pub mod state; pub mod state;
pub mod tables; pub mod tables;
+118 -6
View File
@@ -15,6 +15,7 @@
use crate::app::App; use crate::app::App;
use crate::cli::{Command, StartArgs}; use crate::cli::{Command, StartArgs};
use crate::commands; use crate::commands;
use crate::events::{Event, EventKind};
use crate::nav; use crate::nav;
use crate::ps; use crate::ps;
use crate::tables::DataTable; use crate::tables::DataTable;
@@ -59,6 +60,14 @@ pub struct AmCompleter {
const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("list", "list installed agents"), ("list", "list installed agents"),
("status", "show agent state"), ("status", "show agent state"),
("sessions", "list agent sessions"),
("stats", "usage statistics"),
("log", "read the event journal"),
("logs", "tail an agent log"),
("history", "search command history"),
("init", "generate a project config"),
("alias", "manage aliases"),
("open", "open the install dir"),
("search", "search the catalog"), ("search", "search the catalog"),
("info", "agent details"), ("info", "agent details"),
("install", "install an agent"), ("install", "install an agent"),
@@ -135,7 +144,7 @@ impl AmCompleter {
Self { Self {
theme: std::cell::Cell::new(crate::theme::default_theme()), theme: std::cell::Cell::new(crate::theme::default_theme()),
commands: vec![ commands: vec![
"list", "status", "search", "info", "install", "uninstall", "update", "list", "status", "sessions", "stats", "log", "logs", "history", "init", "alias", "open", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion", "start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "self-update", "self-uninstall", "export", "import", "shell", "theme",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
@@ -884,19 +893,30 @@ pub fn run(app: &App) -> Result<i32> {
} }
let mut session = ShellSession::start(&app.config); let mut session = ShellSession::start(&app.config);
let sid = session_id(); let sid = session_id();
app.emit(
&Event::now(EventKind::Repl)
.with_session(sid.clone())
.with_cwd(Some(current_dir_string())),
);
let _ = crate::sessions::start_repl(app, &sid, Some(current_dir_string()));
if let Err(e) = crate::history::migrate(app) {
app.log.verbose(&format!("history migration skipped: {e:#}"));
}
print!("{}", banner(app, &session, &sid)); print!("{}", banner(app, &session, &sid));
match run_with_editor(app, &mut session) { let result = match run_with_editor(app, &mut session, &sid) {
Ok(code) => Ok(code), Ok(code) => Ok(code),
Err(e) => { Err(e) => {
app.log.verbose(&format!( app.log.verbose(&format!(
"line editor unavailable ({e:#}); falling back to plain input" "line editor unavailable ({e:#}); falling back to plain input"
)); ));
run_plain(app, &mut session) run_plain(app, &mut session, &sid)
} }
} };
let _ = crate::sessions::finish_repl(app, &sid);
result
} }
fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> { fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> = let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
rustyline::Editor::new()?; rustyline::Editor::new()?;
rl.set_helper(Some(AmCompleter::new(app))); rl.set_helper(Some(AmCompleter::new(app)));
@@ -924,6 +944,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
let t = Instant::now(); let t = Instant::now();
let result = handle_input(app, session, &mut last, &trimmed); let result = handle_input(app, session, &mut last, &trimmed);
last_duration = Some(t.elapsed()); last_duration = Some(t.elapsed());
record_history(app, sid, &trimmed, &result, t.elapsed());
// 'theme <name>' may have switched the app theme: repoint the // 'theme <name>' may have switched the app theme: repoint the
// completion menu at the new palette. // completion menu at the new palette.
if let Some(h) = rl.helper_mut() { if let Some(h) = rl.helper_mut() {
@@ -947,8 +968,33 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
Ok(0) Ok(0)
} }
/// Append one structured history entry for a REPL command.
fn record_history(
app: &App,
sid: &str,
line: &str,
result: &Result<bool>,
elapsed: std::time::Duration,
) {
let kind = if is_am_command(line.split_whitespace().next().unwrap_or("")) {
"am"
} else {
"shell"
};
let exit_code = if result.is_err() { Some(1) } else { None };
let _ = crate::history::record(
app,
sid,
&crate::history::HistoryEntry::now(line.to_string())
.with_kind(kind.to_string())
.with_cwd(Some(current_dir_string()))
.with_exit_code(exit_code)
.with_duration_ms(elapsed.as_millis() as u64),
);
}
/// Plain fallback when no terminal/line editor is available. /// Plain fallback when no terminal/line editor is available.
fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> { fn run_plain(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
let started = Instant::now(); let started = Instant::now();
let mut last: Option<DataTable> = None; let mut last: Option<DataTable> = None;
let mut last_duration: Option<Duration> = None; let mut last_duration: Option<Duration> = None;
@@ -967,6 +1013,7 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
let t = Instant::now(); let t = Instant::now();
let result = handle_input(app, session, &mut last, &line); let result = handle_input(app, session, &mut last, &line);
last_duration = Some(t.elapsed()); last_duration = Some(t.elapsed());
record_history(app, sid, &line, &result, t.elapsed());
match result { match result {
Ok(true) => break, Ok(true) => break,
Ok(false) => {} Ok(false) => {}
@@ -1152,10 +1199,59 @@ fn handle_line(
"list" => Command::List { "list" => Command::List {
all: flag("--all"), all: flag("--all"),
category: None, category: None,
running: flag("--running"),
sort: opt_value("--sort"),
},
"logs" => Command::Logs {
agent: need("agent name")?,
lines: opt_value("--lines").and_then(|v| v.parse().ok()).unwrap_or(20),
follow: flag("--follow"),
},
"open" => Command::Open {
agent: need("agent name")?,
},
"alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
name: rest[1].clone(),
target: rest[2].clone(),
}),
Some("remove") if rest.len() >= 2 => Command::Alias(crate::cli::AliasCmd::Remove {
name: rest[1].clone(),
}),
Some("list") | None => Command::Alias(crate::cli::AliasCmd::List),
_ => {
app.log.error("usage: alias add <name> <target> | alias remove <name> | alias list");
return Ok(false);
}
}, },
"status" => Command::Status { "status" => Command::Status {
agent: rest.first().cloned(), agent: rest.first().cloned(),
}, },
"sessions" => Command::Sessions {
agent: rest.first().cloned(),
project: opt_value("--project"),
status: opt_value("--status"),
show: opt_value("--show"),
},
"stats" => Command::Stats {
agent: rest.first().cloned(),
period: opt_value("--period"),
},
"log" => Command::Log {
agent: rest.first().cloned(),
kind: opt_value("--kind"),
since: opt_value("--since"),
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(50),
},
"history" => Command::History {
kind: opt_value("--kind"),
cwd: opt_value("--cwd"),
search: opt_value("--search"),
failed: flag("--failed"),
session: opt_value("--session"),
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(100),
},
"init" => Command::Init { force: flag("--force") },
"search" => Command::Search { "search" => Command::Search {
keyword: rest.join(" "), keyword: rest.join(" "),
category: None, category: None,
@@ -1261,6 +1357,14 @@ fn is_am_command(word: &str) -> bool {
word, word,
"list" "list"
| "status" | "status"
| "sessions"
| "stats"
| "log"
| "logs"
| "history"
| "init"
| "alias"
| "open"
| "search" | "search"
| "info" | "info"
| "install" | "install"
@@ -1379,11 +1483,19 @@ fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<
/// Run one system command through the active shell (inherited stdio). /// Run one system command through the active shell (inherited stdio).
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> { fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
app.log.cmd(&format!("{}: {line}", session.current.name)); app.log.cmd(&format!("{}: {line}", session.current.name));
let started = std::time::Instant::now();
let code = session.run(line)?; let code = session.run(line)?;
if code != 0 { if code != 0 {
app.log app.log
.error(&format!("command exited with code {code}")); .error(&format!("command exited with code {code}"));
} }
app.emit(
&Event::now(EventKind::Shell)
.with_args(vec![line.to_string()])
.with_exit_code(code)
.with_duration(started.elapsed().as_secs())
.with_cwd(Some(current_dir_string())),
);
Ok(()) Ok(())
} }
+339
View File
@@ -0,0 +1,339 @@
//! Session registry: one record per agent session (background start/stop
//! cycles and interactive REPL sessions). sessions.json is an index derived
//! from the event journal; it can be rebuilt with 'am doctor --fix'.
use crate::app::App;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
pub const SESSIONS_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionRecord {
pub id: String,
/// "agent" or "repl".
#[serde(default = "kind_agent")]
pub kind: String,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub pid: Option<u32>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub project: Option<String>,
/// RFC 3339 start timestamp.
pub started_at: String,
/// RFC 3339 end timestamp (None while running).
#[serde(default)]
pub ended_at: Option<String>,
#[serde(default)]
pub exit_code: Option<i32>,
#[serde(default)]
pub log: Option<String>,
#[serde(default)]
pub args: Vec<String>,
/// Variable names only — never values.
#[serde(default)]
pub env_keys: Vec<String>,
/// running | stopped | failed | interrupted
#[serde(default = "status_running")]
pub status: String,
}
fn kind_agent() -> String {
"agent".to_string()
}
fn status_running() -> String {
"running".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionsFile {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub sessions: BTreeMap<String, SessionRecord>,
}
/// Path of sessions.json (inside the state directory).
pub fn file_path(app: &App) -> PathBuf {
app.events_dir().join("sessions.json")
}
pub fn load(app: &App) -> Result<SessionsFile> {
let p = file_path(app);
if !p.exists() {
return Ok(SessionsFile {
version: SESSIONS_VERSION,
sessions: BTreeMap::new(),
});
}
let text = std::fs::read_to_string(&p)
.with_context(|| format!("cannot read sessions file {}", p.display()))?;
let mut sf: SessionsFile = serde_json::from_str(&text).map_err(|e| {
anyhow!(
"sessions file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
p.display()
)
})?;
if sf.version < SESSIONS_VERSION {
sf.version = SESSIONS_VERSION;
save(app, &sf)?;
}
Ok(sf)
}
pub fn save(app: &App, sf: &SessionsFile) -> Result<()> {
let p = file_path(app);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = p.with_extension("json.tmp");
let text = serde_json::to_string_pretty(sf)?;
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, &p)
.with_context(|| format!("cannot write sessions file {}", p.display()))?;
Ok(())
}
/// Open a new agent session; returns its id.
#[allow(clippy::too_many_arguments)]
pub fn start_agent(
app: &App,
agent: &str,
pid: u32,
cwd: Option<String>,
project: Option<String>,
args: Vec<String>,
env_keys: Vec<String>,
log: Option<String>,
) -> Result<String> {
let mut sf = load(app)?;
let id = crate::repl::session_id();
sf.sessions.insert(
id.clone(),
SessionRecord {
id: id.clone(),
kind: "agent".to_string(),
agent: Some(agent.to_string()),
pid: Some(pid),
cwd,
project,
started_at: crate::installers::now_rfc3339(),
ended_at: None,
exit_code: None,
log,
args,
env_keys,
status: "running".to_string(),
},
);
save(app, &sf)?;
let _ = app.state.bump_sessions();
Ok(id)
}
/// Open a REPL session.
pub fn start_repl(app: &App, id: &str, cwd: Option<String>) -> Result<()> {
let mut sf = load(app)?;
sf.sessions.insert(
id.to_string(),
SessionRecord {
id: id.to_string(),
kind: "repl".to_string(),
agent: None,
pid: None,
cwd,
project: None,
started_at: crate::installers::now_rfc3339(),
ended_at: None,
exit_code: None,
log: None,
args: Vec::new(),
env_keys: Vec::new(),
status: "running".to_string(),
},
);
save(app, &sf)?;
let _ = app.state.bump_sessions();
Ok(())
}
/// Close the running session of an agent: the one matching the pid when
/// given, otherwise the most recent one.
pub fn finish_agent(app: &App, agent: &str, pid: Option<u32>, exit_code: i32, ok: bool) -> Result<()> {
let mut sf = load(app)?;
let mut candidates: Vec<String> = sf
.sessions
.iter()
.filter(|(_, r)| r.kind == "agent" && r.status == "running" && r.agent.as_deref() == Some(agent))
.map(|(k, _)| k.clone())
.collect();
candidates.sort_by(|a, b| sf.sessions[b].started_at.cmp(&sf.sessions[a].started_at));
let key = if let Some(p) = pid {
candidates
.iter()
.find(|k| sf.sessions[*k].pid == Some(p))
.cloned()
.or_else(|| candidates.first().cloned())
} else {
candidates.first().cloned()
};
if let Some(k) = key {
if let Some(r) = sf.sessions.get_mut(&k) {
r.ended_at = Some(crate::installers::now_rfc3339());
r.exit_code = Some(exit_code);
r.status = if ok { "stopped" } else { "failed" }.to_string();
}
save(app, &sf)?;
}
Ok(())
}
/// Close a REPL session.
pub fn finish_repl(app: &App, id: &str) -> Result<()> {
let mut sf = load(app)?;
if let Some(r) = sf.sessions.get_mut(id) {
r.ended_at = Some(crate::installers::now_rfc3339());
r.exit_code = Some(0);
r.status = "stopped".to_string();
save(app, &sf)?;
}
Ok(())
}
/// Mark running sessions whose process is dead as interrupted. Returns the
/// number of sessions reconciled. REPL sessions (no pid) are left untouched:
/// the current REPL has no pid, and only 'am sessions' triggers this.
pub fn reconcile(app: &App) -> Result<usize> {
let mut sf = load(app)?;
let now = crate::installers::now_rfc3339();
let mut changed = 0;
for r in sf.sessions.values_mut() {
if r.status != "running" {
continue;
}
let dead = match r.pid {
Some(p) => !crate::process::is_running(p),
None => false,
};
if dead {
r.status = "interrupted".to_string();
r.ended_at = Some(now.clone());
changed += 1;
}
}
if changed > 0 {
save(app, &sf)?;
}
Ok(changed)
}
/// Duration of a session in seconds (elapsed when still running).
pub fn duration_s(r: &SessionRecord) -> Option<u64> {
let start = chrono::DateTime::parse_from_rfc3339(&r.started_at).ok()?;
let end = match &r.ended_at {
Some(e) => chrono::DateTime::parse_from_rfc3339(e).ok()?,
None => chrono::Utc::now().into(),
};
let secs = end
.with_timezone(&chrono::Utc)
.signed_duration_since(start.with_timezone(&chrono::Utc))
.num_seconds();
Some(secs.max(0) as u64)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn test_app(tag: &str) -> 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();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.install_dir = dir.path().join("agents");
p.bin_dir = dir.path().join("agents").join("bin");
p.log_dir = dir.path().join("logs");
p.state_file = dir.path().join("state.json");
p.probe_cache_file = dir.path().join("probe-cache.json");
p.config_dir = Some(dir.path().join("config"));
app.paths = p;
app.state = crate::state::StateStore::new(dir.path().join("state.json"));
let _ = tag;
app
}
#[test]
fn agent_session_lifecycle() {
let app = test_app("sess");
let id = start_agent(&app, "claude-code", 4242, Some("/tmp".into()), None, vec![], vec![], None).unwrap();
let sf = load(&app).unwrap();
let r = sf.sessions.get(&id).unwrap();
assert_eq!(r.status, "running");
assert_eq!(r.pid, Some(4242));
assert_eq!(app.state.load().unwrap().sessions_count, 1);
finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
let sf = load(&app).unwrap();
let r = sf.sessions.get(&id).unwrap();
assert_eq!(r.status, "stopped");
assert_eq!(r.exit_code, Some(0));
assert!(r.ended_at.is_some());
assert!(duration_s(r).is_some());
}
#[test]
fn reconcile_marks_dead_pid_interrupted() {
let app = test_app("recon");
let id = start_agent(&app, "aider", u32::MAX - 1, None, None, vec![], vec![], None).unwrap();
let changed = reconcile(&app).unwrap();
assert_eq!(changed, 1);
let sf = load(&app).unwrap();
assert_eq!(sf.sessions.get(&id).unwrap().status, "interrupted");
}
#[test]
fn repl_session_lifecycle() {
let app = test_app("repl");
start_repl(&app, "s1", None).unwrap();
finish_repl(&app, "s1").unwrap();
let sf = load(&app).unwrap();
assert_eq!(sf.sessions.get("s1").unwrap().status, "stopped");
}
#[test]
fn finish_matches_by_pid_first() {
let app = test_app("multi");
start_agent(&app, "jcode", 100, None, None, vec![], vec![], None).unwrap();
start_agent(&app, "jcode", 200, None, None, vec![], vec![], None).unwrap();
finish_agent(&app, "jcode", Some(200), 1, false).unwrap();
let sf = load(&app).unwrap();
let stopped: Vec<_> = sf
.sessions
.values()
.filter(|r| r.status == "stopped" || r.status == "failed")
.collect();
assert_eq!(stopped.len(), 1);
assert_eq!(stopped[0].pid, Some(200));
assert_eq!(stopped[0].exit_code, Some(1));
assert_eq!(stopped[0].status, "failed");
}
}
+88 -2
View File
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub const STATE_VERSION: u32 = 1; pub const STATE_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateFile { pub struct StateFile {
@@ -14,6 +14,13 @@ pub struct StateFile {
pub version: u32, pub version: u32,
#[serde(default)] #[serde(default)]
pub installed: BTreeMap<String, InstalledEntry>, pub installed: BTreeMap<String, InstalledEntry>,
/// Total number of sessions started (incremented by the session
/// registry; additive since state v2).
#[serde(default)]
pub sessions_count: u64,
/// RFC 3339 timestamp of the last start/run of any agent.
#[serde(default)]
pub last_used: Option<String>,
} }
impl Default for StateFile { impl Default for StateFile {
@@ -21,6 +28,8 @@ impl Default for StateFile {
Self { Self {
version: STATE_VERSION, version: STATE_VERSION,
installed: BTreeMap::new(), installed: BTreeMap::new(),
sessions_count: 0,
last_used: None,
} }
} }
} }
@@ -76,12 +85,18 @@ impl StateStore {
} }
let text = std::fs::read_to_string(&self.path) let text = std::fs::read_to_string(&self.path)
.with_context(|| format!("cannot read state file {}", self.path.display()))?; .with_context(|| format!("cannot read state file {}", self.path.display()))?;
let sf: StateFile = serde_json::from_str(&text).map_err(|e| { let mut sf: StateFile = serde_json::from_str(&text).map_err(|e| {
anyhow!( anyhow!(
"state file {} is corrupt ({e}); run 'am doctor --fix' to repair it", "state file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
self.path.display() self.path.display()
) )
})?; })?;
// Additive migration: older files (v1) load unchanged and are
// rewritten as v2 on first contact.
if sf.version < STATE_VERSION {
sf.version = STATE_VERSION;
self.save(&sf)?;
}
Ok(sf) Ok(sf)
} }
@@ -130,6 +145,23 @@ impl StateStore {
} }
Ok(()) Ok(())
} }
/// Record the last use of an agent (start or run) — additive v2 data.
pub fn touch(&self, name: &str) -> Result<()> {
let mut sf = self.load()?;
if sf.installed.contains_key(name) {
sf.last_used = Some(crate::installers::now_rfc3339());
self.save(&sf)?;
}
Ok(())
}
/// Increment the total session counter (one per session start).
pub fn bump_sessions(&self) -> Result<()> {
let mut sf = self.load()?;
sf.sessions_count = sf.sessions_count.saturating_add(1);
self.save(&sf)
}
} }
#[cfg(test)] #[cfg(test)]
@@ -167,6 +199,60 @@ mod tests {
assert!(st.all().unwrap().is_empty()); assert!(st.all().unwrap().is_empty());
} }
#[test]
fn v1_file_is_migrated_additively() {
let dir = tempfile::tempdir().unwrap();
let st = store(dir.path());
std::fs::write(
st.path(),
r#"{
"version": 1,
"installed": {
"claude-code": {
"name": "claude-code",
"method": "npm",
"install_dir": "/tmp/cc",
"bins": [],
"run": "claude",
"installed_at": "2026-01-01T00:00:00Z"
}
}
}"#,
)
.unwrap();
let sf = st.load().unwrap();
assert_eq!(sf.version, 2);
assert_eq!(sf.sessions_count, 0);
assert_eq!(sf.last_used, None);
assert!(sf.installed.contains_key("claude-code"));
// The migration is persisted on first contact.
let raw = std::fs::read_to_string(st.path()).unwrap();
assert!(raw.contains(r#""version": 2"#), "migrated file rewritten: {raw}");
}
#[test]
fn touch_records_last_used() {
let dir = tempfile::tempdir().unwrap();
let st = store(dir.path());
st.touch("claude-code").unwrap(); // no entry: no-op
assert_eq!(st.load().unwrap().last_used, None);
let entry = InstalledEntry {
name: "claude-code".to_string(),
version: None,
method: "npm".to_string(),
install_dir: "/tmp/cc".to_string(),
bins: vec![],
run: "claude".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
st.set(&entry).unwrap();
st.touch("claude-code").unwrap();
assert!(st.load().unwrap().last_used.is_some());
}
#[test] #[test]
fn corrupt_file_is_reported() { fn corrupt_file_is_reported() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -29,7 +29,7 @@ fn append_then_read_back_through_read_events() {
log.append( log.append(
&Event::now(EventKind::Run) &Event::now(EventKind::Run)
.with_agent("jcode") .with_agent("jcode")
.with_cwd("/tmp/demo") .with_cwd(Some("/tmp/demo".to_string()))
.with_exit_code(0), .with_exit_code(0),
) )
.unwrap(); .unwrap();
+105
View File
@@ -0,0 +1,105 @@
//! Structured history: record, read, filter, migration, command.
mod common;
use agent_manager::commands::history_cmd;
use agent_manager::history::{self, HistoryEntry};
use std::path::PathBuf;
#[test]
fn cli_parses_history() {
use agent_manager::cli::{Cli, Command};
use clap::Parser;
let cli = Cli::parse_from(["am", "history", "--failed", "--search", "install"]);
match &cli.command {
Some(Command::History {
failed, search, ..
}) => {
assert!(*failed);
assert_eq!(search.as_deref(), Some("install"));
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn record_and_read_most_recent_first() {
let app = common::test_app("hist", &["history"]);
history::record(&app, "s1", &HistoryEntry::now("install jcode")).unwrap();
history::record(
&app,
"s1",
&HistoryEntry::now("ls")
.with_kind("shell".to_string())
.with_duration_ms(12),
)
.unwrap();
let all = history::read_all(&app, 0);
assert_eq!(all.len(), 2);
assert_eq!(all[0].cmd, "ls");
assert_eq!(all[0].kind, "shell");
assert_eq!(all[0].duration_ms, Some(12));
assert_eq!(all[1].cmd, "install jcode");
}
#[test]
fn migration_imports_flat_history_once() {
let app = common::test_app("mig", &["history"]);
let txt = app.paths.state_file.with_file_name("history.txt");
std::fs::write(&txt, "install jcode
start group:dev
").unwrap();
assert_eq!(history::migrate(&app).unwrap(), 2);
assert_eq!(history::migrate(&app).unwrap(), 0, "no-op on second run");
let all = history::read_all(&app, 0);
assert_eq!(all.len(), 2);
assert_eq!(all[0].cmd, "start group:dev");
}
#[test]
fn filters_by_kind_search_and_failed() {
let app = common::test_app("filt", &["history"]);
history::record(
&app,
"s1",
&HistoryEntry::now("install jcode")
.with_kind("am".to_string())
.with_exit_code(Some(1)),
)
.unwrap();
history::record(
&app,
"s1",
&HistoryEntry::now("ls -la")
.with_kind("shell".to_string())
.with_cwd(Some("/tmp".to_string())),
)
.unwrap();
assert_eq!(history_cmd::filter(&app, Some("shell"), None, None, false, None, 10).len(), 1);
assert_eq!(history_cmd::filter(&app, None, None, Some("install"), false, None, 10).len(), 1);
assert_eq!(history_cmd::filter(&app, None, None, None, true, None, 10).len(), 1);
assert_eq!(history_cmd::filter(&app, None, Some("/tmp"), None, false, None, 10).len(), 1);
assert_eq!(history_cmd::filter(&app, None, None, Some("zzz"), false, None, 10).len(), 0);
}
#[test]
fn command_runs_and_migrates() {
let app = common::test_app("cmd", &["history"]);
let txt = app.paths.state_file.with_file_name("history.txt");
std::fs::write(&txt, "install jcode
").unwrap();
let code = history_cmd::run(&app, None, None, None, false, None, 100).unwrap();
assert_eq!(code, 0);
// Migration created the structured file.
assert!(history::dir(&app).join("migrated.jsonl").exists());
}
#[test]
fn fixture_session_file_parses() {
// Guard for session_of_file used by the display layer.
assert_eq!(
history::session_of_file(&PathBuf::from("20260815_143926.jsonl")),
Some("20260815_143926".to_string())
);
}
+46
View File
@@ -0,0 +1,46 @@
//! init + context detection.
mod common;
use agent_manager::commands::init_cmd;
use agent_manager::context;
#[test]
fn context_detects_stack_from_markers() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
let stack = context::detect_stack(dir.path());
assert_eq!(stack, vec!["node".to_string(), "rust".to_string()]);
assert_eq!(context::recommended_agents(&stack), vec!["smelt", "claude-code"]);
}
#[test]
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();
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());
// Avec --force, il écrase.
assert!(init_cmd::generate(dir.path(), true).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 text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("claude-code"), "default agent: {text}");
}
#[test]
fn command_runs_dry() {
let app = common::test_app("initdry", &["init", "--dry-run"]);
let code = init_cmd::run(&app, false).unwrap();
assert_eq!(code, 0);
}
+130
View File
@@ -0,0 +1,130 @@
//! Instrumentation: every real action appends one event to the journal;
//! dry runs write nothing. Uses the same isolated-App pattern as the other
//! integration tests (no user config or state leaks in).
mod common;
use agent_manager::app::App;
use agent_manager::cli::{Cli, StartArgs};
use agent_manager::commands::run_cmd;
use agent_manager::events::{self, Event, EventKind};
use agent_manager::state::InstalledEntry;
use clap::Parser;
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
/// Build an isolated App whose catalog contains the given agent definition.
fn app_with_agent(tag: &str, args: &[&str], agent_yaml: &str) -> App {
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("am-instr-{tag}-{}-{id}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = common::write_config(&dir, agent_yaml);
let mut full = vec!["am".to_string(), "--config".to_string(), cfg.display().to_string()];
full.extend(args.iter().map(|s| s.to_string()));
let cli = Cli::parse_from(full);
let mut app = App::from_cli(cli).expect("app should build");
common::isolate(&mut app, &dir);
app
}
fn events(app: &App) -> Vec<Event> {
events::read_events(&app.events_dir(), 0)
}
const ECHO_AGENT: &str = r#"
agents:
- name: echo-agent
install: { type: binary, url: "https://example.com/echo.zip" }
run: sh
args: ["-c", "echo hi"]
"#;
#[test]
fn run_emits_one_run_event_with_exit_code() {
let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT);
let code = run_cmd::run(&app, "echo-agent", &[OsString::from("hello")]).unwrap();
assert_eq!(code, 0);
let evs = events(&app);
assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs);
assert_eq!(evs[0].kind, EventKind::Run);
assert_eq!(evs[0].agent.as_deref(), Some("echo-agent"));
assert_eq!(evs[0].exit_code, Some(0));
assert!(evs[0].duration_s.is_some());
}
#[test]
fn dry_run_writes_no_event() {
let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT);
let code = run_cmd::run(&app, "echo-agent", &[]).unwrap();
assert_eq!(code, 0);
assert!(events(&app).is_empty(), "dry-run must not append events");
assert!(events::journal_files(&app.events_dir()).is_empty());
}
fn sleeper_agent() -> String {
let (run, args) = if cfg!(windows) {
("ping", r#"["-n", "60", "127.0.0.1"]"#)
} else {
("sleep", r#"["60"]"#)
};
format!(
r#"
agents:
- name: sleeper
install: {{ type: binary, url: "https://example.com/sleeper.zip" }}
run: {run}
args: {args}
"#
)
}
#[test]
fn start_and_stop_emit_a_matching_pair() {
let app = app_with_agent("startstop", &["start", "sleeper", "--background"], &sleeper_agent());
// The agent must be "managed" for the PID to be recorded and stoppable.
let entry = InstalledEntry {
name: "sleeper".to_string(),
version: None,
method: "binary".to_string(),
install_dir: app.paths.install_dir.display().to_string(),
bins: vec![],
run: "sleeper".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
app.state.set(&entry).unwrap();
let opts = StartArgs {
agent: "sleeper".to_string(),
background: true,
..Default::default()
};
let code = run_cmd::start(&app, &opts).unwrap();
assert_eq!(code, 0);
let stop_code = run_cmd::stop(&app, "sleeper", false, None).unwrap();
assert_eq!(stop_code, 0);
let evs = events(&app);
assert_eq!(evs.len(), 2, "expected Start + Stop, got {:?}", evs);
assert_eq!(evs[0].kind, EventKind::Start);
assert_eq!(evs[0].agent.as_deref(), Some("sleeper"));
assert!(evs[0].pid.is_some());
assert!(evs[0].session.is_some(), "start events carry a session id");
assert_eq!(evs[1].kind, EventKind::Stop);
assert_eq!(evs[1].pid, evs[0].pid);
assert_eq!(evs[1].exit_code, Some(0));
assert!(evs[1].duration_s.is_some(), "stop events carry the duration");
}
#[test]
fn fixture_path_is_reachable() {
// Guard: the shared fixture used by events_test stays parsable here too.
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/events-sample.jsonl");
assert_eq!(events::parse_file(&p).len(), 2);
}
+115
View File
@@ -0,0 +1,115 @@
//! Quick wins: logs, list filters, config set, alias, open, history dedup.
mod common;
use agent_manager::commands::{alias_cmd, config_cmd, logs_cmd, open_cmd};
use agent_manager::state::InstalledEntry;
use std::path::PathBuf;
fn installed(app: &agent_manager::app::App, name: &str, dir: &PathBuf) {
let entry = InstalledEntry {
name: name.to_string(),
version: Some("1.0.0".to_string()),
method: "binary".to_string(),
install_dir: dir.display().to_string(),
bins: vec![],
run: name.to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
app.state.set(&entry).unwrap();
}
#[test]
fn list_sorts_and_filters_running() {
use agent_manager::commands::list_cmd;
let app = common::test_app("listqw", &["list", "--all"]);
let dir = app.paths.install_dir.join("x");
installed(&app, "jcode", &dir);
let code = list_cmd::run(&app, true, None, true, None).unwrap();
assert_eq!(code, 0, "--running sur un parc arrete donne une liste vide");
let code = list_cmd::run(&app, true, None, false, Some("version")).unwrap();
assert_eq!(code, 0);
assert!(list_cmd::run(&app, true, None, false, Some("bogus")).is_err());
}
#[test]
fn logs_tail_reads_the_agent_log() {
let app = common::test_app("logs", &["logs", "jcode"]);
let dir = app.paths.log_dir.clone();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("jcode.log"), "ligne 1
ligne 2
").unwrap();
let code = logs_cmd::run(&app, "jcode", 20, false).unwrap();
assert_eq!(code, 0);
assert_eq!(logs_cmd::log_path(&app, "jcode").unwrap(), dir.join("jcode.log"));
assert!(logs_cmd::run(&app, "inconnu", 20, false).is_err());
}
#[test]
fn config_set_writes_and_validates() {
let app = common::test_app("cfgset", &["config", "set", "settings.default_shell", "pwsh"]);
let code = config_cmd::set_value(&app, "settings.default_shell", "pwsh").unwrap();
assert_eq!(code, 0);
let cfg_path = app.paths.config_dir.as_ref().unwrap().join("config.yaml");
let text = std::fs::read_to_string(&cfg_path).unwrap();
assert!(text.contains("pwsh"), "cle ecrite: {text}");
// Une cle inconnue du schema est rejetee a la validation.
assert!(config_cmd::set_value(&app, "settings.cle_inconnue", "x").is_err());
}
#[test]
fn alias_add_list_remove() {
let app = common::test_app("alias", &["alias", "add", "cc", "claude-code"]);
let code = alias_cmd::run(&app, &agent_manager::cli::AliasCmd::Add {
name: "cc".to_string(),
target: "claude-code".to_string(),
})
.unwrap();
assert_eq!(code, 0);
let cfg_path = app.paths.config_dir.as_ref().unwrap().join("config.yaml");
let text = std::fs::read_to_string(&cfg_path).unwrap();
assert!(text.contains("cc: claude-code"), "alias ecrit: {text}");
let code = alias_cmd::run(&app, &agent_manager::cli::AliasCmd::List).unwrap();
assert_eq!(code, 0);
let code = alias_cmd::run(&app, &agent_manager::cli::AliasCmd::Remove {
name: "cc".to_string(),
})
.unwrap();
assert_eq!(code, 0);
// Cible inconnue refuse.
assert!(alias_cmd::run(
&app,
&agent_manager::cli::AliasCmd::Add {
name: "zz".to_string(),
target: "agent-inconnu".to_string(),
}
)
.is_err());
}
#[test]
fn open_resolves_install_dir() {
let app = common::test_app("open", &["open", "jcode"]);
let dir = app.paths.install_dir.join("jcode");
std::fs::create_dir_all(&dir).unwrap();
installed(&app, "jcode", &dir);
let target = open_cmd::resolve_target(&app, "jcode").unwrap();
assert_eq!(target, dir);
assert!(open_cmd::resolve_target(&app, "inconnu").is_err());
}
#[test]
fn history_migration_dedups_consecutive() {
use agent_manager::history;
let app = common::test_app("dedup", &["history"]);
let txt = app.paths.state_file.with_file_name("history.txt");
std::fs::write(&txt, "install jcode
install jcode
list
").unwrap();
assert_eq!(history::migrate(&app).unwrap(), 2, "doublons consecutifs sautes");
}
+73
View File
@@ -0,0 +1,73 @@
//! sessions command and session registry: lifecycle, counter, reconciliation
//! and CLI parsing.
mod common;
use agent_manager::cli::{Cli, Command};
use agent_manager::commands::sessions_cmd;
use agent_manager::sessions;
use clap::Parser;
#[test]
fn cli_parses_sessions() {
let cli = Cli::parse_from(["am", "sessions", "--status", "failed", "--show", "abc"]);
match &cli.command {
Some(Command::Sessions {
agent,
status,
show,
..
}) => {
assert!(agent.is_none());
assert_eq!(status.as_deref(), Some("failed"));
assert_eq!(show.as_deref(), Some("abc"));
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn agent_session_roundtrip_and_counter() {
let app = common::test_app("sess", &["sessions"]);
let id = sessions::start_agent(
&app,
"claude-code",
4242,
Some("/tmp".to_string()),
None,
vec![],
vec![],
None,
)
.unwrap();
let sf = sessions::load(&app).unwrap();
let r = sf.sessions.get(&id).unwrap();
assert_eq!(r.status, "running");
assert_eq!(r.agent.as_deref(), Some("claude-code"));
assert_eq!(app.state.load().unwrap().sessions_count, 1);
sessions::finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
let sf = sessions::load(&app).unwrap();
let r = sf.sessions.get(&id).unwrap();
assert_eq!(r.status, "stopped");
assert_eq!(r.exit_code, Some(0));
assert!(r.ended_at.is_some());
}
#[test]
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).unwrap();
assert_eq!(code, 0);
// --json path returns the row set without error.
sessions::finish_agent(&app, "aider", Some(4242), 0, true).unwrap();
}
#[test]
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)).unwrap();
assert_eq!(code, 0);
}
+110
View File
@@ -0,0 +1,110 @@
//! stats + log: aggregation and filtering over the event journal.
mod common;
use agent_manager::app::App;
use agent_manager::commands::log_cmd;
use agent_manager::commands::stats_cmd;
use agent_manager::events;
fn app_with_events(tag: &str, lines: &[&str]) -> App {
let app = common::test_app(tag, &["stats"]);
let month = chrono::Utc::now().format("%Y%m").to_string();
let path = app.events_dir().join(format!("events-{month}.jsonl"));
let mut text = String::new();
for l in lines {
text.push_str(l);
text.push('\n');
}
std::fs::create_dir_all(app.events_dir()).unwrap();
std::fs::write(&path, text).unwrap();
app
}
#[test]
fn stats_aggregate_counts_everything() {
let app = app_with_events(
"agg",
&[
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}"#,
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":30}"#,
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":1,"duration_s":5}"#,
r#"{"ts":"2026-08-03T11:00:00Z","kind":"install","agent":"a"}"#,
r#"{"ts":"2026-08-03T12:00:00Z","kind":"update","agent":"a"}"#,
],
);
let rows = stats_cmd::aggregate(&app, None, None).unwrap();
let a = rows.iter().find(|r| r.agent == "a").unwrap();
assert_eq!(a.launches, 2);
assert_eq!(a.total_duration_s, 40);
assert_eq!(a.avg_duration_s, 20);
assert_eq!(a.failures, 0);
assert_eq!(a.installs, 1);
assert_eq!(a.updates, 1);
let b = rows.iter().find(|r| r.agent == "b").unwrap();
assert_eq!(b.failures, 1);
assert_eq!(b.launches, 1);
}
#[test]
fn stats_period_cuts_old_events() {
let app = app_with_events(
"period",
&[r#"{"ts":"2020-01-01T10:00:00Z","kind":"run","agent":"old","exit_code":0}"#],
);
assert!(stats_cmd::aggregate(&app, None, Some("30d")).unwrap().is_empty());
assert_eq!(stats_cmd::aggregate(&app, None, None).unwrap().len(), 1);
assert!(stats_cmd::aggregate(&app, None, Some("7d")).unwrap().is_empty());
}
#[test]
fn log_filters_by_agent_kind_and_limit() {
let app = app_with_events(
"logf",
&[
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#,
r#"{"ts":"2026-08-01T10:00:01Z","kind":"start","agent":"a","pid":42}"#,
r#"{"ts":"2026-08-01T10:00:02Z","kind":"run","agent":"b","exit_code":1}"#,
],
);
let all = log_cmd::filter_events(&app, None, None, None, 50).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].agent.as_deref(), Some("b"), "most recent first");
let only_a = log_cmd::filter_events(&app, Some("a"), None, None, 50).unwrap();
assert_eq!(only_a.len(), 2);
let starts = log_cmd::filter_events(&app, None, Some("start"), None, 50).unwrap();
assert_eq!(starts.len(), 1);
assert_eq!(starts[0].pid, Some(42));
let limited = log_cmd::filter_events(&app, None, None, None, 1).unwrap();
assert_eq!(limited.len(), 1);
let since = log_cmd::filter_events(&app, None, None, Some("2026-09-01"), 50).unwrap();
assert!(since.is_empty());
}
#[test]
fn stats_command_runs() {
let app = app_with_events(
"cmd",
&[r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#],
);
let code = stats_cmd::run(&app, None, None).unwrap();
assert_eq!(code, 0);
}
#[test]
fn journal_reads_back_through_events_api() {
let app = app_with_events("api", &[]);
let mut log = events::EventLog::new(app.events_dir());
log.append(
&events::Event::now(events::EventKind::Run)
.with_agent("x")
.with_exit_code(0),
)
.unwrap();
let all = events::read_events(&app.events_dir(), 0);
assert_eq!(all.len(), 1);
}