Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ecfa0d21c | ||
|
|
3902bbcb37 | ||
|
|
d4a6aeae1d | ||
|
|
38d0729cb3 | ||
|
|
85d44f9e56 | ||
|
|
48e85555f7 | ||
|
|
c9e2543f50 | ||
|
|
9f4a13eae2 | ||
|
|
33748bea36 | ||
|
|
19e77fd261 | ||
|
|
38b7a3a915 | ||
|
|
3af0834bae | ||
|
|
c6518bcfd5 | ||
|
|
a672880e34 | ||
|
|
310da96a63 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.2.14"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
|
||||
@@ -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> --method pip | choisit une méthode d'installation |
|
||||
| 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 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 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
|
||||
|
||||
| Commande | Rôle |
|
||||
|----------|------|
|
||||
| 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 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 self-update | met à jour am lui-même (si configuré) |
|
||||
| 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
|
||||
%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
|
||||
am self-update si le dépôt de release est configuré).
|
||||
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
# 🗺️ 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)
|
||||
>
|
||||
> ✅ **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
@@ -121,6 +121,23 @@ impl App {
|
||||
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.
|
||||
pub fn confirm(&self, question: &str) -> Result<bool> {
|
||||
if self.cli.yes {
|
||||
|
||||
+114
@@ -68,7 +68,32 @@ pub enum Command {
|
||||
/// Only show agents of this category
|
||||
#[arg(long, value_name = "CATEGORY")]
|
||||
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(StartArgs),
|
||||
/// 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: 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 {
|
||||
/// Agent name or alias
|
||||
@@ -133,11 +194,38 @@ pub enum Command {
|
||||
#[arg(long, value_name = "CATEGORY")]
|
||||
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
|
||||
Info {
|
||||
/// Agent name or alias
|
||||
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
|
||||
Help {
|
||||
/// Command name (also accepts an agent name or alias); "commands" lists every command
|
||||
@@ -221,6 +309,25 @@ pub struct StartArgs {
|
||||
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.
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ConfigCmd {
|
||||
@@ -237,4 +344,11 @@ pub enum ConfigCmd {
|
||||
/// YAML (or JSON) file defining agents to include
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use crate::cli::ConfigCmd;
|
||||
use crate::config;
|
||||
use crate::output::print_json;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::Parser;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
@@ -94,9 +95,63 @@ pub fn run(app: &App, cmd: &ConfigCmd) -> Result<i32> {
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
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> {
|
||||
app.paths
|
||||
.config_dir
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::doctor::{self, Status};
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::output::{boxed_table, print_json, status_style};
|
||||
use crate::runner::SystemRunner;
|
||||
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.
|
||||
let runner = SystemRunner::new(false, false, &app.log);
|
||||
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() {
|
||||
print_json(&report.checks);
|
||||
return Ok(if report.ok() { 0 } else { 1 });
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! installer, and record the installation in the state database.
|
||||
|
||||
use super::*;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::installers;
|
||||
use crate::runner::SystemRunner;
|
||||
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 version = entry.version.clone().unwrap_or_else(|| "unknown".to_string());
|
||||
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!(
|
||||
"installed {} (v{version}) in {}",
|
||||
agent.title(),
|
||||
|
||||
@@ -25,7 +25,13 @@ struct AgentRow {
|
||||
}
|
||||
|
||||
/// 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 probe = crate::probe::detect_externals(app);
|
||||
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 {
|
||||
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() {
|
||||
print_json(&rows);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,16 +1,24 @@
|
||||
//! Command implementations and dispatch.
|
||||
|
||||
pub mod alias_cmd;
|
||||
pub mod completion_cmd;
|
||||
pub mod config_cmd;
|
||||
pub mod doctor_cmd;
|
||||
pub mod export_import;
|
||||
pub mod help_cmd;
|
||||
pub mod history_cmd;
|
||||
pub mod info_cmd;
|
||||
pub mod init_cmd;
|
||||
pub mod install_cmd;
|
||||
pub mod list_cmd;
|
||||
pub mod log_cmd;
|
||||
pub mod logs_cmd;
|
||||
pub mod open_cmd;
|
||||
pub mod run_cmd;
|
||||
pub mod search_cmd;
|
||||
pub mod self_uninstall;
|
||||
pub mod sessions_cmd;
|
||||
pub mod stats_cmd;
|
||||
pub mod self_update;
|
||||
pub mod status_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).
|
||||
pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
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::Stop {
|
||||
agent,
|
||||
@@ -48,6 +68,25 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
timeout,
|
||||
} => run_cmd::restart(app, start, *force, *timeout),
|
||||
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 {
|
||||
agent,
|
||||
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::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
|
||||
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::Init { force } => init_cmd::run(app, *force),
|
||||
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
|
||||
Command::Version => version_cmd::run(app),
|
||||
Command::Config(sub) => config_cmd::run(app, sub),
|
||||
|
||||
@@ -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
@@ -2,7 +2,9 @@
|
||||
|
||||
use super::*;
|
||||
use crate::cli::StartArgs;
|
||||
use crate::events::{self, Event, EventKind};
|
||||
use crate::process;
|
||||
use crate::sessions;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsString;
|
||||
@@ -37,6 +39,7 @@ fn start_one(
|
||||
let exec = resolve_exec(app, agent, extra_args, extra_env)?;
|
||||
let bin = PathBuf::from(&exec.program);
|
||||
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 {
|
||||
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env)?;
|
||||
if app.dry_run() {
|
||||
@@ -51,7 +54,30 @@ fn start_one(
|
||||
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 _ = 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!(
|
||||
"{} started (pid {pid}); log: {}",
|
||||
agent.title(),
|
||||
@@ -70,6 +96,7 @@ fn start_one(
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
||||
let mut full_args = prefix;
|
||||
full_args.extend(exec.args.iter().cloned());
|
||||
@@ -78,7 +105,19 @@ fn start_one(
|
||||
.envs(&exec.env)
|
||||
.status()
|
||||
.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",
|
||||
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)?;
|
||||
return Ok(0);
|
||||
}
|
||||
app.log.info(&format!("stopping '{}' (pid {pid})", agent.name));
|
||||
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)?;
|
||||
if stopped {
|
||||
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);
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
||||
let mut full_args = prefix;
|
||||
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)
|
||||
.status()
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! executables found on PATH otherwise.
|
||||
|
||||
use super::*;
|
||||
use crate::events::{Event, EventKind};
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
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)?;
|
||||
}
|
||||
|
||||
app.emit(&Event::now(EventKind::Uninstall).with_agent(agent.name.clone()));
|
||||
app.log.success(&format!(
|
||||
"uninstalled {} (files removed{})",
|
||||
agent.title(),
|
||||
@@ -178,6 +180,11 @@ fn uninstall_external(app: &App, agent: &AgentDef) -> Result<i32> {
|
||||
.and_then(|t| which::which(t).ok())
|
||||
.is_none();
|
||||
if gone {
|
||||
app.emit(
|
||||
&Event::now(EventKind::Uninstall)
|
||||
.with_agent(agent.name.clone())
|
||||
.with_reason("external"),
|
||||
);
|
||||
app.log
|
||||
.success(&format!("uninstalled {} (external)", agent.title()));
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! reinstall when an update exists.
|
||||
|
||||
use super::*;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::installers;
|
||||
use crate::runner::{Runner, SystemRunner};
|
||||
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());
|
||||
let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string());
|
||||
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!(
|
||||
"updated {} to v{version}",
|
||||
agent.title()
|
||||
|
||||
+153
@@ -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
@@ -32,6 +32,24 @@ pub enum EventKind {
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Event {
|
||||
@@ -90,13 +108,13 @@ impl Event {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
|
||||
self.cwd = Some(cwd.into());
|
||||
pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
|
||||
self.cwd = cwd;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_project(mut self, project: impl Into<String>) -> Self {
|
||||
self.project = Some(project.into());
|
||||
pub fn with_project(mut self, project: Option<String>) -> Self {
|
||||
self.project = project;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -209,6 +227,46 @@ pub fn read_events(dir: &Path, limit: usize) -> Vec<Event> {
|
||||
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).
|
||||
pub struct EventLog {
|
||||
dir: PathBuf,
|
||||
@@ -247,41 +305,11 @@ impl EventLog {
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let month = month_of(&event.ts);
|
||||
let path = self.file_for(&month);
|
||||
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(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())
|
||||
))
|
||||
append_to(&path, event)?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+162
-1
@@ -177,6 +177,143 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
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 {
|
||||
name: "search",
|
||||
category: "Commands",
|
||||
@@ -213,6 +350,23 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
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 {
|
||||
name: "install",
|
||||
category: "Commands",
|
||||
@@ -1380,7 +1534,14 @@ mod tests {
|
||||
assert!(text.contains("allocator"));
|
||||
assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
|
||||
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]
|
||||
|
||||
+280
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,13 @@ pub mod catalog;
|
||||
pub mod cli;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod deps;
|
||||
pub mod doctor;
|
||||
pub mod download;
|
||||
pub mod events;
|
||||
pub mod help;
|
||||
pub mod history;
|
||||
pub mod installers;
|
||||
pub mod nav;
|
||||
pub mod output;
|
||||
@@ -29,6 +31,7 @@ pub mod ps;
|
||||
pub mod process;
|
||||
pub mod repl;
|
||||
pub mod runner;
|
||||
pub mod sessions;
|
||||
pub mod shell;
|
||||
pub mod state;
|
||||
pub mod tables;
|
||||
|
||||
+118
-6
@@ -15,6 +15,7 @@
|
||||
use crate::app::App;
|
||||
use crate::cli::{Command, StartArgs};
|
||||
use crate::commands;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::nav;
|
||||
use crate::ps;
|
||||
use crate::tables::DataTable;
|
||||
@@ -59,6 +60,14 @@ pub struct AmCompleter {
|
||||
const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("list", "list installed agents"),
|
||||
("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"),
|
||||
("info", "agent details"),
|
||||
("install", "install an agent"),
|
||||
@@ -135,7 +144,7 @@ impl AmCompleter {
|
||||
Self {
|
||||
theme: std::cell::Cell::new(crate::theme::default_theme()),
|
||||
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",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "theme",
|
||||
"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 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));
|
||||
match run_with_editor(app, &mut session) {
|
||||
let result = match run_with_editor(app, &mut session, &sid) {
|
||||
Ok(code) => Ok(code),
|
||||
Err(e) => {
|
||||
app.log.verbose(&format!(
|
||||
"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> =
|
||||
rustyline::Editor::new()?;
|
||||
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 result = handle_input(app, session, &mut last, &trimmed);
|
||||
last_duration = Some(t.elapsed());
|
||||
record_history(app, sid, &trimmed, &result, t.elapsed());
|
||||
// 'theme <name>' may have switched the app theme: repoint the
|
||||
// completion menu at the new palette.
|
||||
if let Some(h) = rl.helper_mut() {
|
||||
@@ -947,8 +968,33 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
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.
|
||||
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 mut last: Option<DataTable> = 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 result = handle_input(app, session, &mut last, &line);
|
||||
last_duration = Some(t.elapsed());
|
||||
record_history(app, sid, &line, &result, t.elapsed());
|
||||
match result {
|
||||
Ok(true) => break,
|
||||
Ok(false) => {}
|
||||
@@ -1152,10 +1199,59 @@ fn handle_line(
|
||||
"list" => Command::List {
|
||||
all: flag("--all"),
|
||||
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 {
|
||||
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 {
|
||||
keyword: rest.join(" "),
|
||||
category: None,
|
||||
@@ -1261,6 +1357,14 @@ fn is_am_command(word: &str) -> bool {
|
||||
word,
|
||||
"list"
|
||||
| "status"
|
||||
| "sessions"
|
||||
| "stats"
|
||||
| "log"
|
||||
| "logs"
|
||||
| "history"
|
||||
| "init"
|
||||
| "alias"
|
||||
| "open"
|
||||
| "search"
|
||||
| "info"
|
||||
| "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).
|
||||
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
|
||||
app.log.cmd(&format!("{}: {line}", session.current.name));
|
||||
let started = std::time::Instant::now();
|
||||
let code = session.run(line)?;
|
||||
if code != 0 {
|
||||
app.log
|
||||
.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(())
|
||||
}
|
||||
|
||||
|
||||
+339
@@ -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
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const STATE_VERSION: u32 = 1;
|
||||
pub const STATE_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateFile {
|
||||
@@ -14,6 +14,13 @@ pub struct StateFile {
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -21,6 +28,8 @@ impl Default for StateFile {
|
||||
Self {
|
||||
version: STATE_VERSION,
|
||||
installed: BTreeMap::new(),
|
||||
sessions_count: 0,
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,12 +85,18 @@ impl StateStore {
|
||||
}
|
||||
let text = std::fs::read_to_string(&self.path)
|
||||
.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!(
|
||||
"state file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -130,6 +145,23 @@ impl StateStore {
|
||||
}
|
||||
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)]
|
||||
@@ -167,6 +199,60 @@ mod tests {
|
||||
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]
|
||||
fn corrupt_file_is_reported() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -29,7 +29,7 @@ fn append_then_read_back_through_read_events() {
|
||||
log.append(
|
||||
&Event::now(EventKind::Run)
|
||||
.with_agent("jcode")
|
||||
.with_cwd("/tmp/demo")
|
||||
.with_cwd(Some("/tmp/demo".to_string()))
|
||||
.with_exit_code(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user