- 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
437 lines
14 KiB
Rust
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()));
|
|
}
|
|
}
|