Compare commits

...
6 Commits
Author SHA1 Message Date
bruno 0f4ba71219 v0.2.11 : ligne de statut et banniere — compteurs distincts agents externes (PATH) et agents geres (am install) pour lever l ambiguite du 0 installed 2026-08-16 10:08:54 -04:00
bruno e7cb136c38 v0.2.10 : affichage ls/dir adapte a la largeur du terminal — detection de la largeur de la console (AM_TABLE_WIDTH pour tests), troncature de la colonne nom avec …, suppression des colonnes de fin si necessaire, une ligne par entree 2026-08-16 09:38:54 -04:00
bruno 57234b7a38 v0.2.9 : ps/where/get facon Nushell avec mini-pipeline (ls | where size > 1mb | get name), dir = ls sur Windows, ls beaucoup plus rapide (taille des dossiers bornee a 500 entrees), autocomplet : prefixe commun + menu avec descriptions + fallback chemins 2026-08-16 09:11:09 -04:00
bruno 3e1b66809a v0.2.8 : navigation facon Nushell — commande ls en tableau encadre (type, taille, date), completion Tab des dossiers pour cd (niveau par niveau) et des chemins pour ls, descriptions dans le menu Tab 2026-08-15 23:42:32 -04:00
bruno d886de322c v0.2.7 : self-uninstall supprime maintenant le binaire — renommage immediat de l executable en cours dexecution (am.exe.deleteme) + script .bat temporaire avec reessais, balayage des dossiers vides et auto-suppression 2026-08-15 23:19:28 -04:00
bruno bc38cd59e5 v0.2.6 : colonne AGENT (slug) dans am list + desinstallation des agents externes (npm/pip/uv/cargo/bun ou suppression des fichiers, shims .cmd gerees) 2026-08-15 22:20:51 -04:00
13 changed files with 1617 additions and 75 deletions
Generated
+2 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.2.5"
version = "0.2.11"
dependencies = [
"anyhow",
"chrono",
@@ -41,6 +41,7 @@ dependencies = [
"ureq",
"wait-timeout",
"which",
"windows-sys 0.52.0",
"zip",
]
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.2.5"
version = "0.2.11"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
@@ -35,6 +35,9 @@ wait-timeout = "0.2"
which = "7"
zip = "0.6"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Console"] }
[profile.release]
lto = "thin"
strip = true
+14 -3
View File
@@ -90,7 +90,7 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
| am install <agent> | installe (dépendances vérifiées et proposées) |
| am install <agent> --method pip | choisit une méthode d'installation |
| am update <agent> | met à jour vers la dernière version connue |
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) |
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) — gère aussi les agents externes (npm/pip/uv/cargo/bun, sinon suppression des fichiers) |
| am export / am import | sauvegarde et restaure config + état |
### 🚀 Exécuter
@@ -181,13 +181,24 @@ démarrage et mis en évidence** dans la bannière (config
`settings.default_shell`, puis `SHELL`/`COMSPEC`, puis détection sur le
PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish).
❯ ls # exécuté par le shell actif (cmd, pwsh, bash, ...)
❯ ls # liste façon Nushell : tableau encadré (type, taille, date)
❯ dir # sur Windows, même affichage que ls
❯ ls src # un chemin précis — Tab complète fichiers et dossiers
❯ ps # liste des processus (pid, ppid, cpu, mem, threads)
❯ ls | where size > 1mb # filtre la dernière table (>, <, >=, <=, ==, !=, =~)
❯ ps | where name =~ am # =~ cherche dans le texte
❯ ls | get name # sélectionne une colonne
❯ cd <Tab> # complétion des dossiers uniquement, un niveau par Tab
❯ shell # affiche le shell courant + les shells disponibles
❯ shell bash # change le shell de la session
❯ !list # force l'exécution système (même nom qu'une commande am)
❯ !ls · !list # force l'exécution système (même nom qu'une commande am)
❯ cd ~/projets # change le répertoire de la session (persistant)
❯ /help # commandes slash : /help · /version · /exit · /shell <nom>
Le menu Tab affiche une **description** à côté de chaque candidat
(commandes et agents), comme Nushell, et **cycle** entre les propositions à
chaque Tab.
Pour rendre le choix permanent : `settings.default_shell: pwsh` dans
`config.yaml` (voir `am config path`).
+1 -1
View File
@@ -102,7 +102,7 @@ pub enum Command {
#[arg(long)]
force: bool,
},
/// Uninstall an agent and clean its files
/// Uninstall an agent (managed or external) and clean its files
Uninstall {
/// Agent name or alias
agent: String,
+2 -1
View File
@@ -50,10 +50,11 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
return Ok(0);
}
let color = app.color();
let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "SOURCE", "PATH"]);
let mut table = Table::new(vec!["NAME", "AGENT", "VERSION", "STATUS", "SOURCE", "PATH"]);
for r in &rows {
table.row(vec![
r.display_name.clone(),
r.name.clone(),
r.version.clone().unwrap_or_else(|| "—".to_string()),
status_style(&r.status, color),
r.source.clone(),
+123 -48
View File
@@ -3,7 +3,9 @@
//! configuration directories are deleted, then the executable itself.
use crate::app::{self, App};
use anyhow::{bail, Context, Result};
use anyhow::{bail, Result};
#[cfg(unix)]
use anyhow::Context;
use colored::Colorize;
use std::path::{Path, PathBuf};
@@ -54,6 +56,9 @@ pub fn run(app: &App) -> Result<i32> {
}
}
let exe = std::env::current_exe().ok();
let mut renamed: Option<PathBuf> = None;
if roots.is_empty() {
app.log
.info("nothing to remove — agent-manager left no traces on this machine");
@@ -70,6 +75,20 @@ pub fn run(app: &App) -> Result<i32> {
app.log.dry("nothing removed (dry run)");
return Ok(0);
}
// Windows allows renaming a running executable (but not deleting
// it): do it up-front so the original binary disappears immediately,
// even if the deferred deletion has to wait for this process to exit.
renamed = exe.as_deref().and_then(rename_for_deletion);
if let Some(new) = &renamed {
if let Some(old) = &exe {
println!(
"{} {} -> {}",
"renamed:".cyan(),
old.display(),
new.display()
);
}
}
// Remove everything first; any message logged *after* deleting the
// state directory would recreate the log folder, so the removal
// itself is silent and the report below goes to plain stdout.
@@ -88,38 +107,31 @@ pub fn run(app: &App) -> Result<i32> {
}
if !locked.is_empty() {
let scheduled = schedule_locked(&locked);
if scheduled.len() == locked.len() {
if !scheduled.is_empty() {
println!(
"{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
"locked:".yellow(),
locked.len()
);
} else {
println!(
"{} {} entrée(s) verrouillée(s), {} planifiée(s) au prochain redémarrage",
"locked:".yellow(),
locked.len(),
scheduled.len()
);
for path in &locked {
if !scheduled.contains(path) {
eprintln!(
"{} {} (supprimez-le manuellement ou relancez en administrateur)",
"kept:".red(),
path.display()
);
}
}
}
if scheduled.len() != locked.len() {
println!(
"{} {} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
"deferred:".cyan(),
locked.len() - scheduled.len()
);
}
}
}
// 3. Delete the executable itself, then sweep the directories left
// empty behind it.
let exe = std::env::current_exe()
.with_context(|| "cannot locate the agent-manager executable")?;
let cleanup_roots: Vec<PathBuf> = roots.iter().map(|(_, p)| p.clone()).collect();
remove_self(&exe, &cleanup_roots)?;
match &exe {
Some(e) => remove_self(e, renamed.as_deref(), &cleanup_roots)?,
None => app.log
.warn("cannot locate the agent-manager executable — delete it manually"),
}
Ok(0)
}
@@ -201,7 +213,7 @@ fn schedule_locked(_locked: &[PathBuf]) -> Vec<PathBuf> {
/// runs; on Windows a hidden cmd re-tries the deletion right after this
/// process exits (the file is locked while we run). Empty directories left
/// behind under the removed roots are swept afterwards.
fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
fn remove_self(exe: &Path, renamed: Option<&Path>, roots: &[PathBuf]) -> Result<()> {
#[cfg(unix)]
{
std::fs::remove_file(exe)
@@ -227,29 +239,27 @@ fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
// Immediate deletion: a hidden cmd retries for a few seconds, because
// the executable is locked while we are still running.
// Once the file is gone, the now-empty directories (parent, then the
// removed roots) are swept — rd /q only ever removes empty dirs.
let mut script = format!(
"for /L %i in (1,1,20) do @if exist \"{0}\" (del /f /q \"{0}\" >nul 2>&1 & ping -n 2 127.0.0.1 >nul)",
exe.display()
);
if let Some(parent) = exe.parent() {
if roots.iter().any(|r| parent.starts_with(r)) {
script.push_str(&format!(" & rd /q \"{}\" >nul 2>&1", parent.display()));
}
// The file to delete once this process exits: the renamed copy when
// the up-front rename worked, the original path otherwise.
let target = renamed.unwrap_or(exe);
// A temporary batch file retries the deletion for a few seconds (the
// image stays locked until we exit), sweeps the directories left
// empty, then deletes itself. A .bat avoids every cmd /C command-line
// quoting pitfall.
let bat = write_cleanup_bat(exe, target, roots);
if let Some(b) = &bat {
// raw_arg passes the command line verbatim: regular .arg()
// backslash-escapes embedded quotes, which cmd.exe does not
// understand.
use std::os::windows::process::CommandExt as _;
let mut cleanup = std::process::Command::new("cmd");
cleanup.raw_arg("/C");
cleanup.raw_arg(format!("\"{}\"", b.display()));
let _ = cleanup.spawn();
}
for root in roots {
script.push_str(&format!(" & rd /q \"{}\" >nul 2>&1", root.display()));
}
let spawned = std::process::Command::new("cmd")
.args(["/C", &script])
.spawn()
.is_ok();
// Belt and braces: if something keeps the file locked, the standard
// uninstaller mechanism deletes it at the next reboot.
let wide: Vec<u16> = exe
let wide: Vec<u16> = target
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
@@ -257,17 +267,25 @@ fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
let at_reboot = unsafe {
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
};
if !spawned && !at_reboot {
if bat.is_none() && !at_reboot {
bail!(
"cannot schedule deletion of {} — delete it manually",
exe.display()
);
}
println!(
"{} {}",
"agent-manager removed; the executable will be deleted momentarily:".green(),
exe.display()
);
if renamed.is_some() {
println!(
"{} {}",
"agent-manager removed; the executable was renamed and will be deleted momentarily:".green(),
exe.display()
);
} else {
println!(
"{} {}",
"agent-manager removed; the executable will be deleted momentarily:".green(),
exe.display()
);
}
return Ok(());
}
#[cfg(not(any(unix, windows)))]
@@ -279,6 +297,63 @@ fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
}
}
/// Rename the running executable out of the way (Windows allows renaming a
/// running image — only deletion is refused while it executes). Returns the
/// new path on success.
#[cfg(windows)]
fn rename_for_deletion(exe: &Path) -> Option<PathBuf> {
let name = exe.file_name()?.to_string_lossy();
let new = exe.with_file_name(format!("{name}.deleteme"));
std::fs::rename(exe, &new).ok()?;
Some(new)
}
#[cfg(not(windows))]
fn rename_for_deletion(_exe: &Path) -> Option<PathBuf> {
None
}
/// Write a temporary batch file that deletes the target executable once it
/// is unlocked, sweeps the directories left empty and then removes itself.
#[cfg(windows)]
fn write_cleanup_bat(exe: &Path, target: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
let crlf = "\r\n";
let q = "\"";
let mut script = String::new();
let mut line = |s: &str| {
script.push_str(s);
script.push_str(crlf);
};
line("@echo off");
line("set n=0");
line(":retry");
line(&format!("del /f /q {q}{}{q} >nul 2>&1", target.display()));
line(&format!("del /f /q {q}{}{q} >nul 2>&1", exe.display()));
line(&format!(
"if not exist {q}{}{q} (if not exist {q}{}{q} goto sweep)",
target.display(),
exe.display()
));
line("set /a n+=1");
line("if %n% GEQ 60 goto end");
line("ping -n 2 127.0.0.1 >nul");
line("goto retry");
line(":sweep");
if let Some(parent) = exe.parent() {
if roots.iter().any(|r| parent.starts_with(r)) {
line(&format!("rd /q {q}{}{q} >nul 2>&1", parent.display()));
}
}
for root in roots {
line(&format!("rd /q {q}{}{q} >nul 2>&1", root.display()));
}
line(":end");
line(&format!("del {q}%~f0{q} >nul 2>&1"));
let path = std::env::temp_dir().join(format!("am-uninstall-{}.bat", std::process::id()));
std::fs::write(&path, script).ok()?;
Some(path)
}
#[cfg(test)]
mod tests {
use super::*;
+206 -5
View File
@@ -1,19 +1,19 @@
//! uninstall: stop the agent if needed, remove its files, bins and state
//! entry; --purge also removes logs and the user-config definition.
//! External agents (installed outside agent-manager) are removed through
//! their package manager when the catalog knows it, or by deleting the
//! executables found on PATH otherwise.
use super::*;
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
let agent = require_agent(app, agent_name)?;
let entry = app.state.get(&agent.name).ok().flatten();
let Some(entry) = entry else {
app.log.info(&format!(
"agent '{}' is not managed by agent-manager — nothing to uninstall",
agent.name
));
return Ok(0);
return uninstall_external(app, agent);
};
// Stop it first when running.
@@ -65,6 +65,130 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
Ok(0)
}
/// Build the package-manager uninstall command for an external agent whose
/// install method is a known package manager and whose package is recorded.
fn package_plan(agent: &AgentDef) -> Option<Vec<String>> {
use crate::config::InstallType;
let spec = agent.install.as_ref()?;
for m in spec.all_methods() {
let Some(pkg) = m.package.as_deref() else {
continue;
};
let plan = match m.kind {
InstallType::Npm => {
vec!["npm", "uninstall", "--global", pkg]
}
InstallType::Pip => {
if which::which("pip").is_ok() {
vec!["pip", "uninstall", "-y", pkg]
} else if which::which("python3").is_ok() {
vec!["python3", "-m", "pip", "uninstall", "-y", pkg]
} else if which::which("python").is_ok() {
vec!["python", "-m", "pip", "uninstall", "-y", pkg]
} else {
continue;
}
}
InstallType::Uv => vec!["uv", "tool", "uninstall", pkg],
InstallType::Cargo => vec!["cargo", "uninstall", pkg],
InstallType::Bun => vec!["bun", "remove", "--global", pkg],
_ => continue,
};
return Some(plan.into_iter().map(|s| s.to_string()).collect());
}
None
}
/// Best-effort removal of an agent installed outside agent-manager: first the
/// package-manager command when the catalog knows one, then the executable
/// files left on PATH (and their directory when it is dedicated to the
/// agent, e.g. AppData\Local\atomic-agent).
fn uninstall_external(app: &App, agent: &AgentDef) -> Result<i32> {
let first_token = agent.first_token();
// 1. Package-manager plan (npm, pip, uv, cargo, bun).
if let Some(tokens) = package_plan(agent) {
if which::which(&tokens[0]).is_ok() {
app.log.info(&format!(
"agent '{}' is external (installed outside agent-manager) — uninstall with: {}",
agent.name,
tokens.join(" ")
));
if !app.confirm("Run this command?")? {
app.log.info("cancelled — nothing was uninstalled");
return Ok(0);
}
if app.dry_run() {
app.log.dry(format!("would run: {}", tokens.join(" ")));
} else {
// resolve_program handles Windows .cmd/.bat shims (npm,
// pip, ...) which std::process::Command cannot launch.
let (prog, prefix) = crate::runner::resolve_program(&tokens[0]);
let mut args = prefix;
args.extend(tokens[1..].iter().cloned());
let status = ProcessCommand::new(&prog)
.args(&args)
.status()
.with_context(|| format!("failed to run {}", tokens.join(" ")))?;
if !status.success() {
app.log.warn("the command failed — removing the files it left behind");
}
}
}
}
// 2. Whatever executable is still on PATH, delete it directly (and its
// directory when it belongs to the agent).
let still = first_token.as_deref().and_then(|t| which::which(t).ok());
if let Some(path) = still {
let parent = path.parent().map(|p| p.to_path_buf());
let dedicated = parent
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().eq_ignore_ascii_case(&agent.name))
.unwrap_or(false);
let target: PathBuf = if dedicated {
parent.clone().unwrap_or_else(|| path.clone())
} else {
path.clone()
};
app.log.info(&format!(
"remove leftover {} {}",
if dedicated { "directory" } else { "file" },
target.display()
));
if !app.confirm("Delete it?")? {
app.log.info(&format!("'{}' left in place", target.display()));
return Ok(0);
}
if app.dry_run() {
app.log.dry(format!("would remove {}", target.display()));
} else if dedicated {
std::fs::remove_dir_all(&target)
.with_context(|| format!("cannot remove {}", target.display()))?;
} else {
std::fs::remove_file(&target)
.with_context(|| format!("cannot remove {}", target.display()))?;
}
}
// 3. Final verification.
let gone = first_token
.as_deref()
.and_then(|t| which::which(t).ok())
.is_none();
if gone {
app.log
.success(&format!("uninstalled {} (external)", agent.title()));
} else {
app.log.warn(&format!(
"{} is still present on PATH — remove it manually",
agent.title()
));
}
Ok(0)
}
/// Remove the agent entry from the user config file (--purge).
fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
let Some(path) = &app.config_origin else {
@@ -111,3 +235,80 @@ fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn agent_from_yaml(yaml: &str) -> AgentDef {
#[derive(serde::Deserialize)]
struct Wrap {
agents: Vec<AgentDef>,
}
let w: Wrap = serde_yaml::from_str(yaml).unwrap();
w.agents.into_iter().next().unwrap()
}
#[test]
fn package_plan_npm() {
let a = agent_from_yaml(
r#"
agents:
- name: x
install:
type: npm
package: "@scope/pkg"
run: x
"#,
);
let plan = package_plan(&a).unwrap();
assert_eq!(plan, vec!["npm", "uninstall", "--global", "@scope/pkg"]);
}
#[test]
fn package_plan_cargo() {
let a = agent_from_yaml(
r#"
agents:
- name: x
install:
type: cargo
package: my-crate
run: x
"#,
);
let plan = package_plan(&a).unwrap();
assert_eq!(plan, vec!["cargo", "uninstall", "my-crate"]);
}
#[test]
fn package_plan_binary_is_none() {
let a = agent_from_yaml(
r#"
agents:
- name: y
install:
type: binary
repo: owner/repo
binary: y
run: y
"#,
);
assert!(package_plan(&a).is_none());
}
#[test]
fn package_plan_unknown_method_is_none() {
let a = agent_from_yaml(
r#"
agents:
- name: z
install:
type: git
repo: https://example.com/z.git
run: z
"#,
);
assert!(package_plan(&a).is_none());
}
}
+3
View File
@@ -20,13 +20,16 @@ pub mod deps;
pub mod doctor;
pub mod download;
pub mod installers;
pub mod nav;
pub mod output;
pub mod probe;
pub mod ps;
pub mod process;
pub mod repl;
pub mod runner;
pub mod shell;
pub mod state;
pub mod tables;
pub mod toolchain;
pub mod version;
+312
View File
@@ -0,0 +1,312 @@
//! Nushell-inspired navigation helpers: a boxed table listing of files and
//! directories (ls), and directory/path completion for Tab.
use crate::tables::{Cell, DataTable};
use anyhow::{Context, Result};
use colored::Colorize;
use std::path::{Path, PathBuf};
/// Human-readable byte size, like Nushell ("512 B", "1.2 KB", "3.4 MB").
pub fn format_size(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = 1024.0 * 1024.0;
const GB: f64 = 1024.0 * 1024.0 * 1024.0;
let b = bytes as f64;
if b < KB {
format!("{bytes} B")
} else if b < MB {
format!("{:.1} KB", b / KB)
} else if b < GB {
format!("{:.1} MB", b / MB)
} else {
format!("{:.1} GB", b / GB)
}
}
/// Recursive size of a directory, tightly bounded: beyond the entry cap the
/// size is reported as unknown (None). The cap is deliberately small so a
/// listing stays snappy even when it contains huge trees.
pub fn dir_size(path: &Path) -> Option<u64> {
const CAP: usize = 500;
let mut total: u64 = 0;
let mut stack: Vec<PathBuf> = vec![path.to_path_buf()];
let mut visited: usize = 0;
while let Some(dir) = stack.pop() {
let rd = std::fs::read_dir(&dir).ok()?;
for entry in rd.flatten() {
visited += 1;
if visited > CAP {
return None;
}
let p = entry.path();
match std::fs::symlink_metadata(&p) {
Ok(m) if m.is_dir() => stack.push(p),
Ok(m) if m.is_file() => {
total = total.saturating_add(m.len());
}
_ => {}
}
}
}
Some(total)
}
fn modified_str(path: &Path) -> String {
std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.map(|t| {
let dt: chrono::DateTime<chrono::Local> = t.into();
dt.format("%Y-%m-%d %H:%M").to_string()
})
.unwrap_or_else(|| "—".to_string())
}
fn paint(s: &str, color: bool, style: u8) -> String {
if !color {
return s.to_string();
}
match style {
1 => s.blue().bold().to_string(), // directory
2 => s.green().to_string(), // executable
3 => s.cyan().to_string(), // symlink
4 => s.red().to_string(), // broken symlink
_ => s.to_string(),
}
}
fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(windows)]
{
let _ = meta;
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.as_deref(),
Some("exe") | Some("bat") | Some("cmd") | Some("com")
)
}
}
/// Build the ls table (columns: name, type, size, modified). Directories
/// come first, then alphabetical order.
pub fn ls_table(path: &Path, color: bool) -> Result<DataTable> {
let rd = std::fs::read_dir(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut rows: Vec<(String, Vec<Cell>)> = Vec::new();
for entry in rd.flatten() {
let p = entry.path();
let meta = match std::fs::symlink_metadata(&p) {
Ok(m) => m,
Err(_) => continue,
};
let file_type = meta.file_type();
let name = p
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let (kind, style) = if file_type.is_symlink() {
("symlink", if p.exists() { 3 } else { 4 })
} else if file_type.is_dir() {
("dir", 1)
} else if is_executable(&p, &meta) {
("file", 2)
} else {
("file", 0)
};
let (size_text, size_bytes) = if file_type.is_dir() {
match dir_size(&p) {
Some(s) => (format_size(s), Some(s)),
None => ("—".to_string(), None),
}
} else {
(format_size(meta.len()), Some(meta.len()))
};
let mut display = name.clone();
if file_type.is_dir() {
display.push('/');
} else if file_type.is_symlink() {
if let Ok(target) = std::fs::read_link(&p) {
display.push_str(&format!(" -> {}", target.display()));
}
}
rows.push((
name.to_lowercase(),
vec![
Cell::str(paint(&display, color, style)),
Cell::str(kind.to_string()),
Cell::bytes(size_bytes.unwrap_or(0), size_text),
Cell::str(modified_str(&p)),
],
));
}
// Directories first, then alphabetical.
rows.sort_by(|a, b| {
let a_dir = a.1[1].text == "dir";
let b_dir = b.1[1].text == "dir";
b_dir.cmp(&a_dir).then_with(|| a.0.cmp(&b.0))
});
Ok(DataTable {
columns: vec![
"name".into(),
"type".into(),
"size".into(),
"modified".into(),
],
rows: rows.into_iter().map(|(_, r)| r).collect(),
})
}
/// Render a Nushell-style boxed table of the entries of a path (unbounded
/// width — used by tests; the REPL renders with the terminal width).
pub fn render_ls(path: &Path, color: bool) -> Result<String> {
Ok(ls_table(path, color)?.render(color, 200))
}
/// Completion candidates for a path fragment typed after cd or ls.
///
/// The last segment is matched against the entries of the base directory;
/// directory candidates end with a separator so the next Tab dives one level
/// deeper, like Nushell. With dirs_only, files are excluded (cd).
pub fn path_candidates(base_dir: &Path, input: &str, dirs_only: bool) -> Vec<String> {
let sep = std::path::MAIN_SEPARATOR;
let alt = if sep == '\\' { '/' } else { '\\' };
let idx = input.rfind(|c| c == sep || c == alt);
let (base_part, stem) = match idx {
Some(i) => (&input[..=i], &input[i + 1..]),
None => ("", input),
};
let use_sep = if input.contains(alt) && !input.contains(sep) {
alt
} else {
sep
};
let base: PathBuf = if base_part.starts_with('~') {
crate::config::expand_path(base_part)
} else if base_part.is_empty() {
base_dir.to_path_buf()
} else {
PathBuf::from(base_part)
};
let mut names: Vec<(String, bool)> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&base) {
for entry in rd.flatten() {
let p = entry.path();
let is_dir = p.is_dir();
if dirs_only && !is_dir {
continue;
}
let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.starts_with(stem) {
continue;
}
names.push((name.to_string(), is_dir));
}
}
names.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
names
.into_iter()
.map(|(name, is_dir)| {
let mut full = format!("{base_part}{name}");
if is_dir {
full.push(use_sep);
}
full
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_sizes() {
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(2048), "2.0 KB");
assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
assert_eq!(format_size(3 * 1024 * 1024 * 1024), "3.0 GB");
}
#[test]
fn dir_size_is_bounded() {
let dir = tempfile::tempdir().unwrap();
let big = dir.path().join("big");
std::fs::create_dir_all(&big).unwrap();
for i in 0..520 {
std::fs::write(big.join(format!("f{i}.txt")), "x").unwrap();
}
// Beyond the cap the size is unknown, and the walk stays cheap.
assert_eq!(dir_size(&big), None);
let small = dir.path().join("small");
std::fs::create_dir_all(&small).unwrap();
std::fs::write(small.join("a.txt"), "hello").unwrap();
assert_eq!(dir_size(&small), Some(5));
}
#[test]
fn completes_directories_only() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::create_dir_all(dir.path().join("src2")).unwrap();
std::fs::write(dir.path().join("readme.txt"), "x").unwrap();
let cands = path_candidates(dir.path(), "", true);
assert!(cands.iter().any(|c| c == "src\\" || c == "src/"));
assert!(cands.iter().any(|c| c == "src2\\" || c == "src2/"));
assert!(!cands.iter().any(|c| c.contains("readme")));
let cands = path_candidates(dir.path(), "s", true);
assert_eq!(cands.len(), 2, "{cands:?}");
// Files appear for ls completion.
let cands = path_candidates(dir.path(), "", false);
assert!(cands.iter().any(|c| c.contains("readme")));
}
#[test]
fn completes_nested_paths() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src").join("commands")).unwrap();
std::fs::create_dir_all(dir.path().join("src").join("installers")).unwrap();
let sep = std::path::MAIN_SEPARATOR;
let prefix = format!("src{}", sep);
let cands = path_candidates(dir.path(), &format!("src{}co", sep), true);
assert!(cands.iter().any(|c| c == &format!("{prefix}commands{}", sep)));
assert!(!cands.iter().any(|c| c.contains("installers")));
}
#[test]
fn renders_ls_table() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("subdir")).unwrap();
std::fs::write(dir.path().join("file.txt"), "hello").unwrap();
let table = render_ls(dir.path(), false).unwrap();
assert!(table.contains("╭"));
assert!(table.contains("╰"));
assert!(table.contains("name"));
assert!(table.contains("type"));
assert!(table.contains("modified"));
assert!(table.contains("subdir/"));
assert!(table.contains("file.txt"));
assert!(table.contains("dir"));
assert!(table.contains("file"));
assert!(table.contains("5 B"));
// Every line of the table has the same width.
let widths: Vec<usize> = table.lines().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned table:\n{table}");
}
}
+36
View File
@@ -239,6 +239,42 @@ pub fn print_json<T: Serialize>(value: &T) {
}
}
/// Width of the terminal attached to stdout, in columns. Returns None when
/// stdout is not a terminal (e.g. piped). AM_TABLE_WIDTH overrides it, mostly
/// for tests and scripts.
pub fn terminal_width() -> Option<usize> {
if let Ok(v) = std::env::var("AM_TABLE_WIDTH") {
if let Ok(w) = v.trim().parse::<usize>() {
return Some(w);
}
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::System::Console::{
GetConsoleScreenBufferInfo, CONSOLE_SCREEN_BUFFER_INFO,
};
let handle = std::io::stdout().as_raw_handle() as isize;
let mut info: CONSOLE_SCREEN_BUFFER_INFO = unsafe { std::mem::zeroed() };
if unsafe { GetConsoleScreenBufferInfo(handle, &mut info) } != 0 {
let width = (info.srWindow.Right - info.srWindow.Left + 1) as usize;
if width > 0 {
return Some(width);
}
}
None
}
#[cfg(not(windows))]
{
if let Ok(v) = std::env::var("COLUMNS") {
if let Ok(w) = v.trim().parse::<usize>() {
return Some(w);
}
}
None
}
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
+218
View File
@@ -0,0 +1,218 @@
//! ps: Nushell-style process table.
//!
//! Windows uses the Toolhelp32 snapshot API (pid, ppid, name, threads, cpu
//! time, working set); Unix reads /proc.
use crate::tables::{Cell, DataTable};
/// Columns: pid, ppid, name, cpu, mem, threads.
fn columns() -> Vec<String> {
vec![
"pid".into(),
"ppid".into(),
"name".into(),
"cpu".into(),
"mem".into(),
"threads".into(),
]
}
/// Build the process table.
pub fn ps_table() -> DataTable {
#[cfg(windows)]
let rows = windows_rows();
#[cfg(unix)]
let rows = unix_rows();
#[cfg(not(any(windows, unix)))]
let rows = Vec::new();
let mut rows = rows;
rows.sort_by(|a, b| {
a[0]
.num
.unwrap_or(0.0)
.partial_cmp(&b[0].num.unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal)
});
DataTable {
columns: columns(),
rows,
}
}
// ---------------------------------------------------------------------------
// Windows: Toolhelp32 snapshot
// ---------------------------------------------------------------------------
#[cfg(windows)]
fn windows_rows() -> Vec<Vec<Cell>> {
use crate::nav::format_size;
use std::mem::size_of;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::ProcessStatus::{
K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS,
};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_INFORMATION,
};
let mut rows: Vec<Vec<Cell>> = Vec::new();
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return rows;
}
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
entry.dwSize = size_of::<PROCESSENTRY32W>() as u32;
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
while ok != 0 {
let pid = entry.th32ProcessID;
let ppid = entry.th32ParentProcessID;
let threads = entry.cntThreads;
let mut name = String::new();
for &unit in entry.szExeFile.iter() {
if unit == 0 {
break;
}
if let Some(c) = char::from_u32(unit as u32) {
name.push(c);
}
}
// CPU time and memory need a process handle; some system processes
// refuse — those cells stay "—".
let mut cpu = "—".to_string();
let mut mem = "—".to_string();
let handle = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid) };
if handle != 0 {
let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() };
if unsafe {
K32GetProcessMemoryInfo(
handle,
&mut counters,
size_of::<PROCESS_MEMORY_COUNTERS>() as u32,
)
} != 0
{
mem = format_size(counters.WorkingSetSize as u64);
}
let (mut created, mut exited, mut kernel, mut user): (
windows_sys::Win32::Foundation::FILETIME,
windows_sys::Win32::Foundation::FILETIME,
windows_sys::Win32::Foundation::FILETIME,
windows_sys::Win32::Foundation::FILETIME,
) = unsafe {
(
std::mem::zeroed(),
std::mem::zeroed(),
std::mem::zeroed(),
std::mem::zeroed(),
)
};
if unsafe { GetProcessTimes(handle, &mut created, &mut exited, &mut kernel, &mut user) } != 0 {
let ticks = (filetime_u64(&kernel) + filetime_u64(&user)) as f64 / 10_000_000.0;
cpu = format!("{ticks:.0}s");
}
unsafe { CloseHandle(handle) };
}
rows.push(vec![
Cell::num(pid as f64, pid.to_string()),
Cell::num(ppid as f64, ppid.to_string()),
Cell::str(name),
Cell::str(cpu),
Cell::str(mem),
Cell::num(threads as f64, threads.to_string()),
]);
ok = unsafe { Process32NextW(snapshot, &mut entry) };
}
unsafe { CloseHandle(snapshot) };
rows
}
#[cfg(windows)]
fn filetime_u64(ft: &windows_sys::Win32::Foundation::FILETIME) -> u64 {
((ft.dwHighDateTime as u64) << 32) | (ft.dwLowDateTime as u64)
}
// ---------------------------------------------------------------------------
// Unix: /proc
// ---------------------------------------------------------------------------
#[cfg(unix)]
fn unix_rows() -> Vec<Vec<Cell>> {
use crate::nav::format_size;
let page_size = page_size();
let mut rows: Vec<Vec<Cell>> = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return rows;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let Ok(pid) = name.parse::<f64>() else {
continue;
};
let base = entry.path();
let Ok(stat) = std::fs::read_to_string(base.join("stat")) else {
continue;
};
// The comm field is in parentheses and may contain spaces.
let Some(close) = stat.rfind(')') else {
continue;
};
let comm = stat[stat.find('(').unwrap_or(0) + 1..close].to_string();
let rest: Vec<&str> = stat[close + 2..].split_whitespace().collect();
if rest.len() < 22 {
continue;
}
// rest[0]=state, rest[1]=ppid, rest[11]=utime, rest[12]=stime,
// rest[17]=num_threads, rest[21]=rss (pages).
let ppid = rest[1].parse::<f64>().unwrap_or(0.0);
let utime = rest[11].parse::<f64>().unwrap_or(0.0);
let stime = rest[12].parse::<f64>().unwrap_or(0.0);
let threads = rest[17].parse::<f64>().unwrap_or(0.0);
let rss_pages = rest[21].parse::<u64>().unwrap_or(0);
let cpu = (utime + stime) / 100.0;
rows.push(vec![
Cell::num(pid, pid.to_string()),
Cell::num(ppid, ppid.to_string()),
Cell::str(comm),
Cell::str(format!("{cpu:.0}s")),
Cell::str(format_size(rss_pages * page_size)),
Cell::num(threads, threads.to_string()),
]);
}
rows
}
#[cfg(unix)]
fn page_size() -> u64 {
// Standard page size; RSS in pages * 4096 is close enough for display.
4096
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ps_produces_a_table() {
let table = ps_table();
assert_eq!(
table.columns,
vec!["pid", "ppid", "name", "cpu", "mem", "threads"]
);
assert!(!table.rows.is_empty(), "process table should not be empty");
// Our own pid is in the table.
let me = std::process::id().to_string();
assert!(
table.rows.iter().any(|r| r[0].text == me),
"our pid should be listed"
);
// Rows have as many cells as columns.
assert!(table.rows.iter().all(|r| r.len() == 6));
}
}
+271 -15
View File
@@ -15,6 +15,9 @@
use crate::app::App;
use crate::cli::{Cli, Command, StartArgs};
use crate::commands;
use crate::nav;
use crate::ps;
use crate::tables::DataTable;
use crate::shell::ShellSession;
use anyhow::{anyhow, Result};
use clap::CommandFactory;
@@ -44,8 +47,43 @@ pub struct AmCompleter {
agents: Vec<String>,
aliases: Vec<String>,
groups: Vec<String>,
/// 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"),
("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"),
("shell", "show or switch the system shell"),
("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 {
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups)
@@ -67,12 +105,27 @@ impl AmCompleter {
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", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "help",
"version", "exit",
"self-update", "self-uninstall", "export", "import", "shell", "ls", "dir",
"cd", "ps", "where", "get", "help", "version", "exit",
],
config_sub: vec!["show", "path", "edit", "validate", "add"],
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
@@ -88,6 +141,7 @@ impl AmCompleter {
agents,
aliases: alias_list,
groups: group_list,
descriptions,
}
}
@@ -118,6 +172,16 @@ impl AmCompleter {
"shell" => {
pool = self.gateway_shells.iter().map(|s| s.to_string()).collect();
}
"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);
}
"install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run"
| "info" | "status" => {
if word.starts_with("group:") {
@@ -136,6 +200,11 @@ impl AmCompleter {
_ => {
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);
}
}
}
@@ -172,9 +241,17 @@ impl Completer for AmCompleter {
let (start, cands) = self.candidates_for(line, pos);
let pairs = cands
.into_iter()
.map(|c| Pair {
display: c.clone(),
replacement: c,
.map(|c| {
// Nushell-style menu: show a description next to the
// candidate when one is known.
let display = match self.descriptions.get(&c) {
Some(d) if !d.is_empty() => format!("{c}\t{d}"),
_ => c.clone(),
};
Pair {
display,
replacement: c,
}
})
.collect();
Ok((start, pairs))
@@ -277,6 +354,7 @@ pub fn banner_box(
version: &str,
agents: usize,
installed: usize,
external: usize,
session: &ShellSession,
session_id: &str,
cwd: &str,
@@ -340,13 +418,15 @@ pub fn banner_box(
" system doctor · config · completion · self-update · self-uninstall · export · import"
.to_string(),
));
rows.push(inner(" shell shell · help · version · exit".to_string()));
rows.push(inner(
" shell ls · cd · ps · where · get · shell · help · version · exit".to_string(),
));
// Stats footer.
rows.push(blank());
rows.push(inner(dim(
&format!(
"am v{version} · {agents} agents · {installed} installed · shell {} · /help for commands",
"am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · /help for commands",
session.current.name
),
color,
@@ -370,6 +450,7 @@ pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String {
env!("CARGO_PKG_VERSION"),
agent_count(app),
installed_count(app),
external_count(app),
session,
session_id,
&current_dir_string(),
@@ -405,8 +486,9 @@ fn print_status(app: &App, session: &ShellSession, started: Instant) {
);
let counts = dim(
&format!(
"{} agents · {} installed",
"{} agents · {} external · {} managed",
agent_count(app),
external_count(app),
installed_count(app)
),
color,
@@ -530,12 +612,45 @@ fn installed_count(app: &App) -> usize {
.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
// ---------------------------------------------------------------------------
@@ -568,11 +683,17 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
rustyline::Editor::new()?;
rl.set_helper(Some(AmCompleter::new(app)));
// Nushell-style completion: a single match is inserted immediately;
// otherwise Tab inserts the longest common prefix and the menu lists the
// candidates with their descriptions.
use rustyline::config::{CompletionType, Configurer};
let _ = rl.set_completion_type(CompletionType::List);
let history_path = app.paths.state_file.with_file_name("history.txt");
if rl.load_history(&history_path).is_err() {
// First run: no history yet.
}
let started = Instant::now();
let mut last: Option<DataTable> = None;
loop {
print_status(app, session, started);
match rl.readline("❯ ") {
@@ -582,7 +703,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
continue;
}
let _ = rl.add_history_entry(trimmed.as_str());
match handle_input(app, session, &trimmed) {
match handle_input(app, session, &mut last, &trimmed) {
Ok(true) => break,
Ok(false) => {}
Err(e) => app.log.error(&format!("{e:#}")),
@@ -603,6 +724,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
/// Plain fallback when no terminal/line editor is available.
fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
let started = Instant::now();
let mut last: Option<DataTable> = None;
loop {
print_status(app, session, started);
eprint!("❯ ");
@@ -615,7 +737,7 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
if line.is_empty() {
continue;
}
match handle_input(app, session, &line) {
match handle_input(app, session, &mut last, &line) {
Ok(true) => break,
Ok(false) => {}
Err(e) => app.log.error(&format!("{e:#}")),
@@ -625,7 +747,12 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
}
/// Handle one line of input. Returns true when the session should exit.
fn handle_input(app: &App, session: &mut ShellSession, line: &str) -> Result<bool> {
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" => {
@@ -652,12 +779,21 @@ fn handle_input(app: &App, session: &mut ShellSession, line: &str) -> Result<boo
}
return Ok(false);
}
handle_line(app, session, line)
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, line: &str) -> Result<bool> {
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();
@@ -682,6 +818,40 @@ fn handle_line(app: &App, session: &mut ShellSession, line: &str) -> Result<bool
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)?;
@@ -825,6 +995,80 @@ fn is_am_command(word: &str) -> bool {
)
}
/// Resolve the path argument of ls/dir (defaults to the current directory).
fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
Ok(match rest.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(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 =
shell_words::split(part).map_err(|e| anyhow!("cannot parse command: {e}"))?;
if tokens.is_empty() {
continue;
}
let cmd = tokens[0].as_str();
let rest = &tokens[1..];
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)
}
/// 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));
@@ -904,12 +1148,21 @@ fn print_help() {
println!(" export [--output FILE] backup config + state");
println!(" import <file> restore a backup");
println!(" shell [list|<name>] show or switch the system shell");
println!(" ls [path] · dir [path] list files and directories (Nushell-style table)");
println!(" ps list processes");
println!(" where <col> <op> <val> filter the last table (ls | where size > 1mb)");
println!(" get <col> select a column (ls | get name)");
println!(" cd <dir> change directory — Tab completes folders");
println!(" help | version | exit this help, the version, quit");
println!();
println!("pipelines:");
println!(" ls | where size > 1mb | get name");
println!(" ps | where name =~ am");
println!();
println!("system gateway:");
println!(" any other input runs as a system command in the active shell");
println!(" prefix with '!' to force system execution (e.g. '!list')");
println!(" 'cd <dir>' changes the session directory (in-process)");
println!(" prefix with '!' to force system execution (e.g. '!ls', '!list')");
println!(" Tab inserts the common prefix and lists candidates with descriptions");
println!();
println!("slash commands: /help · /version · /exit · /shell <name>");
}
@@ -1014,6 +1267,7 @@ mod tests {
"9.9.9",
65,
3,
12,
&session,
"20260815_123456_abcdef",
"/home/alice/dev/agent-manager",
@@ -1027,6 +1281,8 @@ mod tests {
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"));
}
+425
View File
@@ -0,0 +1,425 @@
//! Generic Nushell-style tables: render a boxed table, filter rows with
//! where-like predicates and select a column with get.
use anyhow::{bail, Result};
use colored::Colorize;
/// One cell: the text to display plus a typed value for filtering
/// (number, byte size, or plain string).
#[derive(Debug, Clone)]
pub struct Cell {
pub text: String,
pub num: Option<f64>,
pub bytes: Option<u64>,
}
impl Cell {
pub fn str(s: impl Into<String>) -> Self {
Cell {
text: s.into(),
num: None,
bytes: None,
}
}
pub fn num(n: f64, text: String) -> Self {
Cell {
text,
num: Some(n),
bytes: None,
}
}
pub fn bytes(b: u64, text: String) -> Self {
Cell {
text,
num: None,
bytes: Some(b),
}
}
}
/// A tabular result produced by ls, ps, where or get.
#[derive(Debug, Clone, Default)]
pub struct DataTable {
pub columns: Vec<String>,
pub rows: Vec<Vec<Cell>>,
}
impl DataTable {
pub fn column_index(&self, name: &str) -> Option<usize> {
self.columns
.iter()
.position(|c| c.eq_ignore_ascii_case(name))
}
/// Render the boxed Nushell-style table, fitting max_width columns.
/// When the natural layout is too wide, the first (name) column is
/// truncated with an ellipsis and the trailing columns are dropped if
/// that is still not enough — every row stays on a single line.
pub fn render(&self, color: bool, max_width: usize) -> String {
let max_width = max_width.max(20);
let headers = self.columns.clone();
if headers.is_empty() {
return String::new();
}
// Column 0 is the row index; the data columns follow.
let index_width = self.rows.len().to_string().len().max(1);
let mut natural: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
let mut cells: Vec<Vec<String>> = Vec::new();
for (i, row) in self.rows.iter().enumerate() {
let mut line = vec![i.to_string()];
for cell in row {
line.push(cell.text.clone());
}
for (c, cell) in line.iter().enumerate().skip(1) {
natural[c - 1] = natural[c - 1].max(visible_len(cell));
}
cells.push(line);
}
let line_width = |ncols: usize, widths: &[usize]| -> usize {
1 + 3 * (ncols + 1) + index_width + widths.iter().sum::<usize>()
};
// Choose the final column set and widths.
let mut data_cols = headers.len();
let mut widths: Vec<usize> = natural.clone();
loop {
if data_cols == 0 {
break;
}
widths.truncate(data_cols);
if line_width(data_cols, &widths) <= max_width {
// Expand the first column into the remaining slack.
widths[0] += max_width - line_width(data_cols, &widths);
break;
}
// Cap the non-first columns, then give the first what remains.
let mut capped = widths.clone();
for w in capped.iter_mut().skip(1) {
*w = (*w).min(16);
}
if line_width(data_cols, &capped) <= max_width {
capped[0] += max_width - line_width(data_cols, &capped);
widths = capped;
break;
}
if data_cols == 1 {
// Last resort: shrink the single column to whatever remains
// after the borders and the index column.
let fixed = 1 + 3 * (data_cols + 1) + index_width;
capped[0] = max_width.saturating_sub(fixed).max(1);
widths = capped;
break;
}
data_cols -= 1;
}
let all_headers: Vec<String> = std::iter::once("#".to_string())
.chain(headers.iter().take(data_cols).cloned())
.collect();
let mut all_widths = vec![index_width];
all_widths.extend(widths.iter().cloned());
let border = |left: char, mid: char, right: char| -> String {
let mut line = String::new();
line.push(left);
for (i, w) in all_widths.iter().enumerate() {
if i > 0 {
line.push(mid);
}
line.push_str(&"─".repeat(w + 2));
}
line.push(right);
line
};
let row_line = |row: &[String], bold: bool| -> String {
let mut line = String::from("│");
for (i, c) in row.iter().enumerate().take(all_widths.len()) {
line.push(' ');
let cell = fit_cell(c, all_widths[i]);
let rendered = if bold {
cell.bold().to_string()
} else {
cell
};
line.push_str(&rendered);
line.push(' ');
line.push('│');
}
line
};
let dim = |s: &str| -> String {
if color {
s.dimmed().to_string()
} else {
s.to_string()
}
};
let mut out = String::new();
out.push_str(&dim(&border('╭', '┬', '╮')));
out.push('\n');
out.push_str(&row_line(&all_headers, true));
out.push('\n');
out.push_str(&dim(&border('├', '┼', '┤')));
out.push('\n');
for line in &cells {
out.push_str(&row_line(line, false));
out.push('\n');
}
out.push_str(&dim(&border('╰', '┴', '╯')));
out.push('\n');
out
}
}
/// Parse a where value: "1mb"/"512kb"/"2gb" become byte sizes, numbers
/// become numbers, everything else is a string.
#[derive(Debug, Clone, PartialEq)]
enum WhereValue {
Num(f64),
Bytes(u64),
Str(String),
}
fn parse_value(raw: &str) -> WhereValue {
let lower = raw.trim().to_lowercase();
for (suffix, mult) in [("gb", 1024u64.pow(3)), ("mb", 1024u64.pow(2)), ("kb", 1024)] {
if let Some(n) = lower.strip_suffix(suffix) {
if let Ok(v) = n.trim().parse::<f64>() {
return WhereValue::Bytes((v * mult as f64) as u64);
}
}
}
if let Ok(v) = raw.trim().parse::<f64>() {
return WhereValue::Num(v);
}
WhereValue::Str(raw.trim().to_string())
}
fn num_cmp(a: f64, b: f64, op: &str) -> bool {
match op {
">" => a > b,
"<" => a < b,
">=" => a >= b,
"<=" => a <= b,
"==" => (a - b).abs() < f64::EPSILON,
"!=" => (a - b).abs() >= f64::EPSILON,
_ => false,
}
}
/// Filter the rows of a table: where <field> <op> <value>.
pub fn filter_where(table: &DataTable, field: &str, op: &str, value: &str) -> Result<DataTable> {
let Some(idx) = table.column_index(field) else {
bail!(
"unknown column '{field}' — available: {}",
table.columns.join(", ")
);
};
let val = parse_value(value);
let supported = matches!(op, ">" | "<" | ">=" | "<=" | "==" | "!=" | "=~");
if !supported {
bail!("unknown operator '{op}' — use > < >= <= == != =~");
}
let mut out = table.clone();
out.rows.retain(|row| match row.get(idx) {
None => false,
Some(cell) => match &val {
WhereValue::Num(n) => match cell.num {
Some(v) => num_cmp(v, *n, op),
None => false,
},
WhereValue::Bytes(b) => match cell.bytes {
Some(v) => num_cmp(v as f64, *b as f64, op),
None => false,
},
WhereValue::Str(s) => {
let text = strip_ansi(&cell.text);
match op {
"==" => text.eq_ignore_ascii_case(s),
"!=" => !text.eq_ignore_ascii_case(s),
"=~" => text.to_lowercase().contains(&s.to_lowercase()),
_ => false,
}
}
},
});
Ok(out)
}
/// Select one column: get <column>.
pub fn get_column(table: &DataTable, field: &str) -> Result<DataTable> {
let Some(idx) = table.column_index(field) else {
bail!(
"unknown column '{field}' — available: {}",
table.columns.join(", ")
);
};
Ok(DataTable {
columns: vec![field.to_string()],
rows: table
.rows
.iter()
.map(|row| vec![row[idx].clone()])
.collect(),
})
}
/// Pad a cell to its column width, or truncate it (with an ellipsis,
/// preserving any leading color codes) when it is too wide.
fn fit_cell(s: &str, w: usize) -> String {
let n = visible_len(s);
if n <= w {
return format!("{s}{}", " ".repeat(w - n));
}
let plain = strip_ansi(s);
let mut cut: String = plain.chars().take(w.saturating_sub(1).max(1)).collect();
cut.push('…');
if s.contains('') {
let mut prefix = String::new();
let mut chars = s.chars().peekable();
while chars.peek() == Some(&'') {
for ch in chars.by_ref() {
prefix.push(ch);
if ch == 'm' {
break;
}
}
}
format!("{prefix}{cut}")
} else {
cut
}
}
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()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> DataTable {
DataTable {
columns: vec!["name".into(), "type".into(), "size".into()],
rows: vec![
vec![
Cell::str("src"),
Cell::str("dir"),
Cell::bytes(2048, "2.0 KB".into()),
],
vec![
Cell::str("am.exe"),
Cell::str("file"),
Cell::bytes(5_000_000, "4.8 MB".into()),
],
vec![
Cell::str("readme.md"),
Cell::str("file"),
Cell::bytes(512, "512 B".into()),
],
],
}
}
#[test]
fn filters_numeric_and_size() {
let t = sample();
let big = filter_where(&t, "size", ">", "1mb").unwrap();
assert_eq!(big.rows.len(), 1);
assert_eq!(big.rows[0][0].text, "am.exe");
let eq = filter_where(&t, "type", "==", "dir").unwrap();
assert_eq!(eq.rows.len(), 1);
let contains = filter_where(&t, "name", "=~", "read").unwrap();
assert_eq!(contains.rows.len(), 1);
}
#[test]
fn get_selects_column() {
let t = sample();
let names = get_column(&t, "name").unwrap();
assert_eq!(names.columns, vec!["name".to_string()]);
assert_eq!(names.rows.len(), 3);
assert_eq!(names.rows[1][0].text, "am.exe");
}
#[test]
fn unknown_column_errors() {
let t = sample();
assert!(filter_where(&t, "nope", "==", "x").is_err());
assert!(get_column(&t, "nope").is_err());
}
#[test]
fn parses_where_values() {
assert_eq!(parse_value("1mb"), WhereValue::Bytes(1024 * 1024));
assert_eq!(parse_value("512kb"), WhereValue::Bytes(512 * 1024));
assert_eq!(parse_value("3.5"), WhereValue::Num(3.5));
assert_eq!(parse_value("hello"), WhereValue::Str("hello".into()));
}
#[test]
fn render_is_aligned() {
let t = sample();
let text = t.render(false, 200);
assert!(text.contains("╭"));
assert!(text.contains("name"));
assert!(text.contains("am.exe"));
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned:\n{text}");
}
#[test]
fn render_fits_narrow_width() {
// A table whose first column is too long to fit naturally.
let t = DataTable {
columns: vec!["name".into(), "type".into(), "size".into()],
rows: vec![vec![
Cell::str("a-very-long-file-name-that-overflows.txt"),
Cell::str("file"),
Cell::bytes(512, "512 B".into()),
]],
};
// Trailing columns are dropped when too narrow.
let text = t.render(false, 45);
for line in text.lines() {
assert!(line.chars().count() <= 45, "too wide: {line}");
}
assert!(!text.contains("size"));
// The name column is truncated with an ellipsis at the very end.
let text = t.render(false, 30);
for line in text.lines() {
assert!(line.chars().count() <= 30, "too wide: {line}");
}
assert!(text.contains("…"), "name should be truncated:
{text}");
}
#[test]
fn fit_cell_truncates_and_keeps_color() {
let colored = "\u{1b}[34mverylongname\u{1b}[0m".to_string();
let out = fit_cell(&colored, 8);
assert_eq!(strip_ansi(&out).chars().count(), 8);
assert!(out.starts_with("\u{1b}["));
}
}