Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7cb136c38 | ||
|
|
57234b7a38 | ||
|
|
3e1b66809a | ||
|
|
d886de322c | ||
|
|
bc38cd59e5 | ||
|
|
61f4909bc6 | ||
|
|
98aa75d185 |
Generated
+2
-1
@@ -21,7 +21,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "0.2.4"
|
version = "0.2.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -41,6 +41,7 @@ dependencies = [
|
|||||||
"ureq",
|
"ureq",
|
||||||
"wait-timeout",
|
"wait-timeout",
|
||||||
"which",
|
"which",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "0.2.4"
|
version = "0.2.10"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -35,6 +35,9 @@ wait-timeout = "0.2"
|
|||||||
which = "7"
|
which = "7"
|
||||||
zip = "0.6"
|
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]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@@ -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> | installe (dépendances vérifiées et proposées) |
|
||||||
| am install <agent> --method pip | choisit une méthode d'installation |
|
| am install <agent> --method pip | choisit une méthode d'installation |
|
||||||
| am update <agent> | met à jour vers la dernière version connue |
|
| 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 |
|
| am export / am import | sauvegarde et restaure config + état |
|
||||||
|
|
||||||
### 🚀 Exécuter
|
### 🚀 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
|
`settings.default_shell`, puis `SHELL`/`COMSPEC`, puis détection sur le
|
||||||
PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish).
|
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 # affiche le shell courant + les shells disponibles
|
||||||
❯ shell bash # change le shell de la session
|
❯ 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)
|
❯ cd ~/projets # change le répertoire de la session (persistant)
|
||||||
❯ /help # commandes slash : /help · /version · /exit · /shell <nom>
|
❯ /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
|
Pour rendre le choix permanent : `settings.default_shell: pwsh` dans
|
||||||
`config.yaml` (voir `am config path`).
|
`config.yaml` (voir `am config path`).
|
||||||
|
|
||||||
@@ -211,7 +222,11 @@ qu'agent-manager possède :
|
|||||||
# macOS
|
# macOS
|
||||||
rm -rf "$HOME/Library/Application Support/agent-manager" "$HOME/.config/agent-manager"
|
rm -rf "$HOME/Library/Application Support/agent-manager" "$HOME/.config/agent-manager"
|
||||||
|
|
||||||
# Windows (cmd)
|
# Windows — PowerShell
|
||||||
|
Remove-Item -Recurse -Force $env:LOCALAPPDATA\agent-manager, $env:APPDATA\agent-manager -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Windows — cmd.exe (à ne PAS coller dans PowerShell : rmdir y est un
|
||||||
|
# alias de Remove-Item et le & final lance un job en arrière-plan)
|
||||||
rmdir /s /q "%LOCALAPPDATA%\agent-manager" & rmdir /s /q "%APPDATA%\agent-manager"
|
rmdir /s /q "%LOCALAPPDATA%\agent-manager" & rmdir /s /q "%APPDATA%\agent-manager"
|
||||||
|
|
||||||
(Si vous utilisiez `AGENT_MANAGER_DATA`/`AGENT_MANAGER_STATE`/`AGENT_MANAGER_CONFIG_DIR`,
|
(Si vous utilisiez `AGENT_MANAGER_DATA`/`AGENT_MANAGER_STATE`/`AGENT_MANAGER_CONFIG_DIR`,
|
||||||
|
|||||||
+13
@@ -56,6 +56,19 @@ agents:
|
|||||||
run: claude
|
run: claude
|
||||||
tags: [anthropic, assistant, source-available, mainstream]
|
tags: [anthropic, assistant, source-available, mainstream]
|
||||||
|
|
||||||
|
- name: deepseek-harness
|
||||||
|
display_name: "DeepSeek Harness"
|
||||||
|
description: "CLI officiel DeepSeek (dsh) : boot de profils, gestion des plugins Cordis, orchestration multi-agents, boucles de buts (goals) et interface web. Installez-le pour piloter vos agents depuis am."
|
||||||
|
category: coding-agent
|
||||||
|
website: https://github.com/deepseek-ai/deepseek-harness
|
||||||
|
install:
|
||||||
|
type: npm
|
||||||
|
package: "@deepseek-ai/dsh"
|
||||||
|
dependencies:
|
||||||
|
- { name: node, min_version: "20.0.0", install_hint: "https://nodejs.org" }
|
||||||
|
run: dsh
|
||||||
|
tags: [deepseek, harness, multi-agent, plugins, web-ui]
|
||||||
|
|
||||||
- name: hermes-agent
|
- name: hermes-agent
|
||||||
display_name: "Hermes Agent"
|
display_name: "Hermes Agent"
|
||||||
description: "Agent Nous Research multi-modèle (300+ providers). Mémoire persistante, création automatique de skills, exécution de code sandboxée, reach multi-plateforme (Telegram/Slack/Discord/WhatsApp)."
|
description: "Agent Nous Research multi-modèle (300+ providers). Mémoire persistante, création automatique de skills, exécution de code sandboxée, reach multi-plateforme (Telegram/Slack/Discord/WhatsApp)."
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ pub enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
force: bool,
|
force: bool,
|
||||||
},
|
},
|
||||||
/// Uninstall an agent and clean its files
|
/// Uninstall an agent (managed or external) and clean its files
|
||||||
Uninstall {
|
Uninstall {
|
||||||
/// Agent name or alias
|
/// Agent name or alias
|
||||||
agent: String,
|
agent: String,
|
||||||
|
|||||||
+134
-48
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::output::print_json;
|
use crate::output::print_json;
|
||||||
|
use colored::Colorize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -101,51 +102,102 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{}", agent.title());
|
let color = app.color();
|
||||||
if agent.name != agent.title() {
|
let dim = |s: &str| -> String {
|
||||||
println!(" slug: {}", agent.name);
|
if color {
|
||||||
}
|
s.dimmed().to_string()
|
||||||
if let Some(d) = &agent.description {
|
} else {
|
||||||
println!(" description: {d}");
|
s.to_string()
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
let bold = |s: &str| -> String {
|
||||||
|
if color {
|
||||||
|
s.bold().to_string()
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Header -------------------------------------------------------------
|
||||||
|
println!("{}", bold(agent.title()));
|
||||||
|
let mut sub: Vec<String> = vec![agent.name.clone()];
|
||||||
if let Some(c) = &agent.category {
|
if let Some(c) = &agent.category {
|
||||||
println!(" category: {c}");
|
sub.push(c.clone());
|
||||||
|
}
|
||||||
|
println!(" {}", dim(&sub.join(" · ")));
|
||||||
|
if let Some(d) = &agent.description {
|
||||||
|
println!();
|
||||||
|
println!(" {d}");
|
||||||
}
|
}
|
||||||
if let Some(w) = &agent.website {
|
if let Some(w) = &agent.website {
|
||||||
println!(" website: {w}");
|
println!();
|
||||||
}
|
println!(" {} {}", dim("website"), w);
|
||||||
if !agent.tags.is_empty() {
|
|
||||||
println!(" tags: {}", agent.tags.join(", "));
|
|
||||||
}
|
|
||||||
if !agent.platforms.is_empty() {
|
|
||||||
println!(" platforms: {}", agent.platforms.join(", "));
|
|
||||||
}
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
// Status block
|
// --- Status --------------------------------------------------------------
|
||||||
let status = if running {
|
let external = external_path(agent);
|
||||||
format!("running (pid {})", entry.as_ref().and_then(|e| e.pid).unwrap_or(0))
|
let (status_word, detail): (&str, String) = if running {
|
||||||
|
let pid = entry.as_ref().and_then(|e| e.pid).unwrap_or(0);
|
||||||
|
("running", format!(" (pid {pid})"))
|
||||||
} else if entry.is_some() {
|
} else if entry.is_some() {
|
||||||
format!("installed (version {})", entry.as_ref().and_then(|e| e.version.clone()).unwrap_or_else(|| "unknown".to_string()))
|
let v = entry
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|e| e.version.clone())
|
||||||
|
.map(|v| format!(" — v{v}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
("installed", v)
|
||||||
} else if !agent.installable {
|
} else if !agent.installable {
|
||||||
"not installable locally".to_string()
|
("not-installable", String::new())
|
||||||
} else if external_path(agent).is_some() {
|
} else if external.is_some() {
|
||||||
"found on PATH (external — not managed)".to_string()
|
("external", " (found on PATH — not managed)".to_string())
|
||||||
} else {
|
} else {
|
||||||
"not installed".to_string()
|
("not-installed", String::new())
|
||||||
};
|
};
|
||||||
println!(" status: {status}");
|
let word = crate::output::status_style(status_word, color);
|
||||||
|
println!(" {} {}", dim("● status"), format!("{word}{detail}"));
|
||||||
|
|
||||||
|
// Version: recorded for managed installs, detected for external ones.
|
||||||
|
let version_display: Option<String> = entry
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|e| e.version.clone())
|
||||||
|
.or_else(|| external.as_ref().and_then(|p| crate::probe::detect_external_version(p)));
|
||||||
|
if let Some(v) = version_display {
|
||||||
|
println!(" {} {}", dim("● version"), v);
|
||||||
|
}
|
||||||
|
if let Some(t) = entry.as_ref().map(|e| e.installed_at.clone()) {
|
||||||
|
println!(" {} {}", dim("● installed"), t);
|
||||||
|
}
|
||||||
|
if let Some(t) = entry.as_ref().and_then(|e| e.updated_at.clone()) {
|
||||||
|
println!(" {} {}", dim("● updated"), t);
|
||||||
|
}
|
||||||
|
if let Some(p) = entry
|
||||||
|
.as_ref()
|
||||||
|
.map(|e| e.install_dir.clone())
|
||||||
|
.or_else(|| external.clone().map(|p| p.display().to_string()))
|
||||||
|
{
|
||||||
|
println!(" {} {}", dim("● path"), p);
|
||||||
|
}
|
||||||
|
if let Some(e) = &entry {
|
||||||
|
if let Some(bin) = e.bins.first() {
|
||||||
|
println!(" {} {}", dim("● bin"), bin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- How to run it -------------------------------------------------------
|
||||||
if let Some(run) = &agent.run {
|
if let Some(run) = &agent.run {
|
||||||
let args = if agent.args.is_empty() {
|
let args = if agent.args.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!(" {}", agent.args.join(" "))
|
format!(" {}", agent.args.join(" "))
|
||||||
};
|
};
|
||||||
println!(" run: {run}{args}");
|
println!();
|
||||||
|
println!(" {} {}", dim("▶ run"), format!("{run}{args}"));
|
||||||
}
|
}
|
||||||
if !agent.env.is_empty() {
|
if !agent.env.is_empty() {
|
||||||
println!(
|
println!(
|
||||||
" env: {}",
|
" {} {}",
|
||||||
|
dim("⚙ env"),
|
||||||
agent
|
agent
|
||||||
.env
|
.env
|
||||||
.iter()
|
.iter()
|
||||||
@@ -155,38 +207,72 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Installation ---------------------------------------------------------
|
||||||
if let Some(spec) = &agent.install {
|
if let Some(spec) = &agent.install {
|
||||||
println!(" install methods:");
|
println!();
|
||||||
|
println!(" {}", bold("install"));
|
||||||
for (i, m) in spec.all_methods().iter().enumerate() {
|
for (i, m) in spec.all_methods().iter().enumerate() {
|
||||||
let note = m
|
println!(" {}) {}", i + 1, describe_method(m));
|
||||||
.note
|
if let Some(n) = &m.note {
|
||||||
.as_deref()
|
println!(" {}", dim(n));
|
||||||
.map(|n| format!(" ({n})"))
|
}
|
||||||
.unwrap_or_default();
|
|
||||||
println!(" {}) {}{}", i + 1, describe_method(m), note);
|
|
||||||
}
|
}
|
||||||
if !spec.post_install.is_empty() {
|
if !spec.post_install.is_empty() {
|
||||||
println!(" post-install: {}", spec.post_install.join(" && "));
|
println!(
|
||||||
|
" {}",
|
||||||
|
dim(&format!("post-install: {}", spec.post_install.join(" && ")))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Dependencies, checked live ------------------------------------------
|
||||||
if !agent.dependencies.is_empty() {
|
if !agent.dependencies.is_empty() {
|
||||||
println!(" dependencies:");
|
println!();
|
||||||
for d in &agent.dependencies {
|
println!(" {}", bold("dependencies"));
|
||||||
let min = d
|
let runner = crate::runner::SystemRunner::new(false, app.cli.verbose, &app.log);
|
||||||
.min_version
|
let checks = crate::deps::check_agent(agent, &runner);
|
||||||
.as_deref()
|
for c in &checks {
|
||||||
.map(|m| format!(" >= {m}"))
|
let mark = if c.ok { "✓" } else { "✗" };
|
||||||
.unwrap_or_default();
|
let mark = if color {
|
||||||
let hint = d
|
if c.ok {
|
||||||
.install_hint
|
mark.green().to_string()
|
||||||
.as_deref()
|
} else {
|
||||||
.map(|h| format!(" — {h}"))
|
mark.red().to_string()
|
||||||
.unwrap_or_default();
|
}
|
||||||
println!(" - {}{}{}", d.name, min, hint);
|
} else {
|
||||||
|
mark.to_string()
|
||||||
|
};
|
||||||
|
let mut line = format!(" {mark} {}", c.name);
|
||||||
|
if let Some(v) = &c.version {
|
||||||
|
line.push_str(&format!(" {v}"));
|
||||||
|
}
|
||||||
|
if let Some(min) = &c.min {
|
||||||
|
line.push_str(&format!(" (min {min})"));
|
||||||
|
}
|
||||||
|
if !c.ok {
|
||||||
|
if let Some(hint) = agent
|
||||||
|
.dependencies
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.name == c.name)
|
||||||
|
.and_then(|d| d.install_hint.clone())
|
||||||
|
{
|
||||||
|
line.push_str(&format!(" — {hint}"));
|
||||||
|
} else if !c.detail.is_empty() && c.detail != "not found on PATH" {
|
||||||
|
line.push_str(&format!(" — {}", c.detail));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tags and note ---------------------------------------------------------
|
||||||
|
if !agent.tags.is_empty() {
|
||||||
|
println!();
|
||||||
|
println!(" {}", dim(&format!("tags {}", agent.tags.join(" · "))));
|
||||||
|
}
|
||||||
if let Some(n) = &agent.note {
|
if let Some(n) = &agent.note {
|
||||||
println!(" note: {n}");
|
println!();
|
||||||
|
println!(" {}", dim(&format!("note {n}")));
|
||||||
}
|
}
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,10 +50,11 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let color = app.color();
|
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 {
|
for r in &rows {
|
||||||
table.row(vec![
|
table.row(vec![
|
||||||
r.display_name.clone(),
|
r.display_name.clone(),
|
||||||
|
r.name.clone(),
|
||||||
r.version.clone().unwrap_or_else(|| "—".to_string()),
|
r.version.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
status_style(&r.status, color),
|
status_style(&r.status, color),
|
||||||
r.source.clone(),
|
r.source.clone(),
|
||||||
|
|||||||
+270
-28
@@ -3,12 +3,18 @@
|
|||||||
//! configuration directories are deleted, then the executable itself.
|
//! configuration directories are deleted, then the executable itself.
|
||||||
|
|
||||||
use crate::app::{self, App};
|
use crate::app::{self, App};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Result};
|
||||||
|
#[cfg(unix)]
|
||||||
|
use anyhow::Context;
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
const MOVEFILE_DELAY_UNTIL_REBOOT: u32 = 0x4;
|
const MOVEFILE_DELAY_UNTIL_REBOOT: u32 = 0x4;
|
||||||
|
#[cfg(windows)]
|
||||||
|
const FILE_ATTRIBUTE_READONLY: u32 = 0x1;
|
||||||
|
#[cfg(windows)]
|
||||||
|
const INVALID_FILE_ATTRIBUTES: u32 = 0xFFFF_FFFF;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[link(name = "Kernel32")]
|
#[link(name = "Kernel32")]
|
||||||
@@ -18,6 +24,8 @@ extern "system" {
|
|||||||
lpNewFileName: *const u16,
|
lpNewFileName: *const u16,
|
||||||
dwFlags: u32,
|
dwFlags: u32,
|
||||||
) -> i32;
|
) -> i32;
|
||||||
|
fn GetFileAttributesW(lpFileName: *const u16) -> u32;
|
||||||
|
fn SetFileAttributesW(lpFileName: *const u16, dwFileAttributes: u32) -> i32;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(app: &App) -> Result<i32> {
|
pub fn run(app: &App) -> Result<i32> {
|
||||||
@@ -48,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() {
|
if roots.is_empty() {
|
||||||
app.log
|
app.log
|
||||||
.info("nothing to remove — agent-manager left no traces on this machine");
|
.info("nothing to remove — agent-manager left no traces on this machine");
|
||||||
@@ -64,33 +75,160 @@ pub fn run(app: &App) -> Result<i32> {
|
|||||||
app.log.dry("nothing removed (dry run)");
|
app.log.dry("nothing removed (dry run)");
|
||||||
return Ok(0);
|
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
|
// Remove everything first; any message logged *after* deleting the
|
||||||
// state directory would recreate the log folder, so the removal
|
// state directory would recreate the log folder, so the removal
|
||||||
// itself is silent and the report below goes to plain stdout.
|
// itself is silent and the report below goes to plain stdout.
|
||||||
|
// The removal is tolerant: read-only attributes are cleared and files
|
||||||
|
// locked by other processes (for example the running am.exe when it
|
||||||
|
// lives inside the data directory) are scheduled for deletion at the
|
||||||
|
// next reboot instead of failing the whole uninstall.
|
||||||
|
let mut locked: Vec<PathBuf> = Vec::new();
|
||||||
for (_, path) in &roots {
|
for (_, path) in &roots {
|
||||||
std::fs::remove_dir_all(path)
|
remove_tree(path, &mut locked);
|
||||||
.with_context(|| format!("cannot remove {}", path.display()))?;
|
|
||||||
}
|
}
|
||||||
for (_, path) in &roots {
|
for (_, path) in &roots {
|
||||||
println!("{} {}", "removed:".green(), path.display());
|
if !path.exists() {
|
||||||
|
println!("{} {}", "removed:".green(), path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !locked.is_empty() {
|
||||||
|
let scheduled = schedule_locked(&locked);
|
||||||
|
if !scheduled.is_empty() {
|
||||||
|
println!(
|
||||||
|
"{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
|
||||||
|
"locked:".yellow(),
|
||||||
|
scheduled.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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.
|
// 3. Delete the executable itself, then sweep the directories left
|
||||||
let exe = std::env::current_exe()
|
// empty behind it.
|
||||||
.with_context(|| "cannot locate the agent-manager executable")?;
|
let cleanup_roots: Vec<PathBuf> = roots.iter().map(|(_, p)| p.clone()).collect();
|
||||||
remove_self(&exe)?;
|
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)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recursively remove a tree, tolerating locked or read-only entries.
|
||||||
|
/// Paths that could not be removed are collected into `locked` so the
|
||||||
|
/// caller can schedule their deletion at the next reboot.
|
||||||
|
fn remove_tree(path: &Path, locked: &mut Vec<PathBuf>) {
|
||||||
|
let meta = match std::fs::symlink_metadata(path) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if meta.is_dir() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
remove_tree(&entry.path(), locked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clear_readonly(path);
|
||||||
|
if std::fs::remove_dir(path).is_err() {
|
||||||
|
locked.push(path.to_path_buf());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clear_readonly(path);
|
||||||
|
if std::fs::remove_file(path).is_err() {
|
||||||
|
locked.push(path.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the read-only attribute (a common cause of "Access denied" on
|
||||||
|
/// Windows, e.g. files extracted by Expand-Archive).
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn clear_readonly(path: &Path) {
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
let wide: Vec<u16> = path
|
||||||
|
.as_os_str()
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
unsafe {
|
||||||
|
let attrs = GetFileAttributesW(wide.as_ptr());
|
||||||
|
if attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_READONLY) != 0 {
|
||||||
|
SetFileAttributesW(wide.as_ptr(), attrs & !FILE_ATTRIBUTE_READONLY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn clear_readonly(_path: &Path) {}
|
||||||
|
|
||||||
|
/// Schedule every locked path for deletion at the next reboot (Windows).
|
||||||
|
/// Returns the paths that could be scheduled.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn schedule_locked(locked: &[PathBuf]) -> Vec<PathBuf> {
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
let mut scheduled = Vec::new();
|
||||||
|
for path in locked {
|
||||||
|
let wide: Vec<u16> = path
|
||||||
|
.as_os_str()
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let ok = unsafe {
|
||||||
|
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
|
||||||
|
};
|
||||||
|
if ok {
|
||||||
|
scheduled.push(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduled
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn schedule_locked(_locked: &[PathBuf]) -> Vec<PathBuf> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete the running executable. On Unix the file can be unlinked while it
|
/// Delete the running executable. On Unix the file can be unlinked while it
|
||||||
/// runs; on Windows a detached cmd re-tries the deletion right after this
|
/// runs; on Windows a hidden cmd re-tries the deletion right after this
|
||||||
/// process exits (the file is locked while we run).
|
/// process exits (the file is locked while we run). Empty directories left
|
||||||
fn remove_self(exe: &Path) -> Result<()> {
|
/// behind under the removed roots are swept afterwards.
|
||||||
|
fn remove_self(exe: &Path, renamed: Option<&Path>, roots: &[PathBuf]) -> Result<()> {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
std::fs::remove_file(exe)
|
std::fs::remove_file(exe)
|
||||||
.with_context(|| format!("cannot remove {}", exe.display()))?;
|
.with_context(|| format!("cannot remove {}", exe.display()))?;
|
||||||
|
// Sweep empty directories upward, but never above a removed root.
|
||||||
|
let mut dir = exe.parent();
|
||||||
|
while let Some(d) = dir {
|
||||||
|
if !roots.iter().any(|r| d.starts_with(r)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if std::fs::remove_dir(d).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
dir = d.parent();
|
||||||
|
}
|
||||||
println!(
|
println!(
|
||||||
"{} {}",
|
"{} {}",
|
||||||
"agent-manager removed:".green(),
|
"agent-manager removed:".green(),
|
||||||
@@ -101,19 +239,27 @@ fn remove_self(exe: &Path) -> Result<()> {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
use std::os::windows::ffi::OsStrExt;
|
use std::os::windows::ffi::OsStrExt;
|
||||||
// Immediate deletion: a hidden cmd retries for a few seconds, because
|
// The file to delete once this process exits: the renamed copy when
|
||||||
// the executable is locked while we are still running.
|
// the up-front rename worked, the original path otherwise.
|
||||||
let script = format!(
|
let target = renamed.unwrap_or(exe);
|
||||||
"for /L %i in (1,1,20) do @(del /f /q \"{0}\" >nul 2>&1 & if not exist \"{0}\" exit /b 0 & ping -n 2 127.0.0.1 >nul)",
|
// A temporary batch file retries the deletion for a few seconds (the
|
||||||
exe.display()
|
// image stays locked until we exit), sweeps the directories left
|
||||||
);
|
// empty, then deletes itself. A .bat avoids every cmd /C command-line
|
||||||
let spawned = std::process::Command::new("cmd")
|
// quoting pitfall.
|
||||||
.args(["/C", &script])
|
let bat = write_cleanup_bat(exe, target, roots);
|
||||||
.spawn()
|
if let Some(b) = &bat {
|
||||||
.is_ok();
|
// 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();
|
||||||
|
}
|
||||||
// Belt and braces: if something keeps the file locked, the standard
|
// Belt and braces: if something keeps the file locked, the standard
|
||||||
// uninstaller mechanism deletes it at the next reboot.
|
// uninstaller mechanism deletes it at the next reboot.
|
||||||
let wide: Vec<u16> = exe
|
let wide: Vec<u16> = target
|
||||||
.as_os_str()
|
.as_os_str()
|
||||||
.encode_wide()
|
.encode_wide()
|
||||||
.chain(std::iter::once(0))
|
.chain(std::iter::once(0))
|
||||||
@@ -121,17 +267,25 @@ fn remove_self(exe: &Path) -> Result<()> {
|
|||||||
let at_reboot = unsafe {
|
let at_reboot = unsafe {
|
||||||
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
|
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
|
||||||
};
|
};
|
||||||
if !spawned && !at_reboot {
|
if bat.is_none() && !at_reboot {
|
||||||
bail!(
|
bail!(
|
||||||
"cannot schedule deletion of {} — delete it manually",
|
"cannot schedule deletion of {} — delete it manually",
|
||||||
exe.display()
|
exe.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
println!(
|
if renamed.is_some() {
|
||||||
"{} {}",
|
println!(
|
||||||
"agent-manager removed; the executable will be deleted momentarily:".green(),
|
"{} {}",
|
||||||
exe.display()
|
"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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
#[cfg(not(any(unix, windows)))]
|
#[cfg(not(any(unix, windows)))]
|
||||||
@@ -142,3 +296,91 @@ fn remove_self(exe: &Path) -> 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::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removes_readonly_tree() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let sub = dir.path().join("sub");
|
||||||
|
std::fs::create_dir_all(&sub).unwrap();
|
||||||
|
let file = sub.join("readonly.txt");
|
||||||
|
std::fs::write(&file, "x").unwrap();
|
||||||
|
// Read-only files are a classic cause of "Access denied" on Windows.
|
||||||
|
let mut perms = std::fs::metadata(&file).unwrap().permissions();
|
||||||
|
#[allow(clippy::permissions_set_readonly_false)]
|
||||||
|
perms.set_readonly(true);
|
||||||
|
std::fs::set_permissions(&file, perms).unwrap();
|
||||||
|
|
||||||
|
let mut locked: Vec<PathBuf> = Vec::new();
|
||||||
|
remove_tree(dir.path(), &mut locked);
|
||||||
|
assert!(!dir.path().exists(), "tree should be fully removed");
|
||||||
|
assert!(locked.is_empty(), "unexpected locked paths: {locked:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tolerates_missing_tree() {
|
||||||
|
let mut locked: Vec<PathBuf> = Vec::new();
|
||||||
|
remove_tree(Path::new("/nonexistent/agent-manager-xyz"), &mut locked);
|
||||||
|
assert!(locked.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
//! uninstall: stop the agent if needed, remove its files, bins and state
|
//! uninstall: stop the agent if needed, remove its files, bins and state
|
||||||
//! entry; --purge also removes logs and the user-config definition.
|
//! 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 super::*;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command as ProcessCommand;
|
||||||
|
|
||||||
pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
||||||
let agent = require_agent(app, agent_name)?;
|
let agent = require_agent(app, agent_name)?;
|
||||||
let entry = app.state.get(&agent.name).ok().flatten();
|
let entry = app.state.get(&agent.name).ok().flatten();
|
||||||
let Some(entry) = entry else {
|
let Some(entry) = entry else {
|
||||||
app.log.info(&format!(
|
return uninstall_external(app, agent);
|
||||||
"agent '{}' is not managed by agent-manager — nothing to uninstall",
|
|
||||||
agent.name
|
|
||||||
));
|
|
||||||
return Ok(0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stop it first when running.
|
// Stop it first when running.
|
||||||
@@ -65,6 +65,130 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
|||||||
Ok(0)
|
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).
|
/// Remove the agent entry from the user config file (--purge).
|
||||||
fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
||||||
let Some(path) = &app.config_origin else {
|
let Some(path) = &app.config_origin else {
|
||||||
@@ -111,3 +235,80 @@ fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
|||||||
Ok(())
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -624,6 +624,7 @@ mod tests {
|
|||||||
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
|
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
|
||||||
assert!(cfg.agents.len() >= 60, "catalog too small: {}", cfg.agents.len());
|
assert!(cfg.agents.len() >= 60, "catalog too small: {}", cfg.agents.len());
|
||||||
assert!(cfg.agents.iter().any(|a| a.name == "claude-code"));
|
assert!(cfg.agents.iter().any(|a| a.name == "claude-code"));
|
||||||
|
assert!(cfg.agents.iter().any(|a| a.name == "deepseek-harness"));
|
||||||
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
|
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,16 @@ pub mod deps;
|
|||||||
pub mod doctor;
|
pub mod doctor;
|
||||||
pub mod download;
|
pub mod download;
|
||||||
pub mod installers;
|
pub mod installers;
|
||||||
|
pub mod nav;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
pub mod probe;
|
pub mod probe;
|
||||||
|
pub mod ps;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod repl;
|
pub mod repl;
|
||||||
pub mod runner;
|
pub mod runner;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod tables;
|
||||||
pub mod toolchain;
|
pub mod toolchain;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
|
|||||||
+312
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
fn strip_ansi(s: &str) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let mut chars = s.chars();
|
let mut chars = s.chars();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+259
-22
@@ -15,6 +15,9 @@
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::cli::{Cli, Command, StartArgs};
|
use crate::cli::{Cli, Command, StartArgs};
|
||||||
use crate::commands;
|
use crate::commands;
|
||||||
|
use crate::nav;
|
||||||
|
use crate::ps;
|
||||||
|
use crate::tables::DataTable;
|
||||||
use crate::shell::ShellSession;
|
use crate::shell::ShellSession;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use clap::CommandFactory;
|
use clap::CommandFactory;
|
||||||
@@ -44,8 +47,43 @@ pub struct AmCompleter {
|
|||||||
agents: Vec<String>,
|
agents: Vec<String>,
|
||||||
aliases: Vec<String>,
|
aliases: Vec<String>,
|
||||||
groups: 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 {
|
impl AmCompleter {
|
||||||
pub fn new(app: &App) -> Self {
|
pub fn new(app: &App) -> Self {
|
||||||
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups)
|
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups)
|
||||||
@@ -67,12 +105,27 @@ impl AmCompleter {
|
|||||||
alias_list.sort();
|
alias_list.sort();
|
||||||
let mut group_list: Vec<String> = groups.keys().map(|g| format!("group:{g}")).collect();
|
let mut group_list: Vec<String> = groups.keys().map(|g| format!("group:{g}")).collect();
|
||||||
group_list.sort();
|
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 {
|
Self {
|
||||||
commands: vec![
|
commands: vec![
|
||||||
"list", "status", "search", "info", "install", "uninstall", "update",
|
"list", "status", "search", "info", "install", "uninstall", "update",
|
||||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||||
"self-update", "self-uninstall", "export", "import", "shell", "help",
|
"self-update", "self-uninstall", "export", "import", "shell", "ls", "dir",
|
||||||
"version", "exit",
|
"cd", "ps", "where", "get", "help", "version", "exit",
|
||||||
],
|
],
|
||||||
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
||||||
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
|
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
|
||||||
@@ -88,6 +141,7 @@ impl AmCompleter {
|
|||||||
agents,
|
agents,
|
||||||
aliases: alias_list,
|
aliases: alias_list,
|
||||||
groups: group_list,
|
groups: group_list,
|
||||||
|
descriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +172,16 @@ impl AmCompleter {
|
|||||||
"shell" => {
|
"shell" => {
|
||||||
pool = self.gateway_shells.iter().map(|s| s.to_string()).collect();
|
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"
|
"install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run"
|
||||||
| "info" | "status" => {
|
| "info" | "status" => {
|
||||||
if word.starts_with("group:") {
|
if word.starts_with("group:") {
|
||||||
@@ -136,6 +200,11 @@ impl AmCompleter {
|
|||||||
_ => {
|
_ => {
|
||||||
if word.starts_with('-') {
|
if word.starts_with('-') {
|
||||||
pool = self.options.iter().map(|s| s.to_string()).collect();
|
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 (start, cands) = self.candidates_for(line, pos);
|
||||||
let pairs = cands
|
let pairs = cands
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|c| Pair {
|
.map(|c| {
|
||||||
display: c.clone(),
|
// Nushell-style menu: show a description next to the
|
||||||
replacement: c,
|
// 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();
|
.collect();
|
||||||
Ok((start, pairs))
|
Ok((start, pairs))
|
||||||
@@ -340,7 +417,9 @@ pub fn banner_box(
|
|||||||
" system doctor · config · completion · self-update · self-uninstall · export · import"
|
" system doctor · config · completion · self-update · self-uninstall · export · import"
|
||||||
.to_string(),
|
.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.
|
// Stats footer.
|
||||||
rows.push(blank());
|
rows.push(blank());
|
||||||
@@ -536,6 +615,20 @@ fn current_dir_string() -> String {
|
|||||||
.unwrap_or_else(|_| "?".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
|
// Interactive loop
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -568,11 +661,17 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
|||||||
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
|
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
|
||||||
rustyline::Editor::new()?;
|
rustyline::Editor::new()?;
|
||||||
rl.set_helper(Some(AmCompleter::new(app)));
|
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");
|
let history_path = app.paths.state_file.with_file_name("history.txt");
|
||||||
if rl.load_history(&history_path).is_err() {
|
if rl.load_history(&history_path).is_err() {
|
||||||
// First run: no history yet.
|
// First run: no history yet.
|
||||||
}
|
}
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
|
let mut last: Option<DataTable> = None;
|
||||||
loop {
|
loop {
|
||||||
print_status(app, session, started);
|
print_status(app, session, started);
|
||||||
match rl.readline("❯ ") {
|
match rl.readline("❯ ") {
|
||||||
@@ -582,7 +681,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let _ = rl.add_history_entry(trimmed.as_str());
|
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(true) => break,
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Err(e) => app.log.error(&format!("{e:#}")),
|
Err(e) => app.log.error(&format!("{e:#}")),
|
||||||
@@ -603,6 +702,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
|||||||
/// Plain fallback when no terminal/line editor is available.
|
/// Plain fallback when no terminal/line editor is available.
|
||||||
fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
|
let mut last: Option<DataTable> = None;
|
||||||
loop {
|
loop {
|
||||||
print_status(app, session, started);
|
print_status(app, session, started);
|
||||||
eprint!("❯ ");
|
eprint!("❯ ");
|
||||||
@@ -615,7 +715,7 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
|||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match handle_input(app, session, &line) {
|
match handle_input(app, session, &mut last, &line) {
|
||||||
Ok(true) => break,
|
Ok(true) => break,
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Err(e) => app.log.error(&format!("{e:#}")),
|
Err(e) => app.log.error(&format!("{e:#}")),
|
||||||
@@ -625,7 +725,12 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle one line of input. Returns true when the session should exit.
|
/// 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 {
|
match line {
|
||||||
"exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true),
|
"exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true),
|
||||||
"help" | "?" | "/help" => {
|
"help" | "?" | "/help" => {
|
||||||
@@ -652,37 +757,83 @@ fn handle_input(app: &App, session: &mut ShellSession, line: &str) -> Result<boo
|
|||||||
}
|
}
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
handle_line(app, session, line)?;
|
handle_line(app, session, last, line)
|
||||||
Ok(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_line(app: &App, session: &mut ShellSession, line: &str) -> Result<()> {
|
/// 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.
|
// '!' forces system execution even for words that look like am commands.
|
||||||
if let Some(rest) = line.strip_prefix('!') {
|
if let Some(rest) = line.strip_prefix('!') {
|
||||||
let rest = rest.trim();
|
let rest = rest.trim();
|
||||||
if !rest.is_empty() {
|
if !rest.is_empty() {
|
||||||
return run_system(app, session, rest);
|
run_system(app, session, rest)?;
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let tokens = shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))?;
|
let tokens = shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))?;
|
||||||
if tokens.is_empty() {
|
if tokens.is_empty() {
|
||||||
return Ok(());
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let cmd = tokens[0].as_str();
|
let cmd = tokens[0].as_str();
|
||||||
let rest = &tokens[1..];
|
let rest = &tokens[1..];
|
||||||
|
|
||||||
// Session directory change: kept in the am process, not a child shell.
|
// Session directory change: kept in the am process, not a child shell.
|
||||||
if matches!(cmd, "cd" | "chdir") {
|
if matches!(cmd, "cd" | "chdir") {
|
||||||
return change_dir(rest);
|
change_dir(rest)?;
|
||||||
|
return Ok(false);
|
||||||
}
|
}
|
||||||
if cmd == "shell" {
|
if cmd == "shell" {
|
||||||
shell_command(session, rest.first().map(|s| s.as_str()));
|
shell_command(session, rest.first().map(|s| s.as_str()));
|
||||||
return Ok(());
|
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.
|
// Everything that is not an am command is a system command.
|
||||||
if !is_am_command(cmd) {
|
if !is_am_command(cmd) {
|
||||||
return run_system(app, session, line);
|
run_system(app, session, line)?;
|
||||||
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let need = |what: &str| -> Result<String> {
|
let need = |what: &str| -> Result<String> {
|
||||||
@@ -786,11 +937,14 @@ fn handle_line(app: &App, session: &mut ShellSession, line: &str) -> Result<()>
|
|||||||
other => {
|
other => {
|
||||||
app.log
|
app.log
|
||||||
.error(&format!("unknown command '{other}' — type 'help'"));
|
.error(&format!("unknown command '{other}' — type 'help'"));
|
||||||
return Ok(());
|
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)?;
|
commands::execute_command(app, &command)?;
|
||||||
Ok(())
|
Ok(exit_after)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a word is one of the am commands (everything else is executed by
|
/// Whether a word is one of the am commands (everything else is executed by
|
||||||
@@ -819,6 +973,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).
|
/// Run one system command through the active shell (inherited stdio).
|
||||||
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
|
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
|
||||||
app.log.cmd(&format!("{}: {line}", session.current.name));
|
app.log.cmd(&format!("{}: {line}", session.current.name));
|
||||||
@@ -898,12 +1126,21 @@ fn print_help() {
|
|||||||
println!(" export [--output FILE] backup config + state");
|
println!(" export [--output FILE] backup config + state");
|
||||||
println!(" import <file> restore a backup");
|
println!(" import <file> restore a backup");
|
||||||
println!(" shell [list|<name>] show or switch the system shell");
|
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!(" help | version | exit this help, the version, quit");
|
||||||
println!();
|
println!();
|
||||||
|
println!("pipelines:");
|
||||||
|
println!(" ls | where size > 1mb | get name");
|
||||||
|
println!(" ps | where name =~ am");
|
||||||
|
println!();
|
||||||
println!("system gateway:");
|
println!("system gateway:");
|
||||||
println!(" any other input runs as a system command in the active shell");
|
println!(" any other input runs as a system command in the active shell");
|
||||||
println!(" prefix with '!' to force system execution (e.g. '!list')");
|
println!(" prefix with '!' to force system execution (e.g. '!ls', '!list')");
|
||||||
println!(" 'cd <dir>' changes the session directory (in-process)");
|
println!(" Tab inserts the common prefix and lists candidates with descriptions");
|
||||||
println!();
|
println!();
|
||||||
println!("slash commands: /help · /version · /exit · /shell <name>");
|
println!("slash commands: /help · /version · /exit · /shell <name>");
|
||||||
}
|
}
|
||||||
|
|||||||
+425
@@ -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}[0m")
|
||||||
|
} 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}["));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user