quick wins #9-#13 et #16 : logs, list --sort, config set, alias, dedup, open #24

Merged
bruno merged 1 commits from feat/quick-wins into main 2026-08-16 22:45:27 -04:00
11 changed files with 545 additions and 4 deletions
Showing only changes of commit d4a6aeae1d - Show all commits
+51
View File
@@ -68,7 +68,32 @@ pub enum Command {
/// Only show agents of this category /// Only show agents of this category
#[arg(long, value_name = "CATEGORY")] #[arg(long, value_name = "CATEGORY")]
category: Option<String>, category: Option<String>,
/// Only show running agents
#[arg(long)]
running: bool,
/// Sort by name, version or status (default: name)
#[arg(long, value_name = "KEY")]
sort: Option<String>,
}, },
/// Tail the log file of an agent (--follow for a live view)
Logs {
/// Agent name or alias
agent: String,
/// Number of lines to show (default: 20)
#[arg(long, value_name = "N", default_value = "20")]
lines: usize,
/// Follow the log as it grows
#[arg(long)]
follow: bool,
},
/// Open an agent's installation directory in the file manager
Open {
/// Agent name or alias
agent: String,
},
/// Manage command aliases (cc -> claude-code)
#[command(subcommand)]
Alias(AliasCmd),
/// Start an agent (foreground by default, or detached with --background) /// Start an agent (foreground by default, or detached with --background)
Start(StartArgs), Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout) /// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
@@ -284,6 +309,25 @@ pub struct StartArgs {
pub notify: bool, pub notify: bool,
} }
/// Alias management subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum AliasCmd {
/// Add an alias (am alias add cc claude-code)
Add {
/// Short name of the alias
name: String,
/// Agent name (or another alias) it points to
target: String,
},
/// Remove an alias
Remove {
/// Alias to remove
name: String,
},
/// List every alias
List,
}
/// Configuration management subcommands. /// Configuration management subcommands.
#[derive(Subcommand, Debug, Clone)] #[derive(Subcommand, Debug, Clone)]
pub enum ConfigCmd { pub enum ConfigCmd {
@@ -300,4 +344,11 @@ pub enum ConfigCmd {
/// YAML (or JSON) file defining agents to include /// YAML (or JSON) file defining agents to include
file: PathBuf, file: PathBuf,
}, },
/// Set one configuration key without an editor (config set settings.default_shell pwsh)
Set {
/// Dotted key path, e.g. settings.default_shell
key: String,
/// New value (booleans and integers keep their type)
value: String,
},
} }
+103
View File
@@ -0,0 +1,103 @@
//! alias: manage command aliases (cc -> claude-code) in the user config.
use super::*;
use crate::cli::AliasCmd;
use crate::output::print_json;
use anyhow::{anyhow, Context, Result};
use std::path::PathBuf;
/// Ensure the user config file exists and return its path.
fn user_config(app: &App) -> Result<PathBuf> {
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)?;
let path = dir.join(crate::config::CONFIG_FILE_NAME);
if !path.exists() {
std::fs::write(&path, concat!("version: \"1.0\"\n", "agents: []\n"))
.with_context(|| format!("cannot create {}", path.display()))?;
}
Ok(path)
}
/// Read the aliases map from the user config file.
fn read_aliases(app: &App) -> Result<(PathBuf, serde_yaml::Mapping)> {
let path = user_config(app)?;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?;
let mapping = value
.as_mapping_mut()
.ok_or_else(|| anyhow!("{} must contain a YAML mapping", path.display()))?;
let aliases = mapping
.entry(serde_yaml::Value::String("aliases".to_string()))
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()))
.as_mapping()
.cloned()
.unwrap_or_default();
Ok((path, aliases))
}
fn write_aliases(app: &App, path: &std::path::Path, aliases: serde_yaml::Mapping) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?;
if let Some(mapping) = value.as_mapping_mut() {
mapping.insert(
serde_yaml::Value::String("aliases".to_string()),
serde_yaml::Value::Mapping(aliases),
);
}
std::fs::write(path, serde_yaml::to_string(&value)?)
.with_context(|| format!("cannot write {}", path.display()))
}
pub fn run(app: &App, sub: &AliasCmd) -> Result<i32> {
match sub {
AliasCmd::Add { name, target } => {
// The target must be a known agent (or another alias).
require_agent(app, target)?;
let (path, mut aliases) = read_aliases(app)?;
aliases.insert(
serde_yaml::Value::String(name.clone()),
serde_yaml::Value::String(target.clone()),
);
write_aliases(app, &path, aliases)?;
app.log.success(&format!("alias '{name}' -> '{target}'"));
Ok(0)
}
AliasCmd::Remove { name } => {
let (path, mut aliases) = read_aliases(app)?;
let removed = aliases
.remove(&serde_yaml::Value::String(name.clone()))
.is_some();
if removed {
write_aliases(app, &path, aliases)?;
app.log.success(&format!("alias '{name}' removed"));
} else {
app.log.info(&format!("alias '{name}' does not exist"));
}
Ok(0)
}
AliasCmd::List => {
if app.json() {
print_json(&app.config.aliases);
return Ok(0);
}
if app.config.aliases.is_empty() {
app.log.info("no aliases defined — 'am alias add cc claude-code'");
return Ok(0);
}
let mut table = crate::output::Table::new(vec!["ALIAS", "TARGET"]);
for (k, v) in &app.config.aliases {
table.row(vec![k.clone(), v.clone()]);
}
print!("{}", table.render());
Ok(0)
}
}
}
+55
View File
@@ -5,6 +5,7 @@ use crate::cli::ConfigCmd;
use crate::config; use crate::config;
use crate::output::print_json; use crate::output::print_json;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use clap::Parser;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
@@ -94,9 +95,63 @@ pub fn run(app: &App, cmd: &ConfigCmd) -> Result<i32> {
Ok(status.code().unwrap_or(1)) Ok(status.code().unwrap_or(1))
} }
ConfigCmd::Add { file } => add_file(app, file), ConfigCmd::Add { file } => add_file(app, file),
ConfigCmd::Set { key, value } => set_value(app, key, value),
} }
} }
/// config set: set one dotted key (e.g. settings.default_shell) in the user
/// configuration file, then validate the result.
pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
let path = user_config_path(app)?;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut root: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?;
let parts: Vec<&str> = key.split('.').collect();
if parts.is_empty() || parts.iter().any(|p| p.is_empty()) {
return Err(anyhow!("invalid key '{key}' — expected a dotted path like settings.default_shell"));
}
let parsed = parse_scalar(value);
let mut node = root
.as_mapping_mut()
.ok_or_else(|| anyhow!("{} must contain a YAML mapping", path.display()))?;
for part in &parts[..parts.len() - 1] {
let entry = node
.entry(serde_yaml::Value::String(part.to_string()))
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()));
node = entry
.as_mapping_mut()
.ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?;
}
node.insert(
serde_yaml::Value::String(parts.last().unwrap().to_string()),
parsed,
);
std::fs::write(&path, serde_yaml::to_string(&root)?)
.with_context(|| format!("cannot write {}", path.display()))?;
// Validate the new configuration end to end.
let cli = crate::cli::Cli::parse_from(["am", "--config", path.to_str().unwrap_or_default()]);
config::load(&cli).map_err(|e| {
anyhow!("the new value makes the configuration invalid: {e:#}")
})?;
app.log.success(&format!("{key} = {value}"));
Ok(0)
}
/// Parse a scalar: booleans and integers keep their type, everything else
/// stays a string.
fn parse_scalar(value: &str) -> serde_yaml::Value {
if let Ok(b) = value.parse::<bool>() {
return serde_yaml::Value::Bool(b);
}
if let Ok(i) = value.parse::<i64>() {
return serde_yaml::Value::Number(i.into());
}
serde_yaml::Value::String(value.to_string())
}
fn user_config_path_if_any(app: &App) -> Option<PathBuf> { fn user_config_path_if_any(app: &App) -> Option<PathBuf> {
app.paths app.paths
.config_dir .config_dir
+16 -2
View File
@@ -25,7 +25,13 @@ struct AgentRow {
} }
/// run: installed agents only, unless all is set (whole catalog). /// run: installed agents only, unless all is set (whole catalog).
pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> { pub fn run(
app: &App,
all: bool,
category: Option<&str>,
running: bool,
sort: Option<&str>,
) -> Result<i32> {
let entries = app.state.all().unwrap_or_default(); let entries = app.state.all().unwrap_or_default();
let probe = crate::probe::detect_externals(app); let probe = crate::probe::detect_externals(app);
let mut rows: Vec<AgentRow> = Vec::new(); let mut rows: Vec<AgentRow> = Vec::new();
@@ -43,7 +49,15 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
if !all { if !all {
rows.retain(|r| r.status == "running" || r.status == "installed" || r.status == "external"); rows.retain(|r| r.status == "running" || r.status == "installed" || r.status == "external");
} }
rows.sort_by(|a, b| a.name.cmp(&b.name)); if running {
rows.retain(|r| r.status == "running");
}
match sort {
Some("version") => rows.sort_by(|a, b| b.version.cmp(&a.version)),
Some("status") => rows.sort_by(|a, b| a.status.cmp(&b.status)),
Some(other) => bail!("invalid sort key '{other}' — expected name, version or status"),
None => rows.sort_by(|a, b| a.name.cmp(&b.name)),
}
if app.json() { if app.json() {
print_json(&rows); print_json(&rows);
+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. //! Command implementations and dispatch.
pub mod alias_cmd;
pub mod completion_cmd; pub mod completion_cmd;
pub mod config_cmd; pub mod config_cmd;
pub mod doctor_cmd; pub mod doctor_cmd;
@@ -11,6 +12,8 @@ pub mod init_cmd;
pub mod install_cmd; pub mod install_cmd;
pub mod list_cmd; pub mod list_cmd;
pub mod log_cmd; pub mod log_cmd;
pub mod logs_cmd;
pub mod open_cmd;
pub mod run_cmd; pub mod run_cmd;
pub mod search_cmd; pub mod search_cmd;
pub mod self_uninstall; pub mod self_uninstall;
@@ -40,7 +43,19 @@ pub fn execute(app: &App) -> Result<i32> {
/// Execute one parsed command (also used by the interactive shell). /// Execute one parsed command (also used by the interactive shell).
pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> { pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
match cmd { match cmd {
Command::List { all, category } => list_cmd::run(app, *all, category.as_deref()), Command::List {
all,
category,
running,
sort,
} => list_cmd::run(app, *all, category.as_deref(), *running, sort.as_deref()),
Command::Logs {
agent,
lines,
follow,
} => logs_cmd::run(app, agent, *lines, *follow),
Command::Open { agent } => open_cmd::run(app, agent),
Command::Alias(sub) => alias_cmd::run(app, sub),
Command::Start(a) => run_cmd::start(app, a), Command::Start(a) => run_cmd::start(app, a),
Command::Stop { Command::Stop {
agent, agent,
+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" }, 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 { HelpSpec {
name: "history", name: "history",
category: "Commands", category: "Commands",
+6
View File
@@ -104,11 +104,17 @@ pub fn migrate(app: &App) -> Result<usize> {
let text = std::fs::read_to_string(&txt) let text = std::fs::read_to_string(&txt)
.with_context(|| format!("cannot read {}", txt.display()))?; .with_context(|| format!("cannot read {}", txt.display()))?;
let mut count = 0; let mut count = 0;
let mut previous: Option<String> = None;
for line in text.lines() { for line in text.lines() {
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') { if trimmed.is_empty() || trimmed.starts_with('#') {
continue; 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()) let entry = HistoryEntry::now(trimmed.to_string())
.with_kind("am".to_string()) .with_kind("am".to_string())
.with_cwd(None); .with_cwd(None);
+31 -1
View File
@@ -63,8 +63,11 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("sessions", "list agent sessions"), ("sessions", "list agent sessions"),
("stats", "usage statistics"), ("stats", "usage statistics"),
("log", "read the event journal"), ("log", "read the event journal"),
("logs", "tail an agent log"),
("history", "search command history"), ("history", "search command history"),
("init", "generate a project config"), ("init", "generate a project config"),
("alias", "manage aliases"),
("open", "open the install dir"),
("search", "search the catalog"), ("search", "search the catalog"),
("info", "agent details"), ("info", "agent details"),
("install", "install an agent"), ("install", "install an agent"),
@@ -141,7 +144,7 @@ impl AmCompleter {
Self { Self {
theme: std::cell::Cell::new(crate::theme::default_theme()), theme: std::cell::Cell::new(crate::theme::default_theme()),
commands: vec![ commands: vec![
"list", "status", "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", "start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "self-update", "self-uninstall", "export", "import", "shell", "theme",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
@@ -1196,6 +1199,30 @@ fn handle_line(
"list" => Command::List { "list" => Command::List {
all: flag("--all"), all: flag("--all"),
category: None, category: None,
running: flag("--running"),
sort: opt_value("--sort"),
},
"logs" => Command::Logs {
agent: need("agent name")?,
lines: opt_value("--lines").and_then(|v| v.parse().ok()).unwrap_or(20),
follow: flag("--follow"),
},
"open" => Command::Open {
agent: need("agent name")?,
},
"alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
name: rest[1].clone(),
target: rest[2].clone(),
}),
Some("remove") if rest.len() >= 2 => Command::Alias(crate::cli::AliasCmd::Remove {
name: rest[1].clone(),
}),
Some("list") | None => Command::Alias(crate::cli::AliasCmd::List),
_ => {
app.log.error("usage: alias add <name> <target> | alias remove <name> | alias list");
return Ok(false);
}
}, },
"status" => Command::Status { "status" => Command::Status {
agent: rest.first().cloned(), agent: rest.first().cloned(),
@@ -1333,8 +1360,11 @@ fn is_am_command(word: &str) -> bool {
| "sessions" | "sessions"
| "stats" | "stats"
| "log" | "log"
| "logs"
| "history" | "history"
| "init" | "init"
| "alias"
| "open"
| "search" | "search"
| "info" | "info"
| "install" | "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");
}