quick wins (issues #9 #10 #11 #12 #13 #16) : am logs (tail+follow), am list --running/--sort, am config set, am alias add/remove/list, dedup historique, am open

This commit is contained in:
2026-08-16 22:45:11 -04:00
parent 38d0729cb3
commit d4a6aeae1d
11 changed files with 545 additions and 4 deletions
+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)
}