Merge pull request 'quick wins #9-#13 et #16 : logs, list --sort, config set, alias, dedup, open' (#24) from feat/quick-wins into main

This commit was merged in pull request #24.
This commit is contained in:
2026-08-16 22:45:27 -04:00
11 changed files with 545 additions and 4 deletions
+51
View File
@@ -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)
@@ -284,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 {
@@ -300,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,
},
}
+103
View File
@@ -0,0 +1,103 @@
//! alias: manage command aliases (cc -> claude-code) in the user config.
use super::*;
use crate::cli::AliasCmd;
use crate::output::print_json;
use anyhow::{anyhow, Context, Result};
use std::path::PathBuf;
/// Ensure the user config file exists and return its path.
fn user_config(app: &App) -> Result<PathBuf> {
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)?;
let path = dir.join(crate::config::CONFIG_FILE_NAME);
if !path.exists() {
std::fs::write(&path, concat!("version: \"1.0\"\n", "agents: []\n"))
.with_context(|| format!("cannot create {}", path.display()))?;
}
Ok(path)
}
/// Read the aliases map from the user config file.
fn read_aliases(app: &App) -> Result<(PathBuf, serde_yaml::Mapping)> {
let path = user_config(app)?;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?;
let mapping = value
.as_mapping_mut()
.ok_or_else(|| anyhow!("{} must contain a YAML mapping", path.display()))?;
let aliases = mapping
.entry(serde_yaml::Value::String("aliases".to_string()))
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()))
.as_mapping()
.cloned()
.unwrap_or_default();
Ok((path, aliases))
}
fn write_aliases(app: &App, path: &std::path::Path, aliases: serde_yaml::Mapping) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?;
if let Some(mapping) = value.as_mapping_mut() {
mapping.insert(
serde_yaml::Value::String("aliases".to_string()),
serde_yaml::Value::Mapping(aliases),
);
}
std::fs::write(path, serde_yaml::to_string(&value)?)
.with_context(|| format!("cannot write {}", path.display()))
}
pub fn run(app: &App, sub: &AliasCmd) -> Result<i32> {
match sub {
AliasCmd::Add { name, target } => {
// The target must be a known agent (or another alias).
require_agent(app, target)?;
let (path, mut aliases) = read_aliases(app)?;
aliases.insert(
serde_yaml::Value::String(name.clone()),
serde_yaml::Value::String(target.clone()),
);
write_aliases(app, &path, aliases)?;
app.log.success(&format!("alias '{name}' -> '{target}'"));
Ok(0)
}
AliasCmd::Remove { name } => {
let (path, mut aliases) = read_aliases(app)?;
let removed = aliases
.remove(&serde_yaml::Value::String(name.clone()))
.is_some();
if removed {
write_aliases(app, &path, aliases)?;
app.log.success(&format!("alias '{name}' removed"));
} else {
app.log.info(&format!("alias '{name}' does not exist"));
}
Ok(0)
}
AliasCmd::List => {
if app.json() {
print_json(&app.config.aliases);
return Ok(0);
}
if app.config.aliases.is_empty() {
app.log.info("no aliases defined — 'am alias add cc claude-code'");
return Ok(0);
}
let mut table = crate::output::Table::new(vec!["ALIAS", "TARGET"]);
for (k, v) in &app.config.aliases {
table.row(vec![k.clone(), v.clone()]);
}
print!("{}", table.render());
Ok(0)
}
}
}
+55
View File
@@ -5,6 +5,7 @@ use crate::cli::ConfigCmd;
use crate::config;
use crate::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
+16 -2
View File
@@ -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);
+56
View File
@@ -0,0 +1,56 @@
//! logs: tail the log file of an agent (--follow for a live view).
use super::*;
use crate::output::print_json;
use anyhow::{bail, Result};
use std::path::PathBuf;
/// Resolve the log path of an agent (validates the agent first).
pub fn log_path(app: &App, agent_name: &str) -> Result<PathBuf> {
let agent = require_agent(app, agent_name)?;
Ok(crate::process::agent_log_path(app, &agent.name))
}
pub fn run(app: &App, agent: &str, lines: usize, follow: bool) -> Result<i32> {
let path = log_path(app, agent)?;
if !path.exists() {
bail!(
"no log yet for '{}' — start it with 'am start {} --background'",
agent,
agent
);
}
if app.json() {
let tail = crate::process::last_lines(&path, lines);
print_json(&serde_json::json!({
"agent": agent,
"log": path.display().to_string(),
"tail": tail,
}));
return Ok(0);
}
if !follow {
print!("{}", crate::process::last_lines(&path, lines));
return Ok(0);
}
// Live follow: print the initial tail, then new bytes as they arrive.
let mut seen: usize = std::fs::metadata(&path).map(|m| m.len() as usize).unwrap_or(0);
loop {
if let Ok(text) = std::fs::read_to_string(&path) {
let bytes = text.as_bytes();
if bytes.len() > seen {
// Slicer sans risque de couper un caractere UTF-8.
let tail: String = text
.char_indices()
.filter(|(i, _)| *i >= seen)
.map(|(_, c)| c)
.collect();
print!("{tail}");
use std::io::Write;
let _ = std::io::stdout().flush();
}
seen = bytes.len();
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
+16 -1
View File
@@ -1,5 +1,6 @@
//! Command implementations and dispatch.
pub mod alias_cmd;
pub mod completion_cmd;
pub mod config_cmd;
pub mod doctor_cmd;
@@ -11,6 +12,8 @@ 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;
@@ -40,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,
+42
View File
@@ -0,0 +1,42 @@
//! open: open an agent's installation directory in the file manager.
use super::*;
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
/// Resolve the directory to open: the managed install dir, or the parent of
/// the detected external binary.
pub fn resolve_target(app: &App, agent_name: &str) -> Result<PathBuf> {
let agent = require_agent(app, agent_name)?;
if let Some(entry) = app.state.get(&agent.name).ok().flatten() {
return Ok(PathBuf::from(&entry.install_dir));
}
let probe = crate::probe::detect_externals(app);
if let Some(bin) = probe.path_of(&agent.name) {
return Ok(bin
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| bin.clone()));
}
bail!("agent '{}' is not installed — run 'am install {}' first", agent_name, agent_name);
}
pub fn run(app: &App, agent: &str) -> Result<i32> {
let target = resolve_target(app, agent)?;
if app.dry_run() {
app.log.dry(format!("would open {}", target.display()));
return Ok(0);
}
if !target.exists() {
bail!("directory {} does not exist", target.display());
}
#[cfg(windows)]
let opened = std::process::Command::new("explorer").arg(&target).status();
#[cfg(target_os = "macos")]
let opened = std::process::Command::new("open").arg(&target).status();
#[cfg(all(unix, not(target_os = "macos")))]
let opened = std::process::Command::new("xdg-open").arg(&target).status();
opened
.with_context(|| format!("cannot open {}", target.display()))?;
Ok(0)
}
+54
View File
@@ -238,6 +238,60 @@ pub static HELP_SPECS: &[HelpSpec] = &[
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",
+6
View File
@@ -104,11 +104,17 @@ pub fn migrate(app: &App) -> Result<usize> {
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);
+31 -1
View File
@@ -63,8 +63,11 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("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"),
@@ -141,7 +144,7 @@ impl AmCompleter {
Self {
theme: std::cell::Cell::new(crate::theme::default_theme()),
commands: vec![
"list", "status", "sessions", "stats", "log", "history", "init", "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",
@@ -1196,6 +1199,30 @@ 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(),
@@ -1333,8 +1360,11 @@ fn is_am_command(word: &str) -> bool {
| "sessions"
| "stats"
| "log"
| "logs"
| "history"
| "init"
| "alias"
| "open"
| "search"
| "info"
| "install"
+115
View File
@@ -0,0 +1,115 @@
//! Quick wins: logs, list filters, config set, alias, open, history dedup.
mod common;
use agent_manager::commands::{alias_cmd, config_cmd, logs_cmd, open_cmd};
use agent_manager::state::InstalledEntry;
use std::path::PathBuf;
fn installed(app: &agent_manager::app::App, name: &str, dir: &PathBuf) {
let entry = InstalledEntry {
name: name.to_string(),
version: Some("1.0.0".to_string()),
method: "binary".to_string(),
install_dir: dir.display().to_string(),
bins: vec![],
run: name.to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
app.state.set(&entry).unwrap();
}
#[test]
fn list_sorts_and_filters_running() {
use agent_manager::commands::list_cmd;
let app = common::test_app("listqw", &["list", "--all"]);
let dir = app.paths.install_dir.join("x");
installed(&app, "jcode", &dir);
let code = list_cmd::run(&app, true, None, true, None).unwrap();
assert_eq!(code, 0, "--running sur un parc arrete donne une liste vide");
let code = list_cmd::run(&app, true, None, false, Some("version")).unwrap();
assert_eq!(code, 0);
assert!(list_cmd::run(&app, true, None, false, Some("bogus")).is_err());
}
#[test]
fn logs_tail_reads_the_agent_log() {
let app = common::test_app("logs", &["logs", "jcode"]);
let dir = app.paths.log_dir.clone();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("jcode.log"), "ligne 1
ligne 2
").unwrap();
let code = logs_cmd::run(&app, "jcode", 20, false).unwrap();
assert_eq!(code, 0);
assert_eq!(logs_cmd::log_path(&app, "jcode").unwrap(), dir.join("jcode.log"));
assert!(logs_cmd::run(&app, "inconnu", 20, false).is_err());
}
#[test]
fn config_set_writes_and_validates() {
let app = common::test_app("cfgset", &["config", "set", "settings.default_shell", "pwsh"]);
let code = config_cmd::set_value(&app, "settings.default_shell", "pwsh").unwrap();
assert_eq!(code, 0);
let cfg_path = app.paths.config_dir.as_ref().unwrap().join("config.yaml");
let text = std::fs::read_to_string(&cfg_path).unwrap();
assert!(text.contains("pwsh"), "cle ecrite: {text}");
// Une cle inconnue du schema est rejetee a la validation.
assert!(config_cmd::set_value(&app, "settings.cle_inconnue", "x").is_err());
}
#[test]
fn alias_add_list_remove() {
let app = common::test_app("alias", &["alias", "add", "cc", "claude-code"]);
let code = alias_cmd::run(&app, &agent_manager::cli::AliasCmd::Add {
name: "cc".to_string(),
target: "claude-code".to_string(),
})
.unwrap();
assert_eq!(code, 0);
let cfg_path = app.paths.config_dir.as_ref().unwrap().join("config.yaml");
let text = std::fs::read_to_string(&cfg_path).unwrap();
assert!(text.contains("cc: claude-code"), "alias ecrit: {text}");
let code = alias_cmd::run(&app, &agent_manager::cli::AliasCmd::List).unwrap();
assert_eq!(code, 0);
let code = alias_cmd::run(&app, &agent_manager::cli::AliasCmd::Remove {
name: "cc".to_string(),
})
.unwrap();
assert_eq!(code, 0);
// Cible inconnue refuse.
assert!(alias_cmd::run(
&app,
&agent_manager::cli::AliasCmd::Add {
name: "zz".to_string(),
target: "agent-inconnu".to_string(),
}
)
.is_err());
}
#[test]
fn open_resolves_install_dir() {
let app = common::test_app("open", &["open", "jcode"]);
let dir = app.paths.install_dir.join("jcode");
std::fs::create_dir_all(&dir).unwrap();
installed(&app, "jcode", &dir);
let target = open_cmd::resolve_target(&app, "jcode").unwrap();
assert_eq!(target, dir);
assert!(open_cmd::resolve_target(&app, "inconnu").is_err());
}
#[test]
fn history_migration_dedups_consecutive() {
use agent_manager::history;
let app = common::test_app("dedup", &["history"]);
let txt = app.paths.state_file.with_file_name("history.txt");
std::fs::write(&txt, "install jcode
install jcode
list
").unwrap();
assert_eq!(history::migrate(&app).unwrap(), 2, "doublons consecutifs sautes");
}