From d4a6aeae1d4ef81f7911e79678b012fc8233da51 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 16 Aug 2026 22:45:11 -0400 Subject: [PATCH] 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 --- src/cli.rs | 51 ++++++++++++++++ src/commands/alias_cmd.rs | 103 +++++++++++++++++++++++++++++++++ src/commands/config_cmd.rs | 55 ++++++++++++++++++ src/commands/list_cmd.rs | 18 +++++- src/commands/logs_cmd.rs | 56 ++++++++++++++++++ src/commands/mod.rs | 17 +++++- src/commands/open_cmd.rs | 42 ++++++++++++++ src/help.rs | 54 +++++++++++++++++ src/history.rs | 6 ++ src/repl.rs | 32 ++++++++++- tests/quick_wins_test.rs | 115 +++++++++++++++++++++++++++++++++++++ 11 files changed, 545 insertions(+), 4 deletions(-) create mode 100644 src/commands/alias_cmd.rs create mode 100644 src/commands/logs_cmd.rs create mode 100644 src/commands/open_cmd.rs create mode 100644 tests/quick_wins_test.rs diff --git a/src/cli.rs b/src/cli.rs index c97890e..043f79a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -68,7 +68,32 @@ pub enum Command { /// Only show agents of this category #[arg(long, value_name = "CATEGORY")] category: Option, + /// Only show running agents + #[arg(long)] + running: bool, + /// Sort by name, version or status (default: name) + #[arg(long, value_name = "KEY")] + sort: Option, }, + /// 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, + }, } diff --git a/src/commands/alias_cmd.rs b/src/commands/alias_cmd.rs new file mode 100644 index 0000000..1c093d9 --- /dev/null +++ b/src/commands/alias_cmd.rs @@ -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 { + 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 { + 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) + } + } +} diff --git a/src/commands/config_cmd.rs b/src/commands/config_cmd.rs index ed51481..8ccbe00 100644 --- a/src/commands/config_cmd.rs +++ b/src/commands/config_cmd.rs @@ -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 { 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 { + 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::() { + return serde_yaml::Value::Bool(b); + } + if let Ok(i) = value.parse::() { + return serde_yaml::Value::Number(i.into()); + } + serde_yaml::Value::String(value.to_string()) +} + fn user_config_path_if_any(app: &App) -> Option { app.paths .config_dir diff --git a/src/commands/list_cmd.rs b/src/commands/list_cmd.rs index 63880f3..240594e 100644 --- a/src/commands/list_cmd.rs +++ b/src/commands/list_cmd.rs @@ -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 { +pub fn run( + app: &App, + all: bool, + category: Option<&str>, + running: bool, + sort: Option<&str>, +) -> Result { let entries = app.state.all().unwrap_or_default(); let probe = crate::probe::detect_externals(app); let mut rows: Vec = Vec::new(); @@ -43,7 +49,15 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result { 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); diff --git a/src/commands/logs_cmd.rs b/src/commands/logs_cmd.rs new file mode 100644 index 0000000..73d4327 --- /dev/null +++ b/src/commands/logs_cmd.rs @@ -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 { + 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 { + 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)); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 75d5684..e921759 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -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 { /// Execute one parsed command (also used by the interactive shell). pub fn execute_command(app: &App, cmd: &Command) -> Result { 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, diff --git a/src/commands/open_cmd.rs b/src/commands/open_cmd.rs new file mode 100644 index 0000000..0d2fa5e --- /dev/null +++ b/src/commands/open_cmd.rs @@ -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 { + 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 { + 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) +} diff --git a/src/help.rs b/src/help.rs index 4610a16..3aee3d0 100644 --- a/src/help.rs +++ b/src/help.rs @@ -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} ", + 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} ", + 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} [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", diff --git a/src/history.rs b/src/history.rs index b93d83f..6d4ab20 100644 --- a/src/history.rs +++ b/src/history.rs @@ -104,11 +104,17 @@ pub fn migrate(app: &App) -> Result { let text = std::fs::read_to_string(&txt) .with_context(|| format!("cannot read {}", txt.display()))?; let mut count = 0; + let mut previous: Option = 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); diff --git a/src/repl.rs b/src/repl.rs index 16c8ae7..e90130b 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -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 | alias remove | 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" diff --git a/tests/quick_wins_test.rs b/tests/quick_wins_test.rs new file mode 100644 index 0000000..d9e8f67 --- /dev/null +++ b/tests/quick_wins_test.rs @@ -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"); +} -- 2.54.0