//! Interactive shell launched when am is run without a subcommand. //! //! Uses rustyline for line editing: Tab completion (commands, agents, aliases, //! groups, flags), command history persisted in the state directory, and //! graceful fallback to plain line input when no terminal is available. use crate::app::App; use crate::cli::{Cli, Command, StartArgs}; use crate::commands; use anyhow::{anyhow, Result}; use clap::CommandFactory; use rustyline::completion::{Completer, Pair}; use rustyline::error::ReadlineError; use rustyline::highlight::Highlighter; use rustyline::hint::Hinter; use rustyline::validate::Validator; use rustyline::{Context, Helper}; use std::collections::BTreeMap; use std::io::{IsTerminal, Write}; // --------------------------------------------------------------------------- // Tab completion // --------------------------------------------------------------------------- /// Completion candidates for the interactive shell. pub struct AmCompleter { commands: Vec<&'static str>, config_sub: Vec<&'static str>, shells: Vec<&'static str>, options: Vec<&'static str>, agents: Vec, aliases: Vec, groups: Vec, } impl AmCompleter { pub fn new(app: &App) -> Self { Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups) } pub fn from_catalog( catalog: &crate::catalog::Catalog, aliases: &BTreeMap, groups: &BTreeMap>, ) -> Self { let mut agents: Vec = catalog .agents() .iter() .filter(|a| !a.hidden) .map(|a| a.name.clone()) .collect(); agents.sort(); let mut alias_list: Vec = aliases.keys().cloned().collect(); alias_list.sort(); let mut group_list: Vec = groups.keys().map(|g| format!("group:{g}")).collect(); group_list.sort(); Self { commands: vec![ "list", "status", "search", "info", "install", "uninstall", "update", "start", "stop", "restart", "run", "doctor", "config", "completion", "help", "version", "exit", ], config_sub: vec!["show", "path", "edit", "validate", "add"], shells: vec!["bash", "zsh", "fish", "powershell", "elvish"], options: vec![ "--background", "--force", "--purge", "--method", "--all", "--category", "--args", "--env", "--notify", "--timeout", "--yes", "--verbose", "--quiet", "--json", "--dry-run", "--no-color", ], agents, aliases: alias_list, groups: group_list, } } /// Pure completion logic: given the line up to the cursor, returns the /// replacement start position and the matching candidate strings. pub fn candidates_for(&self, line: &str, pos: usize) -> (usize, Vec) { let before = &line[..pos.min(line.len())]; let start = before .rfind(char::is_whitespace) .map(|i| i + 1) .unwrap_or(0); let word = &before[start..]; let previous: Vec<&str> = before[..start].split_whitespace().collect(); let mut pool: Vec = Vec::new(); if previous.is_empty() { // Completing the command name itself. pool = self.commands.iter().map(|s| s.to_string()).collect(); } else { let first = previous[0]; match first { "config" => { pool = self.config_sub.iter().map(|s| s.to_string()).collect(); } "completion" => { pool = self.shells.iter().map(|s| s.to_string()).collect(); } "install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run" | "info" | "status" => { if word.starts_with("group:") { pool = self.groups.clone(); } else if previous.len() == 1 { pool.extend(self.agents.iter().cloned()); pool.extend(self.aliases.iter().cloned()); pool.extend(self.groups.iter().cloned()); } else if word.starts_with('-') { pool = self.options.iter().map(|s| s.to_string()).collect(); } else { // Extra arguments: only offer flags. pool = self.options.iter().map(|s| s.to_string()).collect(); } } _ => { if word.starts_with('-') { pool = self.options.iter().map(|s| s.to_string()).collect(); } } } } let mut cands: Vec = pool .iter() .filter(|c| c.starts_with(word)) .cloned() .collect(); if cands.is_empty() && !word.is_empty() { let lower = word.to_lowercase(); cands = pool .into_iter() .filter(|c| c.to_lowercase().starts_with(&lower)) .collect(); } else if word.is_empty() { cands = pool; } cands.sort(); cands.dedup(); (start, cands) } } impl Completer for AmCompleter { type Candidate = Pair; fn complete( &self, line: &str, pos: usize, _ctx: &Context<'_>, ) -> rustyline::Result<(usize, Vec)> { let (start, cands) = self.candidates_for(line, pos); let pairs = cands .into_iter() .map(|c| Pair { display: c.clone(), replacement: c, }) .collect(); Ok((start, pairs)) } } impl Hinter for AmCompleter { type Hint = String; fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { None } } impl Highlighter for AmCompleter {} impl Validator for AmCompleter {} impl Helper for AmCompleter {} // --------------------------------------------------------------------------- // Interactive loop // --------------------------------------------------------------------------- pub fn run(app: &App) -> Result { if !std::io::stdin().is_terminal() { // Piped stdin: print the help instead of hanging. let mut cmd = Cli::command(); cmd.print_help()?; println!(); return Ok(2); } match run_with_editor(app) { Ok(code) => Ok(code), Err(e) => { app.log.verbose(&format!( "line editor unavailable ({e:#}); falling back to plain input" )); run_plain(app) } } } fn run_with_editor(app: &App) -> Result { let mut rl: rustyline::Editor = rustyline::Editor::new()?; rl.set_helper(Some(AmCompleter::new(app))); let history_path = app.paths.state_file.with_file_name("history.txt"); if rl.load_history(&history_path).is_err() { // First run: no history yet. } eprintln!( "agent-manager interactive shell — Tab pour compléter, flèches pour l'historique, 'help', 'exit'" ); loop { match rl.readline("am> ") { Ok(line) => { let trimmed = line.trim().to_string(); if trimmed.is_empty() { continue; } let _ = rl.add_history_entry(trimmed.as_str()); match trimmed.as_str() { "exit" | "quit" | "q" => break, "help" | "?" => print_help(), "version" => println!("am {}", env!("CARGO_PKG_VERSION")), _ => { if let Err(e) = handle_line(app, &trimmed) { app.log.error(&format!("{e:#}")); } } } } Err(ReadlineError::Interrupted) => continue, Err(ReadlineError::Eof) => break, Err(e) => { app.log.warn(&format!("input error: {e}")); break; } } } let _ = rl.save_history(&history_path); Ok(0) } /// Plain fallback when no terminal/line editor is available. fn run_plain(app: &App) -> Result { eprintln!("agent-manager interactive shell — type 'help' for commands, 'exit' to quit"); loop { eprint!("am> "); std::io::stderr().flush().ok(); let mut line = String::new(); if std::io::stdin().read_line(&mut line).is_err() { break; } let line = line.trim().to_string(); if line.is_empty() { continue; } match line.as_str() { "exit" | "quit" | "q" => break, "help" | "?" => print_help(), "version" => println!("am {}", env!("CARGO_PKG_VERSION")), _ => { if let Err(e) = handle_line(app, &line) { app.log.error(&format!("{e:#}")); } } } } Ok(0) } fn print_help() { println!("commands:"); println!(" list [--all]"); println!(" status [agent]"); println!(" search "); println!(" info "); println!(" install [--method ] [--force]"); println!(" uninstall [--purge]"); println!(" update | update --all"); println!(" start [--background]"); println!(" stop [--force]"); println!(" restart [--background] [--force]"); println!(" run [args...]"); println!(" doctor [--fix]"); println!(" config show | path | validate | edit"); println!(" help | version | exit"); } fn handle_line(app: &App, line: &str) -> Result<()> { let tokens = shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))?; if tokens.is_empty() { return Ok(()); } let cmd = tokens[0].as_str(); let rest = &tokens[1..]; let need = |what: &str| -> Result { rest.first() .cloned() .ok_or_else(|| anyhow!("missing {what}")) }; let flag = |f: &str| rest.iter().any(|t| t == f); let method_flag = || { rest.iter() .position(|t| t == "--method") .and_then(|i| rest.get(i + 1)) .cloned() }; let command = match cmd { "list" => Command::List { all: flag("--all"), category: None, }, "status" => Command::Status { agent: rest.first().cloned(), }, "search" => Command::Search { keyword: rest.join(" "), category: None, }, "info" => Command::Info { agent: need("agent name")?, }, "start" => Command::Start(StartArgs { agent: need("agent name")?, background: flag("--background") || flag("-b"), ..Default::default() }), "stop" => Command::Stop { agent: need("agent name")?, force: flag("--force"), timeout: None, }, "restart" => Command::Restart { start: StartArgs { agent: need("agent name")?, background: flag("--background") || flag("-b"), ..Default::default() }, force: flag("--force"), timeout: None, }, "install" => Command::Install { agent: need("agent name")?, method: method_flag(), force: flag("--force"), }, "uninstall" => Command::Uninstall { agent: need("agent name")?, purge: flag("--purge"), }, "update" => { if flag("--all") { Command::Update { agent: None, all: true, } } else { Command::Update { agent: rest.first().cloned(), all: false, } } } "doctor" => Command::Doctor { fix: flag("--fix") }, "run" => { let agent = need("agent name")?; Command::Run { agent, args: rest[1..].iter().map(|s| s.as_str().into()).collect(), } } "config" => Command::Config(crate::cli::ConfigCmd::Show), other => { app.log .error(&format!("unknown command '{other}' — type 'help'")); return Ok(()); } }; commands::execute_command(app, &command)?; Ok(()) } #[cfg(test)] mod tests { use super::*; fn completer() -> AmCompleter { let cfg: crate::config::Config = serde_yaml::from_str(crate::config::DEFAULT_CONFIG).unwrap(); let catalog = crate::catalog::Catalog::new( cfg.agents, cfg.aliases.clone(), cfg.groups.clone(), ); AmCompleter::from_catalog(&catalog, &cfg.aliases, &cfg.groups) } #[test] fn completes_commands() { let c = completer(); let (start, cands) = c.candidates_for("sta", 3); assert_eq!(start, 0); assert!(cands.contains(&"start".to_string())); assert!(cands.contains(&"status".to_string())); } #[test] fn completes_agent_names_and_aliases() { let c = completer(); let (start, cands) = c.candidates_for("install claude", 14); assert_eq!(start, 8); assert!(cands.contains(&"claude-code".to_string())); let (_, cands) = c.candidates_for("info cc", 7); assert!(cands.contains(&"cc".to_string())); } #[test] fn completes_group_selectors() { let c = completer(); let (_, cands) = c.candidates_for("start group:d", 13); assert!(!cands.is_empty()); assert!(cands.iter().all(|x| x.starts_with("group:"))); assert!(cands.contains(&"group:dev".to_string())); } #[test] fn completes_flags_after_arguments() { let c = completer(); let (_, cands) = c.candidates_for("install smelt --dr", 18); assert!(cands.contains(&"--dry-run".to_string())); } #[test] fn completes_config_subcommands() { let c = completer(); let (_, cands) = c.candidates_for("config sh", 9); assert!(cands.contains(&"show".to_string())); } }