Files
agent-manager/src/repl.rs
T
bruno 8ee3ddf3db agent-manager v0.2.0 : gestionnaire d'agents IA locaux
- CLI Rust (am) : list/install/uninstall/update/start/stop/restart/run/status/search/info/config/doctor/completion/export/import/self-update + shell interactif (Tab, historique)
- 9 methodes d'installation (npm, pip, uv, cargo, go, bun, curl, binary, git)
- catalogue de 72 agents, dependances, processus, securite (dry-run, checksums)
- sondes de version paralleles + cache (am list ~25 ms a chaud)
- installateurs one-liner : install.sh, install.ps1
2026-08-15 11:26:34 -04:00

437 lines
14 KiB
Rust

//! 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<String>,
aliases: Vec<String>,
groups: Vec<String>,
}
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<String, String>,
groups: &BTreeMap<String, Vec<String>>,
) -> Self {
let mut agents: Vec<String> = catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.map(|a| a.name.clone())
.collect();
agents.sort();
let mut alias_list: Vec<String> = aliases.keys().cloned().collect();
alias_list.sort();
let mut group_list: Vec<String> = 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<String>) {
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<String> = 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<String> = 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<Pair>)> {
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<String> {
None
}
}
impl Highlighter for AmCompleter {}
impl Validator for AmCompleter {}
impl Helper for AmCompleter {}
// ---------------------------------------------------------------------------
// Interactive loop
// ---------------------------------------------------------------------------
pub fn run(app: &App) -> Result<i32> {
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<i32> {
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
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<i32> {
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 <keyword>");
println!(" info <agent>");
println!(" install <agent> [--method <n|type>] [--force]");
println!(" uninstall <agent> [--purge]");
println!(" update <agent> | update --all");
println!(" start <agent> [--background]");
println!(" stop <agent> [--force]");
println!(" restart <agent> [--background] [--force]");
println!(" run <agent> [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<String> {
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()));
}
}