F1 — Wizard d'onboarding (src/setup.rs) : - am setup : provider (anthropic, openai, deepseek, google, ollama, custom), token masque (rpassword) -> keyring OS (jamais en clair), modele par defaut (liste declaree + champ libre), ping API non-bloquant - Declencheurs non-bloquants : am ai / am ask et banner REPL proposent am setup quand aucun provider n'est configure ; re-executable (mode revoir) - settings.default_provider + providers.* ecrits via persist_setting F2 — Integration aichat : - Etape wizard 'Installer le moteur IA (aichat) ? [Y/n]' -> am install aichat - am ai sans aichat -> 'aichat est manquant. Installer ? [Y/n]' - install.ps1/install.sh enchainent sur am setup si stdin interactif F3 — Roles copilot aichat (src/roles.rs) : - 6 agents generes dans ~/.config/aichat/agents (ou %APPDATA%\aichat\agents) : am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator - Prompts bilingues (FR defaut) privilegiant les contrats --json ; le prompt am-copilot embarque la liste des commandes am - am ai --role <nom> (valide le fichier genere, passe --agent a aichat), completion REPL ; config aichat creee seulement si absente - Câblage complet : cli, dispatch, help, REPL, tip, man (am-setup.1), i18n Tests : 450 verts (roles + setup unitaires, ai_test ajustes locale-agnostic) v1.1.0 ; ROADMAP axe 14 + maintenance note ; README Premier lancement
2815 lines
101 KiB
Rust
2815 lines
101 KiB
Rust
//! Interactive shell launched when am is run without a subcommand.
|
||
//!
|
||
//! The prompt is a Hermes-style gateway between the user and two worlds:
|
||
//! the am commands (install, start, stop, ...) and the system shell. Every
|
||
//! input that is not an am command is executed as a system command by the
|
||
//! selected shell ('shell <name>' switches it), so the prompt doubles as a
|
||
//! pass-through to pwsh, cmd, bash, zsh, ... The user's default shell is
|
||
//! detected at startup and highlighted in the banner and the status line.
|
||
//!
|
||
//! Uses reedline (Nushell's line editor) for editing: Nushell-style Tab
|
||
//! completion — a single match is inserted immediately, several matches open
|
||
//! an interactive menu under the prompt with the first choice highlighted;
|
||
//! further Tabs move the highlight to the next choice (Enter validates, Esc
|
||
//! closes, arrows navigate, typing filters live). Command history is persisted
|
||
//! in the state directory, and there is a graceful fallback to plain line
|
||
//! input when no terminal is available.
|
||
|
||
use crate::app::App;
|
||
use crate::cli::{Command, StartArgs};
|
||
use crate::commands;
|
||
use crate::events::{Event, EventKind};
|
||
use crate::nav;
|
||
use crate::ps;
|
||
use crate::tables::DataTable;
|
||
use crate::shell::ShellSession;
|
||
use crate::theme::Theme;
|
||
use anyhow::{anyhow, Result};
|
||
use colored::Colorize;
|
||
use nu_ansi_term::{Color, Style};
|
||
use reedline::{
|
||
default_emacs_keybindings, ColumnarMenu, Completer, Emacs, FileBackedHistory, KeyCode,
|
||
KeyModifiers, MenuBuilder, MenuTextStyle, Prompt, PromptEditMode, PromptHistorySearch,
|
||
Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion,
|
||
};
|
||
use std::borrow::Cow;
|
||
use std::collections::{BTreeMap, HashSet};
|
||
use std::io::{IsTerminal, Write};
|
||
use std::path::PathBuf;
|
||
use std::time::{Duration, Instant};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tab completion
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Completion candidates for the interactive shell.
|
||
pub struct AmCompleter {
|
||
commands: Vec<&'static str>,
|
||
config_sub: Vec<&'static str>,
|
||
shells: Vec<&'static str>,
|
||
gateway_shells: Vec<&'static str>,
|
||
options: Vec<&'static str>,
|
||
agents: Vec<String>,
|
||
/// Whole catalog (install/info/search only).
|
||
catalog_agents: Vec<String>,
|
||
aliases: Vec<String>,
|
||
groups: Vec<String>,
|
||
/// 'profile' subcommands.
|
||
profile_sub: Vec<&'static str>,
|
||
/// 'catalog' subcommands (issues #60 #67).
|
||
catalog_sub: Vec<&'static str>,
|
||
/// 'providers' subcommands (issue #88).
|
||
providers_sub: Vec<&'static str>,
|
||
/// Short descriptions shown next to candidates in the Tab menu
|
||
/// (Nushell-style).
|
||
descriptions: BTreeMap<String, String>,
|
||
}
|
||
|
||
/// One-line descriptions for the Tab completion menu.
|
||
const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||
("list", "list installed agents"),
|
||
("status", "show agent state"),
|
||
("sessions", "list agent sessions"),
|
||
("stats", "usage statistics"),
|
||
("top", "most used agents"),
|
||
("report", "activity report"),
|
||
("projects", "project activity"),
|
||
("timeline", "unified activity view"),
|
||
("log", "read the event journal"),
|
||
("logs", "tail an agent log"),
|
||
("history", "search command history"),
|
||
("init", "generate a project config"),
|
||
("alias", "manage aliases"),
|
||
("secret", "manage secrets"),
|
||
("open", "open the install dir"),
|
||
("watch", "supervise an agent"),
|
||
("search", "search the catalog"),
|
||
("info", "agent details"),
|
||
("install", "install an agent"),
|
||
("uninstall", "remove an agent"),
|
||
("update", "update agents"),
|
||
("start", "start an agent"),
|
||
("stop", "stop an agent"),
|
||
("restart", "restart an agent"),
|
||
("run", "run an agent command"),
|
||
("doctor", "check the environment"),
|
||
("config", "manage the configuration"),
|
||
("completion", "generate a completion script"),
|
||
("self-update", "update agent-manager"),
|
||
("self-uninstall", "remove agent-manager"),
|
||
("export", "backup config + state"),
|
||
("import", "restore a backup"),
|
||
("dashboard", "live TUI dashboard of the fleet"),
|
||
("favorite", "mark an agent as a favorite"),
|
||
("unfavorite", "remove the favorite star"),
|
||
("note", "attach a note to an agent"),
|
||
("tag", "tag an agent (personal tags)"),
|
||
("untag", "remove a personal tag"),
|
||
("tags", "list personal tags"),
|
||
("profile", "manage environment profiles"),
|
||
("man", "readable man page in the terminal"),
|
||
("models", "list local models (ollama, llama.cpp, LM Studio)"),
|
||
("catalog", "manage remote catalogs"),
|
||
("registry", "publish, search and install agent catalogs (Gitea)"),
|
||
("providers", "manage the LLM provider registry (base URLs, models, default)"),
|
||
("suggest", "recommend an agent for a request"),
|
||
("ask", "natural language → am commands (local rules first, optional LLM refinement)"),
|
||
("ai", "natural language → shell action via AIChat (--exec to run, dry-run by default)"),
|
||
("setup", "onboarding wizard: provider, token, default model, aichat, copilot roles"),
|
||
("audit", "who changed what, when (config checksums)"),
|
||
("service", "register an agent as a system service (autostart)"),
|
||
("schedule", "plan am commands (daily) and check the fleet health"),
|
||
("monitor", "real-time TUI of managed processes (cpu/rss/uptime)"),
|
||
("sync", "push the state (journal, sessions, config) into a git repo"),
|
||
("migrate", "export/import a machine transfer bundle (config + state)"),
|
||
("playbook", "replay a saved history sequence step by step (am history --save)"),
|
||
("lab", "benchmark the same task on several agents (duration, cost, --json)"),
|
||
("web", "local read-only web dashboard with charts (issue #54)"),
|
||
("serve", "authenticated HTTP + WebSocket API to drive am remotely (issue #80)"),
|
||
("plugins", "list the event plugins and test one (JSON contract on stdin/stdout)"),
|
||
("shell", "show or switch the system shell"),
|
||
("theme", "show or switch the color theme"),
|
||
("lang", "show or switch the output language (fr/en)"),
|
||
("tip", "cheat sheet of the most useful commands"),
|
||
("ls", "list files and directories (Nushell-style)"),
|
||
("dir", "list files and directories (same as ls)"),
|
||
("cd", "change directory — Tab completes folders"),
|
||
("ps", "list processes"),
|
||
("where", "filter the last table — where size > 1mb"),
|
||
("get", "select a column — get name"),
|
||
("help", "show this help"),
|
||
("version", "print the version"),
|
||
("exit", "quit the shell"),
|
||
];
|
||
|
||
impl AmCompleter {
|
||
pub fn new(app: &App) -> Self {
|
||
// Issue #41: Tab proposes the installed agents (managed + external),
|
||
// aliases and groups — not the whole catalog.
|
||
let installed: std::collections::BTreeSet<String> = app
|
||
.state
|
||
.all()
|
||
.unwrap_or_default()
|
||
.keys()
|
||
.cloned()
|
||
.collect();
|
||
let mut completer = Self::from_catalog(
|
||
&app.catalog,
|
||
&app.config.aliases,
|
||
&app.config.groups,
|
||
Some(&installed),
|
||
);
|
||
// External agents found on PATH are launchable too.
|
||
let probe = crate::probe::detect_externals(app);
|
||
for a in app.catalog.agents() {
|
||
if probe.path_of(&a.name).is_some() {
|
||
completer.agents.push(a.name.clone());
|
||
}
|
||
}
|
||
completer.agents.sort();
|
||
completer.agents.dedup();
|
||
|
||
// Issue #41 follow-up: only propose aliases and groups whose target is
|
||
// actually launchable (installed managed or external on PATH).
|
||
let launchable: HashSet<String> = completer.agents.iter().cloned().collect();
|
||
completer
|
||
.aliases
|
||
.retain(|alias| match app.catalog.resolve(alias) {
|
||
Some(agent) => launchable.contains(&agent.name),
|
||
None => {
|
||
// Fallback: alias target is not a known agent name, but
|
||
// points to a launchable binary recorded in the catalog.
|
||
app.catalog
|
||
.alias_target(alias)
|
||
.map(|target| launchable.contains(target))
|
||
.unwrap_or(false)
|
||
}
|
||
});
|
||
completer.groups.retain(|group| {
|
||
let name = group.strip_prefix("group:").unwrap_or(group);
|
||
app.catalog
|
||
.group_members(name)
|
||
.iter()
|
||
.any(|agent| launchable.contains(&agent.name))
|
||
});
|
||
completer
|
||
}
|
||
|
||
pub fn from_catalog(
|
||
catalog: &crate::catalog::Catalog,
|
||
aliases: &BTreeMap<String, String>,
|
||
groups: &BTreeMap<String, Vec<String>>,
|
||
installed: Option<&std::collections::BTreeSet<String>>,
|
||
) -> Self {
|
||
let mut agents: Vec<String> = catalog
|
||
.agents()
|
||
.iter()
|
||
.filter(|a| !a.hidden)
|
||
.filter(|a| installed.map(|set| set.contains(&a.name)).unwrap_or(true))
|
||
.map(|a| a.name.clone())
|
||
.collect();
|
||
agents.sort();
|
||
// The whole catalog stays available for install/info/search.
|
||
let catalog_agents: Vec<String> = catalog
|
||
.agents()
|
||
.iter()
|
||
.filter(|a| !a.hidden)
|
||
.map(|a| a.name.clone())
|
||
.collect();
|
||
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();
|
||
let mut descriptions: BTreeMap<String, String> = BTreeMap::new();
|
||
for (cmd, desc) in COMMAND_DESCRIPTIONS {
|
||
descriptions.insert((*cmd).to_string(), (*desc).to_string());
|
||
}
|
||
for a in catalog.agents() {
|
||
if a.hidden {
|
||
continue;
|
||
}
|
||
let desc = a
|
||
.category
|
||
.clone()
|
||
.or_else(|| a.description.clone())
|
||
.unwrap_or_default();
|
||
descriptions.insert(a.name.clone(), truncate_desc(&desc));
|
||
}
|
||
Self {
|
||
commands: vec![
|
||
"list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "secret", "open", "watch", "search", "info", "install", "uninstall", "update",
|
||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
|
||
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
|
||
"profile", "man", "models", "catalog", "providers", "suggest", "ask", "ai", "setup", "registry", "audit",
|
||
"service", "schedule", "monitor", "web", "serve", "sync", "migrate", "playbook", "lab", "plugins",
|
||
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
||
],
|
||
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
||
profile_sub: vec!["list", "show"],
|
||
catalog_sub: vec!["update", "add", "list"],
|
||
providers_sub: vec!["list", "show", "add", "remove", "default", "set-token", "token"],
|
||
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
|
||
gateway_shells: vec![
|
||
"list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu",
|
||
"elvish",
|
||
],
|
||
options: vec![
|
||
"--background", "--force", "--purge", "--method", "--all",
|
||
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
|
||
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
|
||
"--profile", "--installed", "--tag", "--output", "--random",
|
||
"--model", "--provider", "--no-config",
|
||
],
|
||
agents,
|
||
catalog_agents,
|
||
aliases: alias_list,
|
||
groups: group_list,
|
||
descriptions,
|
||
}
|
||
}
|
||
|
||
/// 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();
|
||
}
|
||
"profile" => {
|
||
pool = self.profile_sub.iter().map(|s| s.to_string()).collect();
|
||
}
|
||
"catalog" => {
|
||
pool = self.catalog_sub.iter().map(|s| s.to_string()).collect();
|
||
}
|
||
"providers" => {
|
||
pool = self.providers_sub.iter().map(|s| s.to_string()).collect();
|
||
}
|
||
"completion" => {
|
||
pool = self.shells.iter().map(|s| s.to_string()).collect();
|
||
}
|
||
"shell" => {
|
||
pool = self.gateway_shells.iter().map(|s| s.to_string()).collect();
|
||
}
|
||
"theme" => {
|
||
pool = crate::theme::names()
|
||
.iter()
|
||
.map(|s| s.to_string())
|
||
.collect();
|
||
}
|
||
"lang" => {
|
||
pool = vec!["fr".to_string(), "en".to_string()];
|
||
}
|
||
"cd" | "chdir" => {
|
||
// Complete directories only — dive one level per Tab.
|
||
let cwd = current_dir();
|
||
pool = crate::nav::path_candidates(&cwd, word, true);
|
||
}
|
||
"ls" | "dir" => {
|
||
// Complete paths (files and directories).
|
||
let cwd = current_dir();
|
||
pool = crate::nav::path_candidates(&cwd, word, false);
|
||
}
|
||
// Issue #41: operational commands propose installed agents,
|
||
// aliases and groups only.
|
||
"start" | "stop" | "restart" | "run" | "status" | "logs" | "open"
|
||
| "watch" | "favorite" | "unfavorite" | "note" | "tag" | "untag"
|
||
| "sessions" | "stats" | "log" | "update" | "uninstall" => {
|
||
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();
|
||
}
|
||
}
|
||
"install" | "info" | "search" => {
|
||
if word.starts_with('-') {
|
||
pool = self.options.iter().map(|s| s.to_string()).collect();
|
||
} else if previous.len() == 1 {
|
||
pool = self.catalog_agents.clone();
|
||
pool.extend(self.aliases.iter().cloned());
|
||
} else {
|
||
pool = self.options.iter().map(|s| s.to_string()).collect();
|
||
}
|
||
}
|
||
_ => {
|
||
if word.starts_with('-') {
|
||
pool = self.options.iter().map(|s| s.to_string()).collect();
|
||
} else {
|
||
// Nushell fallback: complete file paths for external
|
||
// (system) commands when nothing else applies.
|
||
let cwd = current_dir();
|
||
pool = crate::nav::path_candidates(&cwd, word, false);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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 {
|
||
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
|
||
let (start, cands) = self.candidates_for(line, pos);
|
||
cands
|
||
.into_iter()
|
||
.map(|c| Suggestion {
|
||
value: c.clone(),
|
||
description: self.descriptions.get(&c).cloned(),
|
||
span: Span::new(start, pos),
|
||
..Default::default()
|
||
})
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Line editor (reedline) and Nushell-style completion menu
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The REPL prompt, unchanged from the rustyline era. While a completion
|
||
/// menu is open, reedline replaces the (empty) indicator with the menu
|
||
/// marker "| ", like Nushell.
|
||
struct AmPrompt;
|
||
|
||
impl Prompt for AmPrompt {
|
||
fn render_prompt_left(&self) -> Cow<'_, str> {
|
||
Cow::Borrowed("❯ ")
|
||
}
|
||
|
||
fn render_prompt_right(&self) -> Cow<'_, str> {
|
||
Cow::Borrowed("")
|
||
}
|
||
|
||
fn render_prompt_indicator(&self, _mode: PromptEditMode) -> Cow<'_, str> {
|
||
Cow::Borrowed("")
|
||
}
|
||
|
||
fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
|
||
Cow::Borrowed(" ")
|
||
}
|
||
|
||
fn render_prompt_history_search_indicator(
|
||
&self,
|
||
_search: PromptHistorySearch,
|
||
) -> Cow<'_, str> {
|
||
Cow::Borrowed("(reverse-i-search)'")
|
||
}
|
||
}
|
||
|
||
/// Map an ANSI basic color code (30-37, 90-97) onto the nu-ansi-term
|
||
/// named palette.
|
||
fn basic_color(n: u8) -> Option<Color> {
|
||
match n {
|
||
30 => Some(Color::Black),
|
||
31 => Some(Color::Red),
|
||
32 => Some(Color::Green),
|
||
33 => Some(Color::Yellow),
|
||
34 => Some(Color::Blue),
|
||
35 => Some(Color::Purple),
|
||
36 => Some(Color::Cyan),
|
||
37 => Some(Color::White),
|
||
90 => Some(Color::DarkGray),
|
||
91 => Some(Color::LightRed),
|
||
92 => Some(Color::LightGreen),
|
||
93 => Some(Color::LightYellow),
|
||
94 => Some(Color::LightBlue),
|
||
95 => Some(Color::LightPurple),
|
||
96 => Some(Color::LightCyan),
|
||
97 => Some(Color::LightGray),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Map one theme SGR string ("1;38;5;81") onto a nu-ansi-term Style so the
|
||
/// completion menu can follow the active theme. Unknown parameters are
|
||
/// ignored; the empty string (mono theme) yields the default style.
|
||
fn sgr_to_style(sgr: &str) -> Style {
|
||
let toks: Vec<&str> = sgr.split(';').collect();
|
||
let mut style = Style::default();
|
||
let mut i = 0;
|
||
while i < toks.len() {
|
||
match toks[i] {
|
||
"1" => style = style.bold(),
|
||
"2" => style = style.dimmed(),
|
||
"3" => style = style.italic(),
|
||
"4" => style = style.underline(),
|
||
"7" => style = style.reverse(),
|
||
"38" if toks.get(i + 1) == Some(&"5") => {
|
||
if let Some(n) = toks.get(i + 2).and_then(|t| t.parse::<u8>().ok()) {
|
||
style = style.fg(Color::Fixed(n));
|
||
}
|
||
i += 2;
|
||
}
|
||
"39" => style = style.fg(Color::Default),
|
||
other => {
|
||
if let Some(c) = other.parse::<u8>().ok().and_then(basic_color) {
|
||
style = style.fg(c);
|
||
}
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
style
|
||
}
|
||
|
||
/// Completion menu colors derived from the active theme: the highlighted
|
||
/// choice is the theme accent in reverse video (like Nushell's default),
|
||
/// the other choices are dimmed and the descriptions use the info color.
|
||
/// With the mono theme the accent is plain, so only the reverse-video
|
||
/// highlight remains.
|
||
fn menu_text_style(theme: &crate::theme::Theme, color: bool) -> MenuTextStyle {
|
||
let accent = if color {
|
||
sgr_to_style(theme.accent)
|
||
} else {
|
||
Style::default()
|
||
};
|
||
MenuTextStyle {
|
||
selected_text_style: accent.reverse(),
|
||
text_style: if color {
|
||
sgr_to_style(theme.dim)
|
||
} else {
|
||
Style::default()
|
||
},
|
||
description_style: if color {
|
||
sgr_to_style(theme.info)
|
||
} else {
|
||
Style::default()
|
||
},
|
||
selected_match_style: accent.reverse().underline(),
|
||
match_style: Style::default().underline(),
|
||
}
|
||
}
|
||
|
||
/// Build the reedline line editor:
|
||
///
|
||
/// * Nushell-style Tab: the first press opens the completion menu with the
|
||
/// first choice highlighted; every further press moves the highlight to
|
||
/// the next choice (Enter validates, Esc closes, the arrow keys navigate
|
||
/// and typing filters the list live). Shift+Tab moves backwards.
|
||
/// * Quick completions: a single match is inserted immediately, without
|
||
/// opening the menu.
|
||
/// * Command history persisted in the state directory (history.txt).
|
||
/// * The completion menu styled with the active theme (the REPL 'theme
|
||
/// <name>' command rebuilds the editor so the menu follows).
|
||
fn build_line_editor(app: &App, history_path: &std::path::Path) -> Result<Reedline> {
|
||
let mut keybindings = default_emacs_keybindings();
|
||
keybindings.add_binding(
|
||
KeyModifiers::NONE,
|
||
KeyCode::Tab,
|
||
ReedlineEvent::UntilFound(vec![
|
||
ReedlineEvent::Menu("completion_menu".to_string()),
|
||
ReedlineEvent::MenuNext,
|
||
]),
|
||
);
|
||
keybindings.add_binding(
|
||
KeyModifiers::SHIFT,
|
||
KeyCode::BackTab,
|
||
ReedlineEvent::UntilFound(vec![
|
||
ReedlineEvent::Menu("completion_menu".to_string()),
|
||
ReedlineEvent::MenuPrevious,
|
||
]),
|
||
);
|
||
let history = FileBackedHistory::with_file(1000, history_path.to_path_buf())
|
||
.map_err(|e| anyhow!("cannot open the command history: {e}"))?;
|
||
let color = app.color();
|
||
let ts = menu_text_style(app.theme(), color);
|
||
let menu = ColumnarMenu::default()
|
||
.with_name("completion_menu")
|
||
.with_text_style(ts.text_style)
|
||
.with_selected_text_style(ts.selected_text_style)
|
||
.with_description_text_style(ts.description_style)
|
||
.with_selected_match_text_style(ts.selected_match_style)
|
||
.with_match_text_style(ts.match_style);
|
||
Ok(Reedline::create()
|
||
.with_completer(Box::new(AmCompleter::new(app)))
|
||
.with_quick_completions(true)
|
||
.with_history(Box::new(history))
|
||
.with_menu(ReedlineMenu::EngineCompleter(Box::new(menu)))
|
||
.with_edit_mode(Box::new(Emacs::new(keybindings)))
|
||
.with_ansi_colors(color))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Banner (Hermes-style)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Inner width of the banner box; the block art is exactly this wide.
|
||
pub const BOX_WIDTH: usize = 109;
|
||
|
||
/// One 6-row block glyph of the banner art.
|
||
type Glyph = [&'static str; 6];
|
||
|
||
const G_A: Glyph = [
|
||
" █████╗ ",
|
||
"██╔══██╗",
|
||
"███████║",
|
||
"██╔══██║",
|
||
"██║ ██║",
|
||
"╚═╝ ╚═╝",
|
||
];
|
||
const G_G: Glyph = [
|
||
" ██████╗ ",
|
||
"██╔════╝ ",
|
||
"██║ ███╗",
|
||
"██║ ██║",
|
||
"╚██████╔╝",
|
||
" ╚═════╝ ",
|
||
];
|
||
const G_E: Glyph = [
|
||
"███████╗",
|
||
"██╔════╝",
|
||
"█████╗ ",
|
||
"██╔══╝ ",
|
||
"███████╗",
|
||
"╚══════╝",
|
||
];
|
||
const G_N: Glyph = [
|
||
"███╗ ██╗",
|
||
"████╗ ██║",
|
||
"██╔██╗ ██║",
|
||
"██║╚██╗██║",
|
||
"██║ ╚████║",
|
||
"╚═╝ ╚═══╝",
|
||
];
|
||
const G_T: Glyph = [
|
||
"████████╗",
|
||
"╚══██╔══╝",
|
||
" ██║ ",
|
||
" ██║ ",
|
||
" ██║ ",
|
||
" ╚═╝ ",
|
||
];
|
||
const G_M: Glyph = [
|
||
"███╗ ███╗",
|
||
"████╗ ████║",
|
||
"██╔████╔██║",
|
||
"██║╚██╔╝██║",
|
||
"██║ ╚═╝ ██║",
|
||
"╚═╝ ╚═╝",
|
||
];
|
||
const G_R: Glyph = [
|
||
"██████╗ ",
|
||
"██╔══██╗",
|
||
"██████╔╝",
|
||
"██╔══██╗",
|
||
"██║ ██║",
|
||
"╚═╝ ╚═╝",
|
||
];
|
||
const G_SP: Glyph = [" ", " ", " ", " ", " ", " "];
|
||
|
||
/// "AGENT MANAGER" rendered as 6 rows of block glyphs (BOX_WIDTH wide).
|
||
pub fn banner_art() -> Vec<String> {
|
||
let glyphs: &[&Glyph] = &[
|
||
&G_A, &G_G, &G_E, &G_N, &G_T, &G_SP, &G_M, &G_A, &G_N, &G_A, &G_G, &G_E, &G_R,
|
||
];
|
||
(0..6)
|
||
.map(|row| glyphs.iter().map(|g| g[row]).collect::<String>())
|
||
.collect()
|
||
}
|
||
|
||
/// The boxed banner: block art, Shell Gateway section (default shell
|
||
/// highlighted), the active color theme, command groups and the stats
|
||
/// footer. Every color comes from the active theme.
|
||
pub fn banner_box(
|
||
version: &str,
|
||
agents: usize,
|
||
installed: usize,
|
||
external: usize,
|
||
session: &ShellSession,
|
||
session_id: &str,
|
||
cwd: &str,
|
||
lang: &str,
|
||
theme: &Theme,
|
||
color: bool,
|
||
) -> String {
|
||
let w = BOX_WIDTH;
|
||
let mut rows: Vec<String> = Vec::new();
|
||
let frame = |s: &str| -> String {
|
||
if color {
|
||
theme.frame(s)
|
||
} else {
|
||
s.to_string()
|
||
}
|
||
};
|
||
let inner = |content: String| {
|
||
format!(
|
||
"{} {} {}",
|
||
frame("│"),
|
||
pad_visible(content, w),
|
||
frame("│")
|
||
)
|
||
};
|
||
let blank = || inner(" ".to_string());
|
||
|
||
// Top border with the centered header (title in the theme header color,
|
||
// border in the theme frame color).
|
||
let title = format!(" Agent Manager v{version} · shell gateway ");
|
||
let title = if color {
|
||
theme.hdr(&title)
|
||
} else {
|
||
title
|
||
};
|
||
let fill = w + 2 - visible_len(&title);
|
||
let left = fill / 2;
|
||
let right = fill - left;
|
||
// Paint each segment separately: the title's own reset must not cancel
|
||
// the frame color of the dashes on its right.
|
||
rows.push(format!(
|
||
"{}{}{}",
|
||
frame(&format!("╭{}", "─".repeat(left))),
|
||
title,
|
||
frame(&format!("{}╮", "─".repeat(right))),
|
||
));
|
||
|
||
// Block-art title, painted in the theme accent color.
|
||
for art in banner_art() {
|
||
rows.push(inner(if color {
|
||
theme.acc(&art)
|
||
} else {
|
||
art
|
||
}));
|
||
}
|
||
|
||
// Shell gateway: the user's default shell is highlighted at startup.
|
||
rows.push(blank());
|
||
rows.push(inner(if color {
|
||
theme.hdr("Shell Gateway")
|
||
} else {
|
||
"Shell Gateway".to_string()
|
||
}));
|
||
let current = format!(
|
||
"◉ {} — {}{}",
|
||
green(session.current.name, theme, color),
|
||
session.current.title,
|
||
if session.is_default() { " (default)" } else { "" }
|
||
);
|
||
rows.push(inner(format!(" {current}")));
|
||
let others: Vec<&str> = session
|
||
.available
|
||
.iter()
|
||
.filter(|s| s.name != session.current.name)
|
||
.map(|s| s.name)
|
||
.collect();
|
||
let avail = if others.is_empty() {
|
||
"(no other shell found on PATH)".to_string()
|
||
} else {
|
||
others.join(" · ")
|
||
};
|
||
rows.push(inner(dim(&format!(" ⇄ {avail}"), theme, color)));
|
||
rows.push(inner(dim(
|
||
" system commands run in the active shell — 'shell <name>' switches",
|
||
theme,
|
||
color,
|
||
)));
|
||
|
||
// Active color theme: the current one highlighted, every other theme
|
||
// listed, plus the two ways to switch.
|
||
rows.push(blank());
|
||
rows.push(inner(if color {
|
||
theme.hdr("Theme")
|
||
} else {
|
||
"Theme".to_string()
|
||
}));
|
||
let current_theme = format!(
|
||
"◉ {} — {}",
|
||
green(theme.name, theme, color),
|
||
theme.title
|
||
);
|
||
rows.push(inner(format!(" {current_theme}")));
|
||
let theme_others: Vec<&str> = crate::theme::THEMES
|
||
.iter()
|
||
.map(|t| t.name)
|
||
.filter(|n| *n != theme.name)
|
||
.collect();
|
||
// The full list may not fit one line: wrap it into two ⇄ lines.
|
||
let half = theme_others.len().div_ceil(2);
|
||
for (i, chunk) in theme_others.chunks(half).enumerate() {
|
||
let line = format!(
|
||
"{}⇄ {}",
|
||
if i == 0 { " " } else { " " },
|
||
chunk.join(" · ")
|
||
);
|
||
rows.push(inner(dim(&line, theme, color)));
|
||
}
|
||
rows.push(inner(dim(
|
||
" 'theme <name>' to switch (saved) · 'am --theme <name>' for a one-shot",
|
||
theme,
|
||
color,
|
||
)));
|
||
|
||
// am command groups: every command the REPL recognizes, grouped by
|
||
// purpose. Keep this in sync with the completer (COMMAND_DESCRIPTIONS).
|
||
rows.push(blank());
|
||
rows.push(inner(if color {
|
||
theme.hdr("Commands")
|
||
} else {
|
||
"Commands".to_string()
|
||
}));
|
||
rows.push(inner(" catalog list · status · search · info · init".to_string()));
|
||
rows.push(inner(" registry publish · search · install · list".to_string()));
|
||
rows.push(inner(" serve API HTTP+WS · token · stats · run · start · stop · ask".to_string()));
|
||
rows.push(inner(
|
||
" activity sessions · stats · top · report · projects · timeline · log · logs · history"
|
||
.to_string(),
|
||
));
|
||
rows.push(inner(
|
||
" lifecycle install · uninstall · update · start · stop · restart · run".to_string(),
|
||
));
|
||
rows.push(inner(
|
||
" manage open · watch · dashboard · web · favorite · unfavorite · note · tag · untag · tags"
|
||
.to_string(),
|
||
));
|
||
rows.push(inner(
|
||
" config alias · secret · profile · providers · config · doctor · completion · man · tip"
|
||
.to_string(),
|
||
));
|
||
rows.push(inner(
|
||
" models models · models --prune · catalog · suggest · ask · ai · setup · audit".to_string(),
|
||
));
|
||
rows.push(inner(
|
||
" automate service install · schedule add · doctor --watch · monitor · sync · migrate · playbook".to_string(),
|
||
));
|
||
rows.push(inner(
|
||
" bench lab --agents a,b --task t.yaml · plugins · plugins --test <name>".to_string(),
|
||
));
|
||
rows.push(inner(
|
||
" system self-update · self-uninstall · export · import".to_string(),
|
||
));
|
||
rows.push(inner(
|
||
" shell ls · dir · cd · ps · where · get · shell · theme · lang · help · version · exit"
|
||
.to_string(),
|
||
));
|
||
|
||
// Stats footer.
|
||
rows.push(blank());
|
||
rows.push(inner(dim(
|
||
&format!(
|
||
"am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · theme {} · lang {} · /help for commands",
|
||
session.current.name,
|
||
theme.name,
|
||
lang,
|
||
),
|
||
theme,
|
||
color,
|
||
)));
|
||
let prefix = format!(" session {session_id} · cwd ");
|
||
let budget = w.saturating_sub(visible_len(&prefix) + 2);
|
||
rows.push(inner(format!("{prefix}{}", shorten_head(cwd, budget))));
|
||
rows.push(inner(yellow(
|
||
" ✦ Tip: 'shell <name>' switches the system shell · '!' forces a system command",
|
||
theme,
|
||
color,
|
||
)));
|
||
rows.push(frame(&format!("╰{}╯", "─".repeat(w + 2))));
|
||
|
||
rows.join("\n")
|
||
}
|
||
|
||
/// The full startup banner (box + welcome + tip).
|
||
pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String {
|
||
let color = app.color();
|
||
let theme = app.theme();
|
||
let mut text = banner_box(
|
||
env!("CARGO_PKG_VERSION"),
|
||
agent_count(app),
|
||
installed_count(app),
|
||
external_count(app),
|
||
session,
|
||
session_id,
|
||
¤t_dir_string(),
|
||
app.lang().code(),
|
||
theme,
|
||
color,
|
||
);
|
||
text.push('\n');
|
||
text.push_str(
|
||
"Welcome to Agent Manager! Type an am command or any system command — /help for commands.\n",
|
||
);
|
||
text.push_str(&format!(
|
||
"{} {}\n",
|
||
yellow("✦ Tip:", theme, color),
|
||
dim(
|
||
&format!(
|
||
"system commands run in {} — 'shell <name>' switches · '!' forces · 'cd' works in this session",
|
||
session.current.name
|
||
),
|
||
theme,
|
||
color,
|
||
),
|
||
));
|
||
text
|
||
}
|
||
|
||
/// Color an emoji glyph with the active theme (style names: green, blue,
|
||
/// yellow, cyan).
|
||
fn emoji(glyph: &str, style: &str, theme: &Theme, color: bool) -> String {
|
||
if !color {
|
||
return glyph.to_string();
|
||
}
|
||
match style {
|
||
"green" => theme.acc(glyph),
|
||
"blue" => theme.inf(glyph),
|
||
"yellow" => theme.warn(glyph),
|
||
"cyan" => theme.inf(glyph),
|
||
_ => glyph.to_string(),
|
||
}
|
||
}
|
||
|
||
/// One-line status shown before every prompt, with colored emojis:
|
||
/// ⚙ am v0.2.13 🪟 bash │ 🗂️ cwd │ 73 🤖 · 12 🌐 · 0 📊 │ ⚡ 97ms │ ⏲ 32s
|
||
/// `shell` is the active shell-gateway name (issue #44). The last parameter
|
||
/// is the duration of the previous command (None before the first one).
|
||
fn print_status(app: &App, started: Instant, last: Option<Duration>, shell: &str) {
|
||
let color = app.color();
|
||
let theme = app.theme();
|
||
let sep = dim(" │ ", theme, color);
|
||
let shell_part = format!(
|
||
"{} {}",
|
||
emoji("🪟", "cyan", theme, color),
|
||
dim(shell, theme, color)
|
||
);
|
||
let cwd = shorten_head(¤t_dir_string(), 30);
|
||
let folder = format!(
|
||
"{} {}",
|
||
emoji("🗂️", "cyan", theme, color),
|
||
dim(&cwd, theme, color)
|
||
);
|
||
let counts = format!(
|
||
"{} {} {} {} {} {} {} {}",
|
||
dim(&agent_count(app).to_string(), theme, color),
|
||
emoji("🤖", "green", theme, color),
|
||
dim("·", theme, color),
|
||
dim(&external_count(app).to_string(), theme, color),
|
||
emoji("🌐", "blue", theme, color),
|
||
dim("·", theme, color),
|
||
dim(&installed_count(app).to_string(), theme, color),
|
||
emoji("📊", "yellow", theme, color),
|
||
);
|
||
let last_text = match last {
|
||
Some(d) if d.as_secs() >= 1 => format_elapsed(d),
|
||
Some(d) if d.as_millis() >= 1 => format!("{}ms", d.as_millis()),
|
||
Some(d) => format!("{}µs", d.as_micros()),
|
||
None => "—".to_string(),
|
||
};
|
||
let last_part = format!(
|
||
"{} {}",
|
||
emoji("⚡", "yellow", theme, color),
|
||
dim(&last_text, theme, color)
|
||
);
|
||
let elapsed = dim(
|
||
&format!("⏲ {}", format_elapsed(started.elapsed())),
|
||
theme,
|
||
color,
|
||
);
|
||
println!(
|
||
"{}am v{}{sep}{shell_part}{sep}{folder}{sep}{counts}{sep}{last_part}{sep}{elapsed}",
|
||
dim(" ⚙ ", theme, color),
|
||
env!("CARGO_PKG_VERSION"),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Small display helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn strip_ansi(s: &str) -> String {
|
||
let mut out = String::new();
|
||
let mut chars = s.chars();
|
||
while let Some(c) = chars.next() {
|
||
if c == '\u{1b}' {
|
||
for n in chars.by_ref() {
|
||
if n == 'm' {
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
out.push(c);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn visible_len(s: &str) -> usize {
|
||
strip_ansi(s).chars().count()
|
||
}
|
||
|
||
fn pad_visible(content: String, width: usize) -> String {
|
||
let n = visible_len(&content);
|
||
if n >= width {
|
||
content
|
||
} else {
|
||
format!("{content}{}", " ".repeat(width - n))
|
||
}
|
||
}
|
||
|
||
fn dim(s: &str, theme: &Theme, color: bool) -> String {
|
||
if color {
|
||
theme.dimmed(s)
|
||
} else {
|
||
s.to_string()
|
||
}
|
||
}
|
||
|
||
fn green(s: &str, theme: &Theme, color: bool) -> String {
|
||
if color {
|
||
theme.acc(s)
|
||
} else {
|
||
s.to_string()
|
||
}
|
||
}
|
||
|
||
fn yellow(s: &str, theme: &Theme, color: bool) -> String {
|
||
if color {
|
||
theme.warn(s)
|
||
} else {
|
||
s.to_string()
|
||
}
|
||
}
|
||
|
||
/// Keep the tail of a long path so it fits a fixed budget.
|
||
fn shorten_head(text: &str, budget: usize) -> String {
|
||
let n = visible_len(text);
|
||
if n <= budget {
|
||
return text.to_string();
|
||
}
|
||
let keep = budget.saturating_sub(1).max(1);
|
||
let mut tail: String = text.chars().rev().take(keep).collect();
|
||
tail = tail.chars().rev().collect();
|
||
format!("…{tail}")
|
||
}
|
||
|
||
fn format_elapsed(d: Duration) -> String {
|
||
let s = d.as_secs();
|
||
if s < 60 {
|
||
format!("{s}s")
|
||
} else if s < 3600 {
|
||
format!("{}m{:02}s", s / 60, s % 60)
|
||
} else {
|
||
format!("{}h{:02}m", s / 3600, (s % 3600) / 60)
|
||
}
|
||
}
|
||
|
||
/// Unique session stamp, e.g. 20260815_184649_a1b2c3.
|
||
pub fn session_id() -> String {
|
||
let stamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||
let nanos = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.subsec_nanos())
|
||
.unwrap_or(0);
|
||
format!("{stamp}_{:06x}", nanos & 0x00ff_ffff)
|
||
}
|
||
|
||
fn agent_count(app: &App) -> usize {
|
||
app.catalog.agents().iter().filter(|a| !a.hidden).count()
|
||
}
|
||
|
||
fn installed_count(app: &App) -> usize {
|
||
app.state
|
||
.load()
|
||
.ok()
|
||
.map(|s| s.installed.len())
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
/// Number of external agents (installed outside agent-manager) detected on
|
||
/// the PATH — the same definition used by the "external" status in am list.
|
||
fn external_count(app: &App) -> usize {
|
||
let probe = crate::probe::detect_externals(app);
|
||
let managed: std::collections::BTreeSet<String> = app
|
||
.state
|
||
.load()
|
||
.ok()
|
||
.map(|s| s.installed.keys().cloned().collect())
|
||
.unwrap_or_default();
|
||
app.catalog
|
||
.agents()
|
||
.iter()
|
||
.filter(|a| !a.hidden && a.installable)
|
||
.filter(|a| !managed.contains(&a.name))
|
||
.filter(|a| probe.path_of(&a.name).is_some())
|
||
.count()
|
||
}
|
||
|
||
fn current_dir_string() -> String {
|
||
std::env::current_dir()
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|_| "?".to_string())
|
||
}
|
||
|
||
fn current_dir() -> PathBuf {
|
||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||
}
|
||
|
||
/// Keep a catalog description short enough for the completion menu.
|
||
fn truncate_desc(desc: &str) -> String {
|
||
let one_line = desc.lines().next().unwrap_or(desc);
|
||
let mut out: String = one_line.chars().take(64).collect();
|
||
if one_line.chars().count() > 64 {
|
||
out.push('…');
|
||
}
|
||
out
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Interactive loop
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub fn run(app: &App) -> Result<i32> {
|
||
// AM_FORCE_REPL lets scripts and tests exercise the REPL without a TTY.
|
||
let forced = std::env::var_os("AM_FORCE_REPL").is_some();
|
||
if !std::io::stdin().is_terminal() && !forced {
|
||
// Piped stdin: print the help instead of hanging.
|
||
crate::help::print_general(app);
|
||
return Ok(2);
|
||
}
|
||
let mut session = ShellSession::start(&app.config);
|
||
let sid = session_id();
|
||
app.emit(
|
||
&Event::now(EventKind::Repl)
|
||
.with_session(sid.clone())
|
||
.with_cwd(Some(current_dir_string())),
|
||
);
|
||
let _ = crate::sessions::start_repl(app, &sid, Some(current_dir_string()));
|
||
if let Err(e) = crate::history::migrate(app) {
|
||
app.log.verbose(&format!("history migration skipped: {e:#}"));
|
||
}
|
||
print!("{}", banner(app, &session, &sid));
|
||
// Onboarding tip (v1.1.0 F1): one discreet line when no provider is
|
||
// configured yet.
|
||
if crate::setup::needs_setup(app) {
|
||
app.log.info(crate::i18n::tr_in(
|
||
app.lang(),
|
||
"am n'est pas configuré — lancez am setup (provider + token)",
|
||
));
|
||
}
|
||
let result = match run_with_editor(app, &mut session, &sid) {
|
||
Ok(code) => Ok(code),
|
||
Err(e) => {
|
||
app.log.verbose(&format!(
|
||
"line editor unavailable ({e:#}); falling back to plain input"
|
||
));
|
||
run_plain(app, &mut session, &sid)
|
||
}
|
||
};
|
||
let _ = crate::sessions::finish_repl(app, &sid);
|
||
// Issue #66: optional auto-sync of the state when the REPL exits.
|
||
if app.config.settings.sync_on_exit.unwrap_or(false) {
|
||
match crate::sync::sync(app, "repl exit — state sync") {
|
||
Ok(crate::sync::SyncOutcome::Pushed { .. }) => {
|
||
app.log.success("state pushed (sync_on_exit)");
|
||
}
|
||
Ok(_) => {}
|
||
Err(e) => app.log.warn(&format!("sync_on_exit failed: {e:#}")),
|
||
}
|
||
}
|
||
result
|
||
}
|
||
|
||
fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
|
||
// With piped stdin (tests, scripts, CI) the line editor cannot read
|
||
// console keys — reedline would block on the console instead of the
|
||
// pipe. Fall back to plain line input (run() handles the fallback).
|
||
if !std::io::stdin().is_terminal() {
|
||
return Err(anyhow!("stdin is not a terminal"));
|
||
}
|
||
let history_path = app.paths.state_file.with_file_name("history.txt");
|
||
let mut rl = build_line_editor(app, &history_path)?;
|
||
let prompt = AmPrompt;
|
||
let started = Instant::now();
|
||
let mut last: Option<DataTable> = None;
|
||
let mut last_duration: Option<Duration> = None;
|
||
let mut first_read = true;
|
||
let mut theme: &'static crate::theme::Theme = app.theme();
|
||
loop {
|
||
print_status(app, started, last_duration, &session.current.name);
|
||
let signal = match rl.read_line(&prompt) {
|
||
Ok(signal) => signal,
|
||
Err(e) => {
|
||
// No usable terminal (e.g. AM_FORCE_REPL with piped stdin):
|
||
// fall back to plain line input like before.
|
||
if first_read {
|
||
return Err(anyhow!("line editor unavailable: {e}"));
|
||
}
|
||
app.log.warn(&format!("input error: {e}"));
|
||
break;
|
||
}
|
||
};
|
||
first_read = false;
|
||
let line = match signal {
|
||
Signal::Success(line) => line,
|
||
// Like rustyline: Ctrl-C aborts the current line, Ctrl-D (EOF on
|
||
// an empty line) ends the session.
|
||
Signal::CtrlC => continue,
|
||
Signal::CtrlD => break,
|
||
Signal::HostCommand(_) | Signal::ExternalBreak(_) => continue,
|
||
// Signal is non-exhaustive: stay in the REPL for future signals.
|
||
_ => continue,
|
||
};
|
||
let trimmed = line.trim().to_string();
|
||
if trimmed.is_empty() {
|
||
continue;
|
||
}
|
||
let t = Instant::now();
|
||
let result = handle_input(app, session, &mut last, &trimmed);
|
||
last_duration = Some(t.elapsed());
|
||
record_history(app, sid, &trimmed, &result, t.elapsed());
|
||
// Rebuild the editor when the completion pool or the palette may
|
||
// have changed: 'theme <name>' switches the menu colors, and the
|
||
// install/uninstall/update/alias/config/import commands mutate the
|
||
// installed agents, aliases or groups that Tab proposes (the
|
||
// completer snapshots them at startup, so a fresh install would
|
||
// stay hidden until an exit/restart otherwise). The history is
|
||
// file-backed and re-read from disk.
|
||
let first_word = trimmed.split_whitespace().next().unwrap_or("");
|
||
if !std::ptr::eq(theme, app.theme()) || completion_pool_mutating(first_word) {
|
||
theme = app.theme();
|
||
let _ = rl.sync_history();
|
||
match build_line_editor(app, &history_path) {
|
||
Ok(new_rl) => rl = new_rl,
|
||
Err(e) => app.log.warn(&format!("cannot rebuild the editor: {e:#}")),
|
||
}
|
||
}
|
||
match result {
|
||
Ok(true) => break,
|
||
Ok(false) => {}
|
||
Err(e) => app.log.error(&format!("{e:#}")),
|
||
}
|
||
}
|
||
let _ = rl.sync_history();
|
||
Ok(0)
|
||
}
|
||
|
||
/// Append one structured history entry for a REPL command.
|
||
fn record_history(
|
||
app: &App,
|
||
sid: &str,
|
||
line: &str,
|
||
result: &Result<bool>,
|
||
elapsed: std::time::Duration,
|
||
) {
|
||
let kind = if is_am_command(line.split_whitespace().next().unwrap_or("")) {
|
||
"am"
|
||
} else {
|
||
"shell"
|
||
};
|
||
let exit_code = if result.is_err() { Some(1) } else { None };
|
||
let _ = crate::history::record(
|
||
app,
|
||
sid,
|
||
&crate::history::HistoryEntry::now(line.to_string())
|
||
.with_kind(kind.to_string())
|
||
.with_cwd(Some(current_dir_string()))
|
||
.with_exit_code(exit_code)
|
||
.with_duration_ms(elapsed.as_millis() as u64),
|
||
);
|
||
}
|
||
|
||
/// Plain fallback when no terminal/line editor is available.
|
||
fn run_plain(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
|
||
let started = Instant::now();
|
||
let mut last: Option<DataTable> = None;
|
||
let mut last_duration: Option<Duration> = None;
|
||
loop {
|
||
print_status(app, started, last_duration, &session.current.name);
|
||
eprint!("❯ ");
|
||
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;
|
||
}
|
||
let t = Instant::now();
|
||
let result = handle_input(app, session, &mut last, &line);
|
||
last_duration = Some(t.elapsed());
|
||
record_history(app, sid, &line, &result, t.elapsed());
|
||
match result {
|
||
Ok(true) => break,
|
||
Ok(false) => {}
|
||
Err(e) => app.log.error(&format!("{e:#}")),
|
||
}
|
||
}
|
||
Ok(0)
|
||
}
|
||
|
||
/// Handle one line of input. Returns true when the session should exit.
|
||
fn handle_input(
|
||
app: &App,
|
||
session: &mut ShellSession,
|
||
last: &mut Option<DataTable>,
|
||
line: &str,
|
||
) -> Result<bool> {
|
||
match line {
|
||
"exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true),
|
||
"help" | "?" | "/help" => {
|
||
crate::help::print_general(app);
|
||
return Ok(false);
|
||
}
|
||
"version" | "/version" => {
|
||
crate::help::print_version(app);
|
||
return Ok(false);
|
||
}
|
||
"theme" | "/theme" => {
|
||
theme_command(app, None);
|
||
return Ok(false);
|
||
}
|
||
"lang" | "/lang" => {
|
||
lang_command(app, None);
|
||
return Ok(false);
|
||
}
|
||
_ => {}
|
||
}
|
||
// History expansion: !! !N !prefix ^old^new (re-execution, issue #27).
|
||
if let Some(expanded) = crate::history::expand(&crate::history::read_all(app, 500), line) {
|
||
if expanded.trim().is_empty() {
|
||
return Ok(false);
|
||
}
|
||
println!("{expanded}");
|
||
return handle_input(app, session, last, &expanded);
|
||
}
|
||
// Slash commands (Hermes-style).
|
||
if let Some(rest) = line.strip_prefix('/') {
|
||
let (cmd, arg) = match rest.split_once(char::is_whitespace) {
|
||
Some((c, a)) => (c, Some(a.trim())),
|
||
None => (rest, None),
|
||
};
|
||
match cmd {
|
||
"shell" => shell_command(session, arg),
|
||
"theme" => theme_command(app, arg),
|
||
"lang" => lang_command(app, arg),
|
||
"help" => match arg {
|
||
Some(topic) => {
|
||
if let Err(e) = crate::help::print_command(app, topic) {
|
||
app.log.error(format!("{e:#}"));
|
||
}
|
||
}
|
||
None => crate::help::print_general(app),
|
||
},
|
||
_ => app
|
||
.log
|
||
.error(crate::tr_fmt!("unknown command '/{}' — type '/help'", cmd)),
|
||
}
|
||
return Ok(false);
|
||
}
|
||
handle_line(app, session, last, line)
|
||
}
|
||
|
||
/// Handle one line of input. Returns true when the session should exit
|
||
/// (only after a successful self-uninstall: the binary is gone).
|
||
fn handle_line(
|
||
app: &App,
|
||
session: &mut ShellSession,
|
||
last: &mut Option<DataTable>,
|
||
line: &str,
|
||
) -> Result<bool> {
|
||
// Nushell-style pipelines: ls | where ... | get ...
|
||
if line.contains('|') {
|
||
return eval_pipeline(app, last, line);
|
||
}
|
||
// '!' forces system execution even for words that look like am commands.
|
||
if let Some(rest) = line.strip_prefix('!') {
|
||
let rest = rest.trim();
|
||
if !rest.is_empty() {
|
||
run_system(app, session, rest)?;
|
||
}
|
||
return Ok(false);
|
||
}
|
||
// Windows: split with backslashes as literal path separators (the Unix
|
||
// shell_words splitter would strip them from 'cd dev\git\...'). Unix
|
||
// keeps the classic backslash-escaping semantics.
|
||
let tokens = split_command(line)?;
|
||
if tokens.is_empty() {
|
||
return Ok(false);
|
||
}
|
||
let cmd = tokens[0].as_str();
|
||
let rest = &tokens[1..];
|
||
|
||
// Nushell-style help: 'ls -h', 'ls --help', 'start -h', ... show the
|
||
// colored command help. System commands keep their own -h handling.
|
||
if is_am_command(cmd) || is_shell_command(cmd) {
|
||
let help_flag_pos = rest.iter().position(|t| t == "-h" || t == "--help");
|
||
let dd_pos = rest.iter().position(|t| t == "--");
|
||
let help_requested = match help_flag_pos {
|
||
Some(p) => dd_pos.is_none_or(|d| p < d),
|
||
None => false,
|
||
};
|
||
// 'run <agent> --help' passes --help through to the agent; only
|
||
// 'run -h' right after the command name is a help request.
|
||
let passthrough = cmd == "run" && help_flag_pos != Some(0);
|
||
if help_requested && !passthrough {
|
||
if let Err(e) = crate::help::print_command(app, cmd) {
|
||
app.log.error(format!("{e:#}"));
|
||
}
|
||
return Ok(false);
|
||
}
|
||
}
|
||
|
||
// help <topic> | help --find <text> | help commands
|
||
if cmd == "help" {
|
||
return run_help_command(app, rest);
|
||
}
|
||
if cmd == "version" {
|
||
crate::help::print_version(app);
|
||
return Ok(false);
|
||
}
|
||
// theme | theme <name>: list the themes or switch the active one.
|
||
if cmd == "theme" {
|
||
theme_command(app, rest.first().map(|s| s.as_str()));
|
||
return Ok(false);
|
||
}
|
||
// lang | lang <fr|en>: show the output language or switch it (issue #74).
|
||
if cmd == "lang" {
|
||
lang_command(app, rest.first().map(|s| s.as_str()));
|
||
return Ok(false);
|
||
}
|
||
|
||
// Session directory change: kept in the am process, not a child shell.
|
||
if matches!(cmd, "cd" | "chdir") {
|
||
change_dir(rest)?;
|
||
return Ok(false);
|
||
}
|
||
if cmd == "shell" {
|
||
shell_command(session, rest.first().map(|s| s.as_str()));
|
||
return Ok(false);
|
||
}
|
||
// Nushell-style directory listing (the system ls stays available as !ls,
|
||
// and dir maps to the same table on Windows).
|
||
if cmd == "ls" || (cmd == "dir" && cfg!(windows)) {
|
||
let table = nav::ls_table(&ls_target(rest)?, app.color())?;
|
||
show_table(app, last, table);
|
||
return Ok(false);
|
||
}
|
||
if cmd == "ps" {
|
||
show_table(app, last, ps::ps_table());
|
||
return Ok(false);
|
||
}
|
||
if cmd == "where" {
|
||
let base = last
|
||
.clone()
|
||
.ok_or_else(|| anyhow!("'where' needs a table — run ls or ps first (e.g. ls | where size > 1mb)"))?;
|
||
if rest.len() < 3 {
|
||
anyhow::bail!("usage: where <column> <op> <value>");
|
||
}
|
||
let filtered =
|
||
crate::tables::filter_where(&base, &rest[0], &rest[1], &rest[2..].join(" "))?;
|
||
show_table(app, last, filtered);
|
||
return Ok(false);
|
||
}
|
||
if cmd == "get" {
|
||
let base = last
|
||
.clone()
|
||
.ok_or_else(|| anyhow!("'get' needs a table — run ls or ps first (e.g. ls | get name)"))?;
|
||
let field = rest
|
||
.first()
|
||
.ok_or_else(|| anyhow!("usage: get <column>"))?;
|
||
let column = crate::tables::get_column(&base, field)?;
|
||
show_table(app, last, column);
|
||
return Ok(false);
|
||
}
|
||
// Everything that is not an am command is a system command.
|
||
if !is_am_command(cmd) {
|
||
run_system(app, session, line)?;
|
||
return Ok(false);
|
||
}
|
||
|
||
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 opt_value = |f: &str| {
|
||
rest.iter()
|
||
.position(|t| t == f)
|
||
.and_then(|i| rest.get(i + 1))
|
||
.cloned()
|
||
};
|
||
let method_flag = || opt_value("--method");
|
||
let command = match cmd {
|
||
"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")?,
|
||
},
|
||
"watch" => Command::Watch {
|
||
agent: need("agent name")?,
|
||
restart: flag("--restart"),
|
||
notify: flag("--notify"),
|
||
interval: opt_value("--interval").and_then(|v| v.parse().ok()).unwrap_or(5),
|
||
},
|
||
"secret" => match rest.first().map(|s| s.as_str()) {
|
||
Some("set") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Set {
|
||
name: rest[1].clone(),
|
||
agent: opt_value("--agent"),
|
||
provider: opt_value("--provider"),
|
||
value: opt_value("--value").unwrap_or_default(),
|
||
}),
|
||
Some("unset") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Unset {
|
||
name: rest[1].clone(),
|
||
agent: opt_value("--agent"),
|
||
provider: opt_value("--provider"),
|
||
}),
|
||
Some("list") | None => Command::Secret(crate::cli::SecretCmd::List),
|
||
_ => {
|
||
app.log.error(crate::i18n::tr("usage: secret set NAME --agent A --value V | secret set NAME --provider P --value V | secret unset NAME --agent A | secret list"));
|
||
return Ok(false);
|
||
}
|
||
},
|
||
"models" => Command::Models(crate::cli::ModelsArgs {
|
||
prune: flag("--prune"),
|
||
dry_run: flag("--dry-run"),
|
||
yes: flag("--yes") || app.yes(),
|
||
days: opt_value("--days").and_then(|v| v.parse().ok()),
|
||
}),
|
||
"catalog" => match rest.first().map(|s| s.as_str()) {
|
||
Some("update") | None => Command::Catalog(crate::cli::CatalogCmd::Update),
|
||
Some("add") if rest.len() >= 2 => Command::Catalog(crate::cli::CatalogCmd::Add {
|
||
url: rest[1].clone(),
|
||
}),
|
||
Some("list") => Command::Catalog(crate::cli::CatalogCmd::List),
|
||
_ => {
|
||
app.log.error(crate::i18n::tr("usage: catalog update | catalog add <url> | catalog list"));
|
||
return Ok(false);
|
||
}
|
||
},
|
||
"providers" => match rest.first().map(|s| s.as_str()) {
|
||
Some("show") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||
sub: Some(crate::cli::ProvidersCmd::Show {
|
||
name: rest[1].clone(),
|
||
}),
|
||
}),
|
||
Some("add") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||
sub: Some(crate::cli::ProvidersCmd::Add {
|
||
name: rest[1].clone(),
|
||
base_url: opt_value("--base-url").unwrap_or_default(),
|
||
model: opt_value("--model"),
|
||
models: opt_value("--models"),
|
||
}),
|
||
}),
|
||
Some("remove") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||
sub: Some(crate::cli::ProvidersCmd::Remove {
|
||
name: rest[1].clone(),
|
||
}),
|
||
}),
|
||
Some("default") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||
sub: Some(crate::cli::ProvidersCmd::Default {
|
||
name: rest[1].clone(),
|
||
}),
|
||
}),
|
||
Some("set-token") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||
sub: Some(crate::cli::ProvidersCmd::SetToken {
|
||
name: rest[1].clone(),
|
||
value: opt_value("--value").unwrap_or_default(),
|
||
}),
|
||
}),
|
||
Some("token") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||
sub: Some(crate::cli::ProvidersCmd::Token {
|
||
name: rest[1].clone(),
|
||
}),
|
||
}),
|
||
Some("list") | None => Command::Providers(crate::cli::ProviderArgs {
|
||
sub: Some(crate::cli::ProvidersCmd::List),
|
||
}),
|
||
_ => {
|
||
app.log.error(crate::i18n::tr("usage: providers list | show <nom> | add <nom> --base-url <url> | remove <nom> | default <nom>"));
|
||
return Ok(false);
|
||
}
|
||
},
|
||
"registry" => match rest.first().map(|s| s.as_str()) {
|
||
None => Command::Registry(crate::cli::RegistryArgs::default()),
|
||
Some("list") => Command::Registry(crate::cli::RegistryArgs {
|
||
sub: Some(crate::cli::RegistryCmd::List),
|
||
}),
|
||
Some("search") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs {
|
||
sub: Some(crate::cli::RegistryCmd::Search {
|
||
query: rest[1..].join(" "),
|
||
}),
|
||
}),
|
||
Some("install") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs {
|
||
sub: Some(crate::cli::RegistryCmd::Install {
|
||
url: rest[1].clone(),
|
||
yes: flag("--yes"),
|
||
}),
|
||
}),
|
||
Some("publish") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs {
|
||
sub: Some(crate::cli::RegistryCmd::Publish {
|
||
catalog: rest[1].clone().into(),
|
||
source: opt_value("--source"),
|
||
author: opt_value("--author"),
|
||
version: opt_value("--version"),
|
||
}),
|
||
}),
|
||
_ => {
|
||
app.log.error(crate::i18n::tr(
|
||
"usage: registry publish <catalogue.yaml> --source <url> | registry search <mot> | registry install <url> | registry list",
|
||
));
|
||
return Ok(false);
|
||
}
|
||
},
|
||
"suggest" => {
|
||
if rest.is_empty() {
|
||
app.log.error(crate::i18n::tr("usage: suggest <requête> — ex: suggest un agent pour du Python"));
|
||
return Ok(false);
|
||
}
|
||
Command::Suggest {
|
||
words: rest.to_vec(),
|
||
}
|
||
}
|
||
"ask" => {
|
||
if rest.is_empty() {
|
||
app.log.error(crate::i18n::tr("usage: ask <demande> — ex: ask installe claude et lance-le"));
|
||
return Ok(false);
|
||
}
|
||
Command::Ask {
|
||
query: rest.to_vec(),
|
||
yes: flag("--yes"),
|
||
}
|
||
},
|
||
"ai" => {
|
||
let mut exec = false;
|
||
let mut yes = false;
|
||
let mut dry_run = false;
|
||
let mut files: Vec<std::path::PathBuf> = Vec::new();
|
||
let mut provider: Option<String> = None;
|
||
let mut model: Option<String> = None;
|
||
let mut role: Option<String> = None;
|
||
let mut prompt: Vec<String> = Vec::new();
|
||
let mut i = 0;
|
||
while i < rest.len() {
|
||
match rest[i].as_str() {
|
||
"--exec" | "-e" => exec = true,
|
||
"--yes" | "-y" => yes = true,
|
||
"--dry-run" => dry_run = true,
|
||
"--files" | "-f" => {
|
||
i += 1;
|
||
if i < rest.len() {
|
||
files.push(std::path::PathBuf::from(&rest[i]));
|
||
}
|
||
}
|
||
"--provider" => {
|
||
i += 1;
|
||
if i < rest.len() {
|
||
provider = Some(rest[i].clone());
|
||
}
|
||
}
|
||
"--model" => {
|
||
i += 1;
|
||
if i < rest.len() {
|
||
model = Some(rest[i].clone());
|
||
}
|
||
}
|
||
"--role" => {
|
||
i += 1;
|
||
if i < rest.len() {
|
||
role = Some(rest[i].clone());
|
||
}
|
||
}
|
||
other => prompt.push(other.to_string()),
|
||
}
|
||
i += 1;
|
||
}
|
||
if prompt.is_empty() {
|
||
app.log.error(crate::i18n::tr(
|
||
"usage: ai <demande> — ex: ai --exec \"compresse les fichiers JSON\"",
|
||
));
|
||
return Ok(false);
|
||
}
|
||
Command::Ai(crate::cli::AiArgs {
|
||
prompt,
|
||
exec,
|
||
files,
|
||
provider,
|
||
model,
|
||
role,
|
||
yes,
|
||
dry_run,
|
||
})
|
||
},
|
||
"setup" => Command::Setup {
|
||
roles: flag("--roles"),
|
||
},
|
||
"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(crate::i18n::tr("usage: alias add <name> <target> | alias remove <name> | alias list"));
|
||
return Ok(false);
|
||
}
|
||
},
|
||
"dashboard" => Command::Dashboard,
|
||
"favorite" => Command::Favorite {
|
||
agent: need("agent name")?,
|
||
},
|
||
"unfavorite" => Command::Unfavorite {
|
||
agent: need("agent name")?,
|
||
},
|
||
"note" => Command::Note {
|
||
agent: need("agent name")?,
|
||
text: rest[1..].to_vec(),
|
||
},
|
||
"tag" => Command::Tag {
|
||
agent: need("agent name")?,
|
||
words: rest[1..].to_vec(),
|
||
},
|
||
"untag" => Command::Untag {
|
||
agent: need("agent name")?,
|
||
tag: need("tag")?,
|
||
},
|
||
"tags" => Command::Tags {
|
||
agent: rest.first().cloned(),
|
||
},
|
||
"profile" => match rest.first().map(|s| s.as_str()) {
|
||
Some("show") if rest.len() >= 2 => {
|
||
Command::Profile(crate::cli::ProfileCmd::Show {
|
||
name: rest[1].clone(),
|
||
})
|
||
}
|
||
Some("list") | None => Command::Profile(crate::cli::ProfileCmd::List),
|
||
other => {
|
||
app.log.error(&format!(
|
||
"{}{}",
|
||
crate::i18n::tr("usage: profile list | profile show <name>"),
|
||
other.map(|o| format!(" (unknown 'profile {o}')")).unwrap_or_default()
|
||
));
|
||
return Ok(false);
|
||
}
|
||
},
|
||
"man" => Command::Man {
|
||
command: rest.first().cloned(),
|
||
output: opt_value("--output").map(PathBuf::from),
|
||
},
|
||
"status" => Command::Status {
|
||
agent: rest.first().cloned(),
|
||
},
|
||
"sessions" => Command::Sessions {
|
||
agent: rest.first().cloned(),
|
||
project: opt_value("--project"),
|
||
status: opt_value("--status"),
|
||
show: opt_value("--show"),
|
||
resume: opt_value("--resume"),
|
||
export: opt_value("--export"),
|
||
output: opt_value("--output"),
|
||
retention: opt_value("--retention").and_then(|v| v.parse().ok()),
|
||
},
|
||
"stats" => Command::Stats {
|
||
agent: rest.first().cloned(),
|
||
period: opt_value("--period"),
|
||
costs: flag("--costs"),
|
||
},
|
||
"monitor" => Command::Monitor {
|
||
interval: opt_value("--interval").and_then(|v| v.parse().ok()),
|
||
json: flag("--json"),
|
||
},
|
||
"web" => Command::Web(crate::cli::WebArgs {
|
||
port: opt_value("--port").and_then(|v| v.parse().ok()),
|
||
no_open: flag("--no-open"),
|
||
}),
|
||
"serve" => Command::Serve(crate::cli::ServeArgs {
|
||
token: opt_value("--token"),
|
||
host: opt_value("--host"),
|
||
port: opt_value("--port").and_then(|v| v.parse().ok()),
|
||
rate_limit: opt_value("--rate-limit").and_then(|v| v.parse().ok()),
|
||
}),
|
||
"sync" => Command::Sync {
|
||
message: opt_value("--message"),
|
||
},
|
||
"migrate" => {
|
||
if flag("--export") {
|
||
Command::Migrate {
|
||
export: true,
|
||
output: opt_value("--output").map(std::path::PathBuf::from),
|
||
bundle: None,
|
||
}
|
||
} else {
|
||
Command::Migrate {
|
||
export: false,
|
||
output: None,
|
||
bundle: rest.first().map(std::path::PathBuf::from),
|
||
}
|
||
}
|
||
},
|
||
"top" => Command::Top {
|
||
period: opt_value("--period"),
|
||
},
|
||
"report" => Command::Report {
|
||
last_week: flag("--last-week"),
|
||
last_month: flag("--last-month"),
|
||
output: opt_value("--output").map(PathBuf::from),
|
||
},
|
||
"projects" => Command::Projects {
|
||
name: rest.first().cloned(),
|
||
},
|
||
"timeline" => Command::Timeline {
|
||
agent: rest.first().cloned(),
|
||
project: opt_value("--project"),
|
||
since: opt_value("--since"),
|
||
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(50),
|
||
},
|
||
"log" => Command::Log {
|
||
agent: rest.first().cloned(),
|
||
kind: opt_value("--kind"),
|
||
since: opt_value("--since"),
|
||
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(50),
|
||
follow: flag("--follow"),
|
||
},
|
||
"history" => Command::History {
|
||
kind: opt_value("--kind"),
|
||
cwd: opt_value("--cwd"),
|
||
search: opt_value("--search"),
|
||
failed: flag("--failed"), session: opt_value("--session"),
|
||
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(100),
|
||
rerun: opt_value("--rerun").and_then(|v| v.parse().ok()),
|
||
range: rest.first().cloned(),
|
||
save: opt_value("--save").map(std::path::PathBuf::from),
|
||
},
|
||
"playbook" => {
|
||
if rest.is_empty() {
|
||
app.log.error(crate::i18n::tr("usage: playbook <file> — ex: playbook deploy.yaml"));
|
||
return Ok(false);
|
||
}
|
||
Command::Playbook {
|
||
path: std::path::PathBuf::from(rest[0].clone()),
|
||
var: rest
|
||
.iter()
|
||
.filter_map(|t| t.strip_prefix("--var=").map(|v| v.to_string()))
|
||
.collect(),
|
||
}
|
||
},
|
||
"lab" => Command::Lab(crate::cli::LabArgs {
|
||
agents: opt_value("--agents"),
|
||
task: opt_value("--task").map(std::path::PathBuf::from),
|
||
parallel: flag("--parallel"),
|
||
timeout: opt_value("--timeout").and_then(|v| v.parse().ok()),
|
||
list: flag("--list"),
|
||
}),
|
||
"plugins" => Command::Plugins(crate::cli::PluginsArgs {
|
||
test: opt_value("--test"),
|
||
}),
|
||
"init" => Command::Init {
|
||
force: flag("--force"),
|
||
template: opt_value("--template"),
|
||
},
|
||
"search" => {
|
||
let words: Vec<String> = rest
|
||
.iter()
|
||
.filter(|t| !t.starts_with("--"))
|
||
.cloned()
|
||
.collect();
|
||
let kw = words.join(" ");
|
||
Command::Search {
|
||
keyword: if kw.is_empty() { None } else { Some(kw) },
|
||
category: None,
|
||
tag: opt_value("--tag"),
|
||
}
|
||
}
|
||
"info" => Command::Info {
|
||
agent: need("agent name")?,
|
||
},
|
||
"start" => Command::Start(StartArgs {
|
||
agent: rest.first().cloned(),
|
||
background: flag("--background") || flag("-b"),
|
||
profile: opt_value("--profile"),
|
||
model: opt_value("--model"),
|
||
provider: opt_value("--provider"),
|
||
no_sandbox: flag("--no-sandbox"),
|
||
..Default::default()
|
||
}),
|
||
"stop" => Command::Stop {
|
||
agent: need("agent name")?,
|
||
force: flag("--force"),
|
||
timeout: None,
|
||
},
|
||
"restart" => Command::Restart {
|
||
start: StartArgs {
|
||
agent: rest.first().cloned(),
|
||
background: flag("--background") || flag("-b"),
|
||
profile: opt_value("--profile"),
|
||
model: opt_value("--model"),
|
||
provider: opt_value("--provider"),
|
||
..Default::default()
|
||
},
|
||
force: flag("--force"),
|
||
timeout: None,
|
||
},
|
||
"install" => Command::Install {
|
||
agent: need("agent name")?,
|
||
method: method_flag(),
|
||
force: flag("--force"),
|
||
provider: opt_value("--provider"),
|
||
model: opt_value("--model"),
|
||
no_config: flag("--no-config"),
|
||
},
|
||
"uninstall" => Command::Uninstall {
|
||
agent: need("agent name")?,
|
||
purge: flag("--purge"),
|
||
},
|
||
"update" => {
|
||
if flag("--all") {
|
||
Command::Update {
|
||
agent: None,
|
||
all: true,
|
||
rollback: opt_value("--rollback"),
|
||
}
|
||
} else if let Some(rb) = opt_value("--rollback") {
|
||
Command::Update {
|
||
agent: None,
|
||
all: false,
|
||
rollback: Some(rb),
|
||
}
|
||
} else {
|
||
Command::Update {
|
||
agent: rest.first().cloned(),
|
||
all: false,
|
||
rollback: None,
|
||
}
|
||
}
|
||
}
|
||
"audit" => Command::Audit,
|
||
"service" => match rest.first().map(|s| s.as_str()) {
|
||
Some(action) if rest.len() >= 2 => Command::Service(match action {
|
||
"install" => crate::cli::ServiceCmd::Install {
|
||
agent: rest[1].clone(),
|
||
autostart: flag("--autostart"),
|
||
},
|
||
"uninstall" => crate::cli::ServiceCmd::Uninstall {
|
||
agent: rest[1].clone(),
|
||
},
|
||
"status" => crate::cli::ServiceCmd::Status {
|
||
agent: rest[1].clone(),
|
||
},
|
||
_ => {
|
||
app.log
|
||
.error("usage: service install|uninstall|status <agent> [--autostart]");
|
||
return Ok(false);
|
||
}
|
||
}),
|
||
_ => {
|
||
app.log
|
||
.error("usage: service install|uninstall|status <agent> [--autostart]");
|
||
return Ok(false);
|
||
}
|
||
},
|
||
"schedule" => match rest.first().map(|s| s.as_str()) {
|
||
Some("add") => Command::Schedule(crate::cli::ScheduleCmd::Add {
|
||
command: rest[1..]
|
||
.iter()
|
||
.filter(|a| !a.starts_with("--"))
|
||
.cloned()
|
||
.collect(),
|
||
at: opt_value("--at"),
|
||
}),
|
||
Some("list") | None => Command::Schedule(crate::cli::ScheduleCmd::List),
|
||
Some("remove") if rest.len() >= 2 => Command::Schedule(
|
||
crate::cli::ScheduleCmd::Remove {
|
||
id: rest[1].clone(),
|
||
},
|
||
),
|
||
Some("run") if rest.len() >= 2 => Command::Schedule(crate::cli::ScheduleCmd::Run {
|
||
id: rest[1].clone(),
|
||
}),
|
||
_ => {
|
||
app.log.error(crate::i18n::tr("usage: schedule add <cmd...> | schedule list | schedule remove <id>"));
|
||
return Ok(false);
|
||
}
|
||
},
|
||
"doctor" => Command::Doctor {
|
||
fix: flag("--fix"),
|
||
watch: opt_value("--watch").and_then(|v| v.parse().ok()),
|
||
},
|
||
"run" => {
|
||
let agent = need("agent name")?;
|
||
Command::Run {
|
||
agent,
|
||
model: opt_value("--model"),
|
||
provider: opt_value("--provider"),
|
||
no_sandbox: flag("--no-sandbox"),
|
||
container: flag("--container"),
|
||
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
|
||
}
|
||
}
|
||
"config" => Command::Config(crate::cli::ConfigCmd::Show),
|
||
"completion" => {
|
||
let name = need("shell name")?;
|
||
let shell: clap_complete::Shell = name
|
||
.parse()
|
||
.map_err(|e: String| anyhow!("unknown shell '{name}': {e}"))?;
|
||
Command::Completion {
|
||
shell,
|
||
installed: flag("--installed"),
|
||
}
|
||
}
|
||
"self-update" => Command::SelfUpdate {
|
||
check: flag("--check"),
|
||
to: opt_value("--to").map(PathBuf::from),
|
||
},
|
||
"self-uninstall" => Command::SelfUninstall,
|
||
"export" => Command::Export {
|
||
output: opt_value("--output")
|
||
.map(PathBuf::from)
|
||
.unwrap_or_else(|| PathBuf::from("am-export.json")),
|
||
},
|
||
"import" => Command::Import {
|
||
file: need("file")?.into(),
|
||
},
|
||
"tip" => Command::Tip {
|
||
random: flag("--random"),
|
||
},
|
||
other => {
|
||
app.log
|
||
.error(crate::tr_fmt!("unknown command '{}' — type 'help'", other));
|
||
return Ok(false);
|
||
}
|
||
};
|
||
// After a successful self-uninstall the executable is scheduled for
|
||
// deletion: leave the session instead of pretending nothing happened.
|
||
let exit_after = matches!(cmd, "self-uninstall");
|
||
commands::execute_command(app, &command)?;
|
||
Ok(exit_after)
|
||
}
|
||
|
||
/// Whether a word is one of the REPL's shell-level commands (ls, ps, ...).
|
||
fn is_shell_command(word: &str) -> bool {
|
||
matches!(
|
||
word,
|
||
"ls" | "dir" | "ps" | "where" | "get" | "cd" | "shell" | "theme" | "lang" | "help" | "version" | "exit"
|
||
)
|
||
}
|
||
|
||
/// Commands whose execution can change the agent completion pool
|
||
/// (installed agents, aliases, groups): the Tab completer snapshots that
|
||
/// pool at startup, so the line editor is rebuilt after one of these runs.
|
||
fn completion_pool_mutating(first: &str) -> bool {
|
||
matches!(
|
||
first,
|
||
"install" | "uninstall" | "update" | "alias" | "config" | "import" | "self-update"
|
||
)
|
||
}
|
||
|
||
/// Whether a word is one of the am commands (everything else is executed by
|
||
/// the system shell).
|
||
pub fn is_am_command(word: &str) -> bool {
|
||
matches!(
|
||
word,
|
||
"list"
|
||
| "status"
|
||
| "sessions"
|
||
| "stats"
|
||
| "top"
|
||
| "report"
|
||
| "projects"
|
||
| "timeline"
|
||
| "log"
|
||
| "logs"
|
||
| "history"
|
||
| "init"
|
||
| "alias"
|
||
| "secret"
|
||
| "open"
|
||
| "watch"
|
||
| "search"
|
||
| "info"
|
||
| "install"
|
||
| "uninstall"
|
||
| "update"
|
||
| "start"
|
||
| "stop"
|
||
| "restart"
|
||
| "run"
|
||
| "doctor"
|
||
| "config"
|
||
| "completion"
|
||
| "self-update"
|
||
| "self-uninstall"
|
||
| "export"
|
||
| "import"
|
||
| "dashboard"
|
||
| "favorite"
|
||
| "unfavorite"
|
||
| "note"
|
||
| "tag"
|
||
| "untag"
|
||
| "tags"
|
||
| "profile"
|
||
| "man"
|
||
| "tip"
|
||
| "models"
|
||
| "catalog"
|
||
| "registry"
|
||
| "providers"
|
||
| "suggest"
|
||
| "ask"
|
||
| "ai"
|
||
| "setup"
|
||
| "audit"
|
||
| "service"
|
||
| "schedule"
|
||
| "monitor"
|
||
| "web"
|
||
| "serve"
|
||
| "sync"
|
||
| "migrate"
|
||
| "playbook"
|
||
| "lab"
|
||
| "plugins"
|
||
)
|
||
}
|
||
|
||
/// Resolve the path argument of ls/dir (defaults to the current directory).
|
||
fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
|
||
// A leading '--' ends flag parsing, so 'ls -- -h' can list a file
|
||
// literally named "-h" instead of showing the help.
|
||
let args: Vec<&String> = if rest.first().map(String::as_str) == Some("--") {
|
||
rest.iter().skip(1).collect()
|
||
} else {
|
||
rest.iter().collect()
|
||
};
|
||
Ok(match args.first() {
|
||
Some(p) => crate::config::expand_path(p),
|
||
None => current_dir(),
|
||
})
|
||
}
|
||
|
||
/// Print a table and remember it as the last result (for where/get).
|
||
/// The table is rendered to fit the terminal width so rows never wrap.
|
||
fn show_table(app: &App, last: &mut Option<DataTable>, table: DataTable) {
|
||
let width = crate::output::terminal_width().unwrap_or(120);
|
||
print!(
|
||
"{}",
|
||
table.render_themed(app.theme(), app.color(), width)
|
||
);
|
||
*last = Some(table);
|
||
}
|
||
|
||
/// Evaluate a Nushell-style pipeline: ls | ps | where | get, in order.
|
||
fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<bool> {
|
||
let parts: Vec<&str> = line
|
||
.split('|')
|
||
.map(str::trim)
|
||
.filter(|p| !p.is_empty())
|
||
.collect();
|
||
let mut table: Option<DataTable> = None;
|
||
for part in &parts {
|
||
let tokens = split_command(part)?;
|
||
if tokens.is_empty() {
|
||
continue;
|
||
}
|
||
let cmd = tokens[0].as_str();
|
||
let rest = &tokens[1..];
|
||
// Nushell-style: 'ls -h' inside a pipeline shows the command help.
|
||
if rest
|
||
.iter()
|
||
.take_while(|t| t.as_str() != "--")
|
||
.any(|t| t == "-h" || t == "--help")
|
||
&& matches!(cmd, "ls" | "dir" | "ps" | "where" | "get")
|
||
{
|
||
if let Err(e) = crate::help::print_command(app, cmd) {
|
||
app.log.error(format!("{e:#}"));
|
||
}
|
||
return Ok(false);
|
||
}
|
||
match cmd {
|
||
"ls" => {
|
||
table = Some(nav::ls_table(&ls_target(rest)?, app.color())?);
|
||
}
|
||
"dir" if cfg!(windows) => {
|
||
table = Some(nav::ls_table(&ls_target(rest)?, app.color())?);
|
||
}
|
||
"ps" => {
|
||
table = Some(ps::ps_table());
|
||
}
|
||
"where" => {
|
||
let base = table
|
||
.as_ref()
|
||
.ok_or_else(|| anyhow!("'where' needs a table before it"))?;
|
||
if rest.len() < 3 {
|
||
anyhow::bail!("usage: where <column> <op> <value>");
|
||
}
|
||
table = Some(crate::tables::filter_where(
|
||
base,
|
||
&rest[0],
|
||
&rest[1],
|
||
&rest[2..].join(" "),
|
||
)?);
|
||
}
|
||
"get" => {
|
||
let base = table
|
||
.as_ref()
|
||
.ok_or_else(|| anyhow!("'get' needs a table before it"))?;
|
||
let field = rest.first().ok_or_else(|| anyhow!("usage: get <column>"))?;
|
||
table = Some(crate::tables::get_column(base, field)?);
|
||
}
|
||
other => anyhow::bail!(
|
||
"'{other}' cannot be used in a pipeline — use ls, ps, where or get"
|
||
),
|
||
}
|
||
}
|
||
if let Some(t) = table {
|
||
show_table(app, last, t);
|
||
}
|
||
Ok(false)
|
||
}
|
||
|
||
/// Split a command line into tokens.
|
||
///
|
||
/// On Windows, backslashes are literal path separators: 'cd dev\git\...'
|
||
/// must reach change_dir intact, and double quotes group tokens ("" escapes
|
||
/// a quote), like cmd/PowerShell. The Unix shell_words splitter would eat
|
||
/// every backslash as an escape character, breaking Windows paths.
|
||
/// On Unix, the classic backslash-escaping rules are kept.
|
||
fn split_command(line: &str) -> Result<Vec<String>> {
|
||
#[cfg(windows)]
|
||
{
|
||
let mut tokens: Vec<String> = Vec::new();
|
||
let mut cur = String::new();
|
||
let mut chars = line.chars().peekable();
|
||
let mut in_quotes = false;
|
||
while let Some(c) = chars.next() {
|
||
match c {
|
||
'"' => {
|
||
if in_quotes && chars.peek() == Some(&'"') {
|
||
chars.next();
|
||
cur.push('"');
|
||
} else {
|
||
in_quotes = !in_quotes;
|
||
}
|
||
}
|
||
c if c.is_whitespace() && !in_quotes => {
|
||
if !cur.is_empty() {
|
||
tokens.push(std::mem::take(&mut cur));
|
||
}
|
||
}
|
||
c => cur.push(c),
|
||
}
|
||
}
|
||
if in_quotes {
|
||
anyhow::bail!("unterminated quote");
|
||
}
|
||
if !cur.is_empty() {
|
||
tokens.push(cur);
|
||
}
|
||
Ok(tokens)
|
||
}
|
||
#[cfg(not(windows))]
|
||
{
|
||
shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))
|
||
}
|
||
}
|
||
|
||
/// Run one system command through the active shell (inherited stdio).
|
||
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
|
||
app.log.cmd(&format!("{}: {line}", session.current.name));
|
||
let started = std::time::Instant::now();
|
||
let code = session.run(line)?;
|
||
if code != 0 {
|
||
app.log
|
||
.error(crate::tr_fmt!("command exited with code {}", code));
|
||
}
|
||
app.emit(
|
||
&Event::now(EventKind::Shell)
|
||
.with_args(vec![line.to_string()])
|
||
.with_exit_code(code)
|
||
.with_duration(started.elapsed().as_secs())
|
||
.with_cwd(Some(current_dir_string())),
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Change the session directory (in-process, unlike shell commands).
|
||
fn change_dir(args: &[String]) -> Result<()> {
|
||
let target: PathBuf = match args.first().map(|s| s.as_str()) {
|
||
None | Some("~") => crate::config::home_dir()
|
||
.ok_or_else(|| anyhow!("cannot find the home directory"))?,
|
||
Some(p) => crate::config::expand_path(p),
|
||
};
|
||
std::env::set_current_dir(&target)
|
||
.map_err(|e| anyhow!("cd {}: {e}", target.display()))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Show or switch the system shell used by the gateway.
|
||
fn shell_command(session: &mut ShellSession, arg: Option<&str>) {
|
||
match arg {
|
||
None | Some("list") => print_shell_status(session),
|
||
Some(name) => match session.switch(name) {
|
||
Ok(spec) => println!(
|
||
"{}",
|
||
format!("shell: {} ({})", spec.name, spec.title).green().bold()
|
||
),
|
||
Err(e) => eprintln!("{} {}", "error:".red().bold(), e),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 'theme' in the REPL: list the themes, or switch with 'theme <name>'.
|
||
fn theme_command(app: &App, arg: Option<&str>) {
|
||
match arg.map(str::trim).filter(|s| !s.is_empty()) {
|
||
None => print_theme_status(app),
|
||
Some(name) => match crate::theme::find(name) {
|
||
Some(t) => {
|
||
app.set_theme(t);
|
||
// Persist the choice (settings.theme in the active config
|
||
// file) so it survives an exit and restart.
|
||
if let Err(e) =
|
||
crate::commands::config_cmd::persist_setting(app, "settings.theme", t.name)
|
||
{
|
||
app.log.error(format!(
|
||
"theme applied for this session, but it could not be saved to the config: {e:#}"
|
||
));
|
||
}
|
||
let color = app.color();
|
||
println!(
|
||
"{} {}",
|
||
if color { t.hdr("theme:") } else { "theme:".to_string() },
|
||
format!("{} — {}", t.acc(t.name), t.title)
|
||
);
|
||
}
|
||
None => app.log.error(format!(
|
||
"unknown theme '{name}' — available: {}",
|
||
crate::theme::names().join(", ")
|
||
)),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// List the available themes, highlighting the active one.
|
||
fn print_theme_status(app: &App) {
|
||
let theme = app.theme();
|
||
let color = app.color();
|
||
let hdr = |s: &str| -> String {
|
||
if color {
|
||
theme.hdr(s)
|
||
} else {
|
||
s.to_string()
|
||
}
|
||
};
|
||
println!("{}", hdr("Theme"));
|
||
println!(
|
||
" ◉ {} — {}",
|
||
theme.acc(theme.name),
|
||
theme.title
|
||
);
|
||
let others: Vec<&str> = crate::theme::THEMES
|
||
.iter()
|
||
.map(|t| t.name)
|
||
.filter(|n| *n != theme.name)
|
||
.collect();
|
||
println!(" {}", theme.dimmed(&format!("⇄ {}", others.join(" · "))));
|
||
println!(
|
||
" {}",
|
||
theme.dimmed("'theme <name>' to switch (saved to the config) · 'am --theme <name>' for a one-shot")
|
||
);
|
||
}
|
||
|
||
/// Show or switch the output language (issue #74). With no argument the
|
||
/// current locale is shown; with 'fr' or 'en' it is switched for the rest
|
||
/// of the session (the i18n global follows immediately).
|
||
fn lang_command(app: &App, arg: Option<&str>) {
|
||
match arg.map(str::trim).filter(|s| !s.is_empty()) {
|
||
None => print_lang_status(app),
|
||
Some(code) => match code.to_ascii_lowercase().as_str() {
|
||
"fr" | "french" | "français" => {
|
||
app.set_lang(crate::i18n::Lang::Fr);
|
||
println!("language: {}", crate::i18n::Lang::Fr.label());
|
||
}
|
||
"en" | "english" | "anglais" => {
|
||
app.set_lang(crate::i18n::Lang::En);
|
||
println!("language: {}", crate::i18n::Lang::En.label());
|
||
}
|
||
other => app.log.error(format!(
|
||
"unknown language '{other}' — use 'lang fr' or 'lang en'"
|
||
)),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Show the active language and the switch hint.
|
||
fn print_lang_status(app: &App) {
|
||
let lang = app.lang();
|
||
let color = app.color();
|
||
let theme = app.theme();
|
||
let hdr = |s: &str| -> String {
|
||
if color {
|
||
theme.hdr(s)
|
||
} else {
|
||
s.to_string()
|
||
}
|
||
};
|
||
println!("{}", hdr("Language"));
|
||
println!(" ◉ {} ({})", theme.acc(lang.label()), lang.code());
|
||
println!(
|
||
" {}",
|
||
theme.dimmed("'lang <fr|en>' to switch (this session) · 'am --lang <fr|en>' at startup")
|
||
);
|
||
}
|
||
|
||
fn print_shell_status(session: &ShellSession) {
|
||
let others: Vec<&str> = session
|
||
.available
|
||
.iter()
|
||
.filter(|s| s.name != session.current.name)
|
||
.map(|s| s.name)
|
||
.collect();
|
||
println!(
|
||
"current shell: {} — {}{}",
|
||
session.current.name.bold(),
|
||
session.current.title,
|
||
if session.is_default() { " (default)" } else { "" }
|
||
);
|
||
if others.is_empty() {
|
||
println!("no other shell found on PATH");
|
||
} else {
|
||
println!("available: {}", others.join(", "));
|
||
}
|
||
println!("use 'shell <name>' to switch; set settings.default_shell in the config to make it permanent");
|
||
}
|
||
|
||
/// 'help' in the REPL: the overview, a command/agent topic, the command
|
||
/// list, or a --find search — the same Nushell-style help as 'am help'.
|
||
fn run_help_command(app: &App, rest: &[String]) -> Result<bool> {
|
||
let mut iter = rest.iter();
|
||
match iter.next().map(|s| s.as_str()) {
|
||
None => crate::help::print_general(app),
|
||
Some("--find") => {
|
||
let text = iter.next().map(|s| s.as_str()).unwrap_or("");
|
||
crate::help::print_find(app, text);
|
||
}
|
||
Some("-h") | Some("--help") => {
|
||
crate::help::print_command(app, "help")?;
|
||
}
|
||
Some(topic) => {
|
||
crate::help::print_command(app, topic)?;
|
||
}
|
||
}
|
||
Ok(false)
|
||
}
|
||
|
||
#[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, None)
|
||
}
|
||
|
||
fn test_session() -> ShellSession {
|
||
ShellSession {
|
||
current: crate::shell::find("pwsh").unwrap(),
|
||
default: crate::shell::find("pwsh").unwrap(),
|
||
available: vec![
|
||
crate::shell::find("pwsh").unwrap(),
|
||
crate::shell::find("bash").unwrap(),
|
||
],
|
||
}
|
||
}
|
||
|
||
#[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()));
|
||
let (_, cands) = c.candidates_for("sh", 2);
|
||
assert!(cands.contains(&"shell".to_string()));
|
||
let (_, cands) = c.candidates_for("self", 4);
|
||
assert!(cands.contains(&"self-update".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 pool_mutating_commands_are_detected() {
|
||
// These commands change the agents/aliases/groups that Tab proposes:
|
||
// the line editor must be rebuilt after them.
|
||
for cmd in [
|
||
"install", "uninstall", "update", "alias", "config", "import", "self-update",
|
||
] {
|
||
assert!(
|
||
completion_pool_mutating(cmd),
|
||
"{cmd} must refresh the completion pool"
|
||
);
|
||
}
|
||
// Read-only and system commands must not trigger a rebuild.
|
||
for cmd in ["start", "stop", "list", "status", "ls", "cd", "theme", "help", "exit"] {
|
||
assert!(
|
||
!completion_pool_mutating(cmd),
|
||
"{cmd} must not refresh the completion pool"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn completes_config_subcommands() {
|
||
let c = completer();
|
||
let (_, cands) = c.candidates_for("config sh", 9);
|
||
assert!(cands.contains(&"show".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn completes_shell_names() {
|
||
let c = completer();
|
||
let (_, cands) = c.candidates_for("shell pw", 8);
|
||
assert!(cands.contains(&"pwsh".to_string()));
|
||
let (_, cands) = c.candidates_for("shell po", 8);
|
||
assert!(cands.contains(&"powershell".to_string()));
|
||
let (_, cands) = c.candidates_for("shell ", 6);
|
||
assert!(cands.contains(&"list".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn banner_art_is_rectangular() {
|
||
let rows = banner_art();
|
||
assert_eq!(rows.len(), 6);
|
||
for row in &rows {
|
||
assert_eq!(row.chars().count(), BOX_WIDTH, "row width mismatch: {row:?}");
|
||
}
|
||
assert!(rows[0].contains("█████"));
|
||
}
|
||
|
||
#[test]
|
||
fn banner_box_lines_are_aligned() {
|
||
let session = test_session();
|
||
let text = banner_box(
|
||
"9.9.9",
|
||
65,
|
||
3,
|
||
12,
|
||
&session,
|
||
"20260815_123456_abcdef",
|
||
"/home/alice/dev/agent-manager",
|
||
"fr",
|
||
crate::theme::default_theme(),
|
||
false,
|
||
);
|
||
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
|
||
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned box:\n{text}");
|
||
assert!(text.contains("Shell Gateway"));
|
||
assert!(text.contains("pwsh"));
|
||
assert!(text.contains("PowerShell 7"));
|
||
assert!(text.contains("(default)"));
|
||
assert!(text.contains("bash"));
|
||
assert!(text.contains("/help for commands"));
|
||
assert!(text.contains("12 external"));
|
||
assert!(text.contains("3 managed"));
|
||
assert!(text.contains("agent-manager"));
|
||
// The splash screen shows the active theme and every other theme.
|
||
assert!(text.contains("Theme"));
|
||
assert!(text.contains("◉ default"));
|
||
assert!(text.contains("theme default"));
|
||
assert!(text.contains("--theme <name>"));
|
||
for t in crate::theme::THEMES {
|
||
if t.name != "default" {
|
||
assert!(text.contains(t.name), "banner misses theme {}", t.name);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn banner_commands_section_lists_every_command() {
|
||
let session = test_session();
|
||
let text = banner_box(
|
||
"9.9.9",
|
||
65,
|
||
3,
|
||
12,
|
||
&session,
|
||
"20260815_123456_abcdef",
|
||
"/home/alice/dev/agent-manager",
|
||
"fr",
|
||
crate::theme::default_theme(),
|
||
false,
|
||
);
|
||
// The Commands section starts at the "Commands" heading and runs to
|
||
// the stats footer. Every command the REPL knows (am + shell) must
|
||
// appear there.
|
||
let section = text.split_once("Commands").map(|(_, r)| r).unwrap_or_default();
|
||
for (cmd, _) in COMMAND_DESCRIPTIONS {
|
||
assert!(
|
||
section.contains(&format!(" {cmd} ")),
|
||
"banner Commands section misses {cmd}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn banner_box_is_themed_and_still_aligned() {
|
||
let session = test_session();
|
||
let ocean = crate::theme::find("ocean").unwrap();
|
||
let text = banner_box(
|
||
"9.9.9",
|
||
65,
|
||
3,
|
||
12,
|
||
&session,
|
||
"20260815_123456_abcdef",
|
||
"/home/alice/dev/agent-manager",
|
||
"fr",
|
||
ocean,
|
||
true,
|
||
);
|
||
// ANSI color codes are present with the ocean theme...
|
||
assert!(text.contains("\x1b["), "no ANSI in themed banner:\n{text}");
|
||
assert!(strip_ansi(&text).contains("◉ ocean"));
|
||
// ...and the visible box stays aligned (strip the ANSI codes).
|
||
let widths: Vec<usize> = text
|
||
.lines()
|
||
.map(|l| strip_ansi(l).chars().count())
|
||
.collect();
|
||
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned box:\n{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn completer_produces_suggestions_with_spans() {
|
||
// The reedline completer maps (start, candidates) onto suggestions
|
||
// whose span covers exactly the typed word.
|
||
let mut c = completer();
|
||
let suggs = c.complete("inst", 5);
|
||
assert!(!suggs.is_empty());
|
||
for s in &suggs {
|
||
assert_eq!(s.span.start, 0, "completes from the line start");
|
||
assert_eq!(s.span.end, 5);
|
||
assert!(s.value.starts_with("inst"), "value: {}", s.value);
|
||
}
|
||
let suggs = c.complete("install cl", 11);
|
||
assert_eq!(suggs[0].span.start, 8);
|
||
assert_eq!(suggs[0].span.end, 11);
|
||
assert!(suggs.iter().all(|s| s.value.starts_with("cl")));
|
||
}
|
||
|
||
#[test]
|
||
fn completer_suggestions_carry_descriptions() {
|
||
let mut c = completer();
|
||
let suggs = c.complete("star", 5);
|
||
assert!(suggs.iter().any(|s| {
|
||
s.value == "start" && s.description.as_deref() == Some("start an agent")
|
||
}));
|
||
// Path candidates (cd) have no description: the menu shows them in
|
||
// columnar layout instead of the single description column. The
|
||
// absolute path keeps the test independent from the process cwd.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
std::fs::create_dir_all(dir.path().join("src")).unwrap();
|
||
let line = format!("cd {}", dir.path().display());
|
||
let suggs = c.complete(&line, line.len());
|
||
let sep = std::path::MAIN_SEPARATOR;
|
||
let expected = format!("{}{sep}", dir.path().display());
|
||
assert!(
|
||
suggs.iter().any(|s| s.value == expected),
|
||
"expected {expected}: {suggs:?}"
|
||
);
|
||
assert!(suggs.iter().all(|s| s.description.is_none()));
|
||
}
|
||
|
||
#[test]
|
||
fn sgr_theme_strings_map_to_ansi_term_styles() {
|
||
// "1;38;5;81" = bold + 256-color 81 (ocean accent).
|
||
let s = sgr_to_style("1;38;5;81");
|
||
assert!(s.is_bold, "accent should be bold");
|
||
let fg = s.foreground.expect("accent should set a color");
|
||
assert_eq!(fg, Color::Fixed(81));
|
||
// Basic ANSI colors ("36" = cyan) map onto the named palette.
|
||
let s = sgr_to_style("36");
|
||
assert_eq!(s.foreground, Some(Color::Cyan));
|
||
// The mono theme uses empty SGR strings: default style, no color.
|
||
let s = sgr_to_style("");
|
||
assert!(!s.is_bold);
|
||
assert_eq!(s.foreground, None);
|
||
}
|
||
|
||
#[test]
|
||
fn menu_styles_follow_the_theme() {
|
||
let ocean = crate::theme::find("ocean").unwrap();
|
||
let ts = menu_text_style(ocean, true);
|
||
assert!(
|
||
ts.selected_text_style.is_reverse,
|
||
"the highlighted choice must be in reverse video"
|
||
);
|
||
assert_eq!(
|
||
ts.selected_text_style.foreground,
|
||
Some(Color::Fixed(81)),
|
||
"the highlighted choice uses the accent color"
|
||
);
|
||
assert_eq!(
|
||
ts.description_style.foreground,
|
||
Some(Color::Fixed(117)),
|
||
"descriptions use the theme info color"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn banner_top_border_right_dashes_keep_the_frame_color() {
|
||
let session = test_session();
|
||
let ocean = crate::theme::find("ocean").unwrap();
|
||
let text = banner_box(
|
||
"9.9.9",
|
||
65,
|
||
3,
|
||
12,
|
||
&session,
|
||
"20260815_123456_abcdef",
|
||
"/home/alice/dev/agent-manager",
|
||
"fr",
|
||
ocean,
|
||
true,
|
||
);
|
||
let top = text.lines().next().unwrap();
|
||
// The painted title ends with a reset; the dashes after it must be
|
||
// re-painted with the frame color instead of staying default.
|
||
let Some(reset_pos) = top.find("\x1b[0m") else {
|
||
panic!("no title reset in top border: {top:?}");
|
||
};
|
||
let tail = &top[reset_pos + "\x1b[0m".len()..];
|
||
assert!(tail.contains('─'), "no dashes after the title: {tail:?}");
|
||
assert!(
|
||
tail.contains("\x1b["),
|
||
"right dashes not re-painted with the frame color: {tail:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn is_am_command_recognizes_known_commands() {
|
||
for cmd in [
|
||
"list", "status", "search", "info", "install", "uninstall", "update", "start",
|
||
"stop", "restart", "run", "doctor", "config", "completion", "self-update",
|
||
"self-uninstall", "export", "import", "ask", "serve", "registry",
|
||
] {
|
||
assert!(is_am_command(cmd), "{cmd}");
|
||
}
|
||
for cmd in ["ls", "dir", "echo", "Get-ChildItem", "git", "shell", "help", "cd"] {
|
||
assert!(!is_am_command(cmd), "{cmd}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn format_elapsed_renders_units() {
|
||
assert_eq!(format_elapsed(Duration::from_secs(0)), "0s");
|
||
assert_eq!(format_elapsed(Duration::from_secs(59)), "59s");
|
||
assert_eq!(format_elapsed(Duration::from_secs(65)), "1m05s");
|
||
assert_eq!(format_elapsed(Duration::from_secs(3725)), "1h02m");
|
||
}
|
||
|
||
#[test]
|
||
fn shorten_head_keeps_the_tail() {
|
||
assert_eq!(shorten_head("abc", 10), "abc");
|
||
let s = shorten_head("/very/long/path/here", 8);
|
||
assert!(s.starts_with('…'));
|
||
assert!(s.ends_with("here"));
|
||
assert!(s.chars().count() <= 9);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn split_command_keeps_windows_backslashes() {
|
||
// The core regression: 'cd dev\git\...' must not lose its
|
||
// backslashes (they are path separators on Windows, not escapes).
|
||
let tokens = split_command("cd dev\\git\\Rust\\agent-manager\\").unwrap();
|
||
assert_eq!(tokens, vec!["cd", "dev\\git\\Rust\\agent-manager\\"]);
|
||
let tokens = split_command("ls dev\\AI\\").unwrap();
|
||
assert_eq!(tokens, vec!["ls", "dev\\AI\\"]);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn split_command_handles_quotes_and_whitespace() {
|
||
let tokens = split_command("cd \"my dir\"\\src").unwrap();
|
||
assert_eq!(tokens, vec!["cd", "my dir\\src"]);
|
||
let tokens = split_command("echo \"a\"\"b\"").unwrap();
|
||
assert_eq!(tokens, vec!["echo", "a\"b"]);
|
||
assert!(split_command("echo \"unterminated").is_err());
|
||
assert!(split_command(" ").unwrap().is_empty());
|
||
}
|
||
|
||
#[cfg(not(windows))]
|
||
#[test]
|
||
fn split_command_keeps_unix_escaping() {
|
||
let tokens = split_command("ls foo\\ bar").unwrap();
|
||
assert_eq!(tokens, vec!["ls", "foo bar"]);
|
||
let tokens = split_command("cd dev/git").unwrap();
|
||
assert_eq!(tokens, vec!["cd", "dev/git"]);
|
||
}
|
||
}
|