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:
+51
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user