Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e1b66809a | ||
|
|
d886de322c | ||
|
|
bc38cd59e5 |
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.2.5"
|
||||
version = "0.2.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.2.5"
|
||||
version = "0.2.8"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
|
||||
@@ -90,7 +90,7 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
| am install <agent> | installe (dépendances vérifiées et proposées) |
|
||||
| am install <agent> --method pip | choisit une méthode d'installation |
|
||||
| am update <agent> | met à jour vers la dernière version connue |
|
||||
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) |
|
||||
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) — gère aussi les agents externes (npm/pip/uv/cargo/bun, sinon suppression des fichiers) |
|
||||
| am export / am import | sauvegarde et restaure config + état |
|
||||
|
||||
### 🚀 Exécuter
|
||||
@@ -181,13 +181,19 @@ démarrage et mis en évidence** dans la bannière (config
|
||||
`settings.default_shell`, puis `SHELL`/`COMSPEC`, puis détection sur le
|
||||
PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish).
|
||||
|
||||
❯ ls # exécuté par le shell actif (cmd, pwsh, bash, ...)
|
||||
❯ ls # liste façon Nushell : tableau encadré (type, taille, date)
|
||||
❯ ls src # un chemin précis — Tab complète fichiers et dossiers
|
||||
❯ cd <Tab> # complétion des dossiers uniquement, un niveau par Tab
|
||||
❯ shell # affiche le shell courant + les shells disponibles
|
||||
❯ shell bash # change le shell de la session
|
||||
❯ !list # force l'exécution système (même nom qu'une commande am)
|
||||
❯ !ls · !list # force l'exécution système (même nom qu'une commande am)
|
||||
❯ cd ~/projets # change le répertoire de la session (persistant)
|
||||
❯ /help # commandes slash : /help · /version · /exit · /shell <nom>
|
||||
|
||||
Le menu Tab affiche une **description** à côté de chaque candidat
|
||||
(commandes et agents), comme Nushell, et **cycle** entre les propositions à
|
||||
chaque Tab.
|
||||
|
||||
Pour rendre le choix permanent : `settings.default_shell: pwsh` dans
|
||||
`config.yaml` (voir `am config path`).
|
||||
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ pub enum Command {
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Uninstall an agent and clean its files
|
||||
/// Uninstall an agent (managed or external) and clean its files
|
||||
Uninstall {
|
||||
/// Agent name or alias
|
||||
agent: String,
|
||||
|
||||
@@ -50,10 +50,11 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
|
||||
return Ok(0);
|
||||
}
|
||||
let color = app.color();
|
||||
let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "SOURCE", "PATH"]);
|
||||
let mut table = Table::new(vec!["NAME", "AGENT", "VERSION", "STATUS", "SOURCE", "PATH"]);
|
||||
for r in &rows {
|
||||
table.row(vec![
|
||||
r.display_name.clone(),
|
||||
r.name.clone(),
|
||||
r.version.clone().unwrap_or_else(|| "—".to_string()),
|
||||
status_style(&r.status, color),
|
||||
r.source.clone(),
|
||||
|
||||
+123
-48
@@ -3,7 +3,9 @@
|
||||
//! configuration directories are deleted, then the executable itself.
|
||||
|
||||
use crate::app::{self, App};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{bail, Result};
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context;
|
||||
use colored::Colorize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -54,6 +56,9 @@ pub fn run(app: &App) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().ok();
|
||||
let mut renamed: Option<PathBuf> = None;
|
||||
|
||||
if roots.is_empty() {
|
||||
app.log
|
||||
.info("nothing to remove — agent-manager left no traces on this machine");
|
||||
@@ -70,6 +75,20 @@ pub fn run(app: &App) -> Result<i32> {
|
||||
app.log.dry("nothing removed (dry run)");
|
||||
return Ok(0);
|
||||
}
|
||||
// Windows allows renaming a running executable (but not deleting
|
||||
// it): do it up-front so the original binary disappears immediately,
|
||||
// even if the deferred deletion has to wait for this process to exit.
|
||||
renamed = exe.as_deref().and_then(rename_for_deletion);
|
||||
if let Some(new) = &renamed {
|
||||
if let Some(old) = &exe {
|
||||
println!(
|
||||
"{} {} -> {}",
|
||||
"renamed:".cyan(),
|
||||
old.display(),
|
||||
new.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
// Remove everything first; any message logged *after* deleting the
|
||||
// state directory would recreate the log folder, so the removal
|
||||
// itself is silent and the report below goes to plain stdout.
|
||||
@@ -88,38 +107,31 @@ pub fn run(app: &App) -> Result<i32> {
|
||||
}
|
||||
if !locked.is_empty() {
|
||||
let scheduled = schedule_locked(&locked);
|
||||
if scheduled.len() == locked.len() {
|
||||
if !scheduled.is_empty() {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
|
||||
"locked:".yellow(),
|
||||
locked.len()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s), {} planifiée(s) au prochain redémarrage",
|
||||
"locked:".yellow(),
|
||||
locked.len(),
|
||||
scheduled.len()
|
||||
);
|
||||
for path in &locked {
|
||||
if !scheduled.contains(path) {
|
||||
eprintln!(
|
||||
"{} {} (supprimez-le manuellement ou relancez en administrateur)",
|
||||
"kept:".red(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if scheduled.len() != locked.len() {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
|
||||
"deferred:".cyan(),
|
||||
locked.len() - scheduled.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete the executable itself, then sweep the directories left
|
||||
// empty behind it.
|
||||
let exe = std::env::current_exe()
|
||||
.with_context(|| "cannot locate the agent-manager executable")?;
|
||||
let cleanup_roots: Vec<PathBuf> = roots.iter().map(|(_, p)| p.clone()).collect();
|
||||
remove_self(&exe, &cleanup_roots)?;
|
||||
match &exe {
|
||||
Some(e) => remove_self(e, renamed.as_deref(), &cleanup_roots)?,
|
||||
None => app.log
|
||||
.warn("cannot locate the agent-manager executable — delete it manually"),
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
@@ -201,7 +213,7 @@ fn schedule_locked(_locked: &[PathBuf]) -> Vec<PathBuf> {
|
||||
/// runs; on Windows a hidden cmd re-tries the deletion right after this
|
||||
/// process exits (the file is locked while we run). Empty directories left
|
||||
/// behind under the removed roots are swept afterwards.
|
||||
fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
|
||||
fn remove_self(exe: &Path, renamed: Option<&Path>, roots: &[PathBuf]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::fs::remove_file(exe)
|
||||
@@ -227,29 +239,27 @@ fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
// Immediate deletion: a hidden cmd retries for a few seconds, because
|
||||
// the executable is locked while we are still running.
|
||||
// Once the file is gone, the now-empty directories (parent, then the
|
||||
// removed roots) are swept — rd /q only ever removes empty dirs.
|
||||
let mut script = format!(
|
||||
"for /L %i in (1,1,20) do @if exist \"{0}\" (del /f /q \"{0}\" >nul 2>&1 & ping -n 2 127.0.0.1 >nul)",
|
||||
exe.display()
|
||||
);
|
||||
if let Some(parent) = exe.parent() {
|
||||
if roots.iter().any(|r| parent.starts_with(r)) {
|
||||
script.push_str(&format!(" & rd /q \"{}\" >nul 2>&1", parent.display()));
|
||||
}
|
||||
// The file to delete once this process exits: the renamed copy when
|
||||
// the up-front rename worked, the original path otherwise.
|
||||
let target = renamed.unwrap_or(exe);
|
||||
// A temporary batch file retries the deletion for a few seconds (the
|
||||
// image stays locked until we exit), sweeps the directories left
|
||||
// empty, then deletes itself. A .bat avoids every cmd /C command-line
|
||||
// quoting pitfall.
|
||||
let bat = write_cleanup_bat(exe, target, roots);
|
||||
if let Some(b) = &bat {
|
||||
// raw_arg passes the command line verbatim: regular .arg()
|
||||
// backslash-escapes embedded quotes, which cmd.exe does not
|
||||
// understand.
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
let mut cleanup = std::process::Command::new("cmd");
|
||||
cleanup.raw_arg("/C");
|
||||
cleanup.raw_arg(format!("\"{}\"", b.display()));
|
||||
let _ = cleanup.spawn();
|
||||
}
|
||||
for root in roots {
|
||||
script.push_str(&format!(" & rd /q \"{}\" >nul 2>&1", root.display()));
|
||||
}
|
||||
let spawned = std::process::Command::new("cmd")
|
||||
.args(["/C", &script])
|
||||
.spawn()
|
||||
.is_ok();
|
||||
// Belt and braces: if something keeps the file locked, the standard
|
||||
// uninstaller mechanism deletes it at the next reboot.
|
||||
let wide: Vec<u16> = exe
|
||||
let wide: Vec<u16> = target
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
@@ -257,17 +267,25 @@ fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
|
||||
let at_reboot = unsafe {
|
||||
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
|
||||
};
|
||||
if !spawned && !at_reboot {
|
||||
if bat.is_none() && !at_reboot {
|
||||
bail!(
|
||||
"cannot schedule deletion of {} — delete it manually",
|
||||
exe.display()
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"{} {}",
|
||||
"agent-manager removed; the executable will be deleted momentarily:".green(),
|
||||
exe.display()
|
||||
);
|
||||
if renamed.is_some() {
|
||||
println!(
|
||||
"{} {}",
|
||||
"agent-manager removed; the executable was renamed and will be deleted momentarily:".green(),
|
||||
exe.display()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} {}",
|
||||
"agent-manager removed; the executable will be deleted momentarily:".green(),
|
||||
exe.display()
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
@@ -279,6 +297,63 @@ fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename the running executable out of the way (Windows allows renaming a
|
||||
/// running image — only deletion is refused while it executes). Returns the
|
||||
/// new path on success.
|
||||
#[cfg(windows)]
|
||||
fn rename_for_deletion(exe: &Path) -> Option<PathBuf> {
|
||||
let name = exe.file_name()?.to_string_lossy();
|
||||
let new = exe.with_file_name(format!("{name}.deleteme"));
|
||||
std::fs::rename(exe, &new).ok()?;
|
||||
Some(new)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn rename_for_deletion(_exe: &Path) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Write a temporary batch file that deletes the target executable once it
|
||||
/// is unlocked, sweeps the directories left empty and then removes itself.
|
||||
#[cfg(windows)]
|
||||
fn write_cleanup_bat(exe: &Path, target: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
|
||||
let crlf = "\r\n";
|
||||
let q = "\"";
|
||||
let mut script = String::new();
|
||||
let mut line = |s: &str| {
|
||||
script.push_str(s);
|
||||
script.push_str(crlf);
|
||||
};
|
||||
line("@echo off");
|
||||
line("set n=0");
|
||||
line(":retry");
|
||||
line(&format!("del /f /q {q}{}{q} >nul 2>&1", target.display()));
|
||||
line(&format!("del /f /q {q}{}{q} >nul 2>&1", exe.display()));
|
||||
line(&format!(
|
||||
"if not exist {q}{}{q} (if not exist {q}{}{q} goto sweep)",
|
||||
target.display(),
|
||||
exe.display()
|
||||
));
|
||||
line("set /a n+=1");
|
||||
line("if %n% GEQ 60 goto end");
|
||||
line("ping -n 2 127.0.0.1 >nul");
|
||||
line("goto retry");
|
||||
line(":sweep");
|
||||
if let Some(parent) = exe.parent() {
|
||||
if roots.iter().any(|r| parent.starts_with(r)) {
|
||||
line(&format!("rd /q {q}{}{q} >nul 2>&1", parent.display()));
|
||||
}
|
||||
}
|
||||
for root in roots {
|
||||
line(&format!("rd /q {q}{}{q} >nul 2>&1", root.display()));
|
||||
}
|
||||
line(":end");
|
||||
line(&format!("del {q}%~f0{q} >nul 2>&1"));
|
||||
let path = std::env::temp_dir().join(format!("am-uninstall-{}.bat", std::process::id()));
|
||||
std::fs::write(&path, script).ok()?;
|
||||
Some(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
//! uninstall: stop the agent if needed, remove its files, bins and state
|
||||
//! entry; --purge also removes logs and the user-config definition.
|
||||
//! External agents (installed outside agent-manager) are removed through
|
||||
//! their package manager when the catalog knows it, or by deleting the
|
||||
//! executables found on PATH otherwise.
|
||||
|
||||
use super::*;
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
||||
let agent = require_agent(app, agent_name)?;
|
||||
let entry = app.state.get(&agent.name).ok().flatten();
|
||||
let Some(entry) = entry else {
|
||||
app.log.info(&format!(
|
||||
"agent '{}' is not managed by agent-manager — nothing to uninstall",
|
||||
agent.name
|
||||
));
|
||||
return Ok(0);
|
||||
return uninstall_external(app, agent);
|
||||
};
|
||||
|
||||
// Stop it first when running.
|
||||
@@ -65,6 +65,130 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Build the package-manager uninstall command for an external agent whose
|
||||
/// install method is a known package manager and whose package is recorded.
|
||||
fn package_plan(agent: &AgentDef) -> Option<Vec<String>> {
|
||||
use crate::config::InstallType;
|
||||
let spec = agent.install.as_ref()?;
|
||||
for m in spec.all_methods() {
|
||||
let Some(pkg) = m.package.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let plan = match m.kind {
|
||||
InstallType::Npm => {
|
||||
vec!["npm", "uninstall", "--global", pkg]
|
||||
}
|
||||
InstallType::Pip => {
|
||||
if which::which("pip").is_ok() {
|
||||
vec!["pip", "uninstall", "-y", pkg]
|
||||
} else if which::which("python3").is_ok() {
|
||||
vec!["python3", "-m", "pip", "uninstall", "-y", pkg]
|
||||
} else if which::which("python").is_ok() {
|
||||
vec!["python", "-m", "pip", "uninstall", "-y", pkg]
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
InstallType::Uv => vec!["uv", "tool", "uninstall", pkg],
|
||||
InstallType::Cargo => vec!["cargo", "uninstall", pkg],
|
||||
InstallType::Bun => vec!["bun", "remove", "--global", pkg],
|
||||
_ => continue,
|
||||
};
|
||||
return Some(plan.into_iter().map(|s| s.to_string()).collect());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Best-effort removal of an agent installed outside agent-manager: first the
|
||||
/// package-manager command when the catalog knows one, then the executable
|
||||
/// files left on PATH (and their directory when it is dedicated to the
|
||||
/// agent, e.g. AppData\Local\atomic-agent).
|
||||
fn uninstall_external(app: &App, agent: &AgentDef) -> Result<i32> {
|
||||
let first_token = agent.first_token();
|
||||
|
||||
// 1. Package-manager plan (npm, pip, uv, cargo, bun).
|
||||
if let Some(tokens) = package_plan(agent) {
|
||||
if which::which(&tokens[0]).is_ok() {
|
||||
app.log.info(&format!(
|
||||
"agent '{}' is external (installed outside agent-manager) — uninstall with: {}",
|
||||
agent.name,
|
||||
tokens.join(" ")
|
||||
));
|
||||
if !app.confirm("Run this command?")? {
|
||||
app.log.info("cancelled — nothing was uninstalled");
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would run: {}", tokens.join(" ")));
|
||||
} else {
|
||||
// resolve_program handles Windows .cmd/.bat shims (npm,
|
||||
// pip, ...) which std::process::Command cannot launch.
|
||||
let (prog, prefix) = crate::runner::resolve_program(&tokens[0]);
|
||||
let mut args = prefix;
|
||||
args.extend(tokens[1..].iter().cloned());
|
||||
let status = ProcessCommand::new(&prog)
|
||||
.args(&args)
|
||||
.status()
|
||||
.with_context(|| format!("failed to run {}", tokens.join(" ")))?;
|
||||
if !status.success() {
|
||||
app.log.warn("the command failed — removing the files it left behind");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Whatever executable is still on PATH, delete it directly (and its
|
||||
// directory when it belongs to the agent).
|
||||
let still = first_token.as_deref().and_then(|t| which::which(t).ok());
|
||||
if let Some(path) = still {
|
||||
let parent = path.parent().map(|p| p.to_path_buf());
|
||||
let dedicated = parent
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().eq_ignore_ascii_case(&agent.name))
|
||||
.unwrap_or(false);
|
||||
let target: PathBuf = if dedicated {
|
||||
parent.clone().unwrap_or_else(|| path.clone())
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
app.log.info(&format!(
|
||||
"remove leftover {} {}",
|
||||
if dedicated { "directory" } else { "file" },
|
||||
target.display()
|
||||
));
|
||||
if !app.confirm("Delete it?")? {
|
||||
app.log.info(&format!("'{}' left in place", target.display()));
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would remove {}", target.display()));
|
||||
} else if dedicated {
|
||||
std::fs::remove_dir_all(&target)
|
||||
.with_context(|| format!("cannot remove {}", target.display()))?;
|
||||
} else {
|
||||
std::fs::remove_file(&target)
|
||||
.with_context(|| format!("cannot remove {}", target.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Final verification.
|
||||
let gone = first_token
|
||||
.as_deref()
|
||||
.and_then(|t| which::which(t).ok())
|
||||
.is_none();
|
||||
if gone {
|
||||
app.log
|
||||
.success(&format!("uninstalled {} (external)", agent.title()));
|
||||
} else {
|
||||
app.log.warn(&format!(
|
||||
"{} is still present on PATH — remove it manually",
|
||||
agent.title()
|
||||
));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Remove the agent entry from the user config file (--purge).
|
||||
fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
||||
let Some(path) = &app.config_origin else {
|
||||
@@ -111,3 +235,80 @@ fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn agent_from_yaml(yaml: &str) -> AgentDef {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Wrap {
|
||||
agents: Vec<AgentDef>,
|
||||
}
|
||||
let w: Wrap = serde_yaml::from_str(yaml).unwrap();
|
||||
w.agents.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_plan_npm() {
|
||||
let a = agent_from_yaml(
|
||||
r#"
|
||||
agents:
|
||||
- name: x
|
||||
install:
|
||||
type: npm
|
||||
package: "@scope/pkg"
|
||||
run: x
|
||||
"#,
|
||||
);
|
||||
let plan = package_plan(&a).unwrap();
|
||||
assert_eq!(plan, vec!["npm", "uninstall", "--global", "@scope/pkg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_plan_cargo() {
|
||||
let a = agent_from_yaml(
|
||||
r#"
|
||||
agents:
|
||||
- name: x
|
||||
install:
|
||||
type: cargo
|
||||
package: my-crate
|
||||
run: x
|
||||
"#,
|
||||
);
|
||||
let plan = package_plan(&a).unwrap();
|
||||
assert_eq!(plan, vec!["cargo", "uninstall", "my-crate"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_plan_binary_is_none() {
|
||||
let a = agent_from_yaml(
|
||||
r#"
|
||||
agents:
|
||||
- name: y
|
||||
install:
|
||||
type: binary
|
||||
repo: owner/repo
|
||||
binary: y
|
||||
run: y
|
||||
"#,
|
||||
);
|
||||
assert!(package_plan(&a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_plan_unknown_method_is_none() {
|
||||
let a = agent_from_yaml(
|
||||
r#"
|
||||
agents:
|
||||
- name: z
|
||||
install:
|
||||
type: git
|
||||
repo: https://example.com/z.git
|
||||
run: z
|
||||
"#,
|
||||
);
|
||||
assert!(package_plan(&a).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod deps;
|
||||
pub mod doctor;
|
||||
pub mod download;
|
||||
pub mod installers;
|
||||
pub mod nav;
|
||||
pub mod output;
|
||||
pub mod probe;
|
||||
pub mod process;
|
||||
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
//! Nushell-inspired navigation helpers: a boxed table listing of files and
|
||||
//! directories (ls), and directory/path completion for Tab.
|
||||
|
||||
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, bounded: beyond the entry cap the size is
|
||||
/// reported as unknown (None) so huge trees never freeze the listing.
|
||||
pub fn dir_size(path: &Path) -> Option<u64> {
|
||||
const CAP: usize = 20_000;
|
||||
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)
|
||||
}
|
||||
|
||||
/// One entry of the ls table.
|
||||
struct LsRow {
|
||||
name: String,
|
||||
kind: &'static str,
|
||||
size: String,
|
||||
modified: String,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a Nushell-style boxed table of the entries of a path, with the
|
||||
/// columns: index, name, type, size, modified. Directories come first, then
|
||||
/// alphabetical order; sizes are human-readable and directories show their
|
||||
/// recursive size (bounded).
|
||||
pub fn render_ls(path: &Path, color: bool) -> Result<String> {
|
||||
let rd = std::fs::read_dir(path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let mut rows: Vec<LsRow> = 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 = if file_type.is_dir() {
|
||||
match dir_size(&p) {
|
||||
Some(s) => format_size(s),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
} else {
|
||||
format_size(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(LsRow {
|
||||
name: paint(&display, color, style),
|
||||
kind,
|
||||
size,
|
||||
modified: modified_str(&p),
|
||||
});
|
||||
}
|
||||
// Directories first, then alphabetical (case-insensitive).
|
||||
rows.sort_by(|a, b| {
|
||||
let a_dir = a.kind == "dir";
|
||||
let b_dir = b.kind == "dir";
|
||||
b_dir
|
||||
.cmp(&a_dir)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
// Column contents: index, name, type, size, modified.
|
||||
let headers = vec!["#", "name", "type", "size", "modified"];
|
||||
let mut widths: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
|
||||
let mut cells: Vec<Vec<String>> = Vec::new();
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let line = vec![
|
||||
i.to_string(),
|
||||
row.name.clone(),
|
||||
row.kind.to_string(),
|
||||
row.size.clone(),
|
||||
row.modified.clone(),
|
||||
];
|
||||
for (c, cell) in line.iter().enumerate() {
|
||||
widths[c] = widths[c].max(visible_len(cell));
|
||||
}
|
||||
cells.push(line);
|
||||
}
|
||||
|
||||
let pad = |s: &str, w: usize| -> String {
|
||||
let n = visible_len(s);
|
||||
format!("{s}{}", " ".repeat(w.saturating_sub(n)))
|
||||
};
|
||||
let border = |left: char, mid: char, right: char| -> String {
|
||||
let mut line = String::new();
|
||||
line.push(left);
|
||||
for (i, w) in widths.iter().enumerate() {
|
||||
if i > 0 {
|
||||
line.push(mid);
|
||||
}
|
||||
line.push_str(&"─".repeat(w + 2));
|
||||
}
|
||||
line.push(right);
|
||||
line
|
||||
};
|
||||
let row = |cells: &[String], bold: bool| -> String {
|
||||
let mut line = String::from("│");
|
||||
for (i, c) in cells.iter().enumerate() {
|
||||
line.push(' ');
|
||||
let cell = pad(c, 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(&headers.iter().map(|h| h.to_string()).collect::<Vec<_>>(), true));
|
||||
out.push('\n');
|
||||
out.push_str(&dim(&border('├', '┼', '┤')));
|
||||
out.push('\n');
|
||||
for line in &cells {
|
||||
out.push_str(&row(line, false));
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&dim(&border('╰', '┴', '╯')));
|
||||
out.push('\n');
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
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::*;
|
||||
|
||||
#[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 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}");
|
||||
}
|
||||
}
|
||||
+105
-8
@@ -15,6 +15,7 @@
|
||||
use crate::app::App;
|
||||
use crate::cli::{Cli, Command, StartArgs};
|
||||
use crate::commands;
|
||||
use crate::nav;
|
||||
use crate::shell::ShellSession;
|
||||
use anyhow::{anyhow, Result};
|
||||
use clap::CommandFactory;
|
||||
@@ -44,8 +45,39 @@ pub struct AmCompleter {
|
||||
agents: Vec<String>,
|
||||
aliases: Vec<String>,
|
||||
groups: Vec<String>,
|
||||
/// Short descriptions shown next to candidates in the Tab menu
|
||||
/// (Nushell-style).
|
||||
descriptions: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// One-line descriptions for the Tab completion menu.
|
||||
const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("list", "list installed agents"),
|
||||
("status", "show agent state"),
|
||||
("search", "search the catalog"),
|
||||
("info", "agent details"),
|
||||
("install", "install an agent"),
|
||||
("uninstall", "remove an agent"),
|
||||
("update", "update agents"),
|
||||
("start", "start an agent"),
|
||||
("stop", "stop an agent"),
|
||||
("restart", "restart an agent"),
|
||||
("run", "run an agent command"),
|
||||
("doctor", "check the environment"),
|
||||
("config", "manage the configuration"),
|
||||
("completion", "generate a completion script"),
|
||||
("self-update", "update agent-manager"),
|
||||
("self-uninstall", "remove agent-manager"),
|
||||
("export", "backup config + state"),
|
||||
("import", "restore a backup"),
|
||||
("shell", "show or switch the system shell"),
|
||||
("ls", "list files and directories (Nushell-style)"),
|
||||
("cd", "change directory — Tab completes folders"),
|
||||
("help", "show this help"),
|
||||
("version", "print the version"),
|
||||
("exit", "quit the shell"),
|
||||
];
|
||||
|
||||
impl AmCompleter {
|
||||
pub fn new(app: &App) -> Self {
|
||||
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups)
|
||||
@@ -67,12 +99,27 @@ impl AmCompleter {
|
||||
alias_list.sort();
|
||||
let mut group_list: Vec<String> = groups.keys().map(|g| format!("group:{g}")).collect();
|
||||
group_list.sort();
|
||||
let mut descriptions: BTreeMap<String, String> = BTreeMap::new();
|
||||
for (cmd, desc) in COMMAND_DESCRIPTIONS {
|
||||
descriptions.insert((*cmd).to_string(), (*desc).to_string());
|
||||
}
|
||||
for a in catalog.agents() {
|
||||
if a.hidden {
|
||||
continue;
|
||||
}
|
||||
let desc = a
|
||||
.category
|
||||
.clone()
|
||||
.or_else(|| a.description.clone())
|
||||
.unwrap_or_default();
|
||||
descriptions.insert(a.name.clone(), truncate_desc(&desc));
|
||||
}
|
||||
Self {
|
||||
commands: vec![
|
||||
"list", "status", "search", "info", "install", "uninstall", "update",
|
||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "help",
|
||||
"version", "exit",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "ls", "cd",
|
||||
"help", "version", "exit",
|
||||
],
|
||||
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
||||
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
|
||||
@@ -88,6 +135,7 @@ impl AmCompleter {
|
||||
agents,
|
||||
aliases: alias_list,
|
||||
groups: group_list,
|
||||
descriptions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +166,16 @@ impl AmCompleter {
|
||||
"shell" => {
|
||||
pool = self.gateway_shells.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
"cd" | "chdir" => {
|
||||
// Complete directories only — dive one level per Tab.
|
||||
let cwd = current_dir();
|
||||
pool = crate::nav::path_candidates(&cwd, word, true);
|
||||
}
|
||||
"ls" => {
|
||||
// Complete paths (files and directories).
|
||||
let cwd = current_dir();
|
||||
pool = crate::nav::path_candidates(&cwd, word, false);
|
||||
}
|
||||
"install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run"
|
||||
| "info" | "status" => {
|
||||
if word.starts_with("group:") {
|
||||
@@ -172,9 +230,17 @@ impl Completer for AmCompleter {
|
||||
let (start, cands) = self.candidates_for(line, pos);
|
||||
let pairs = cands
|
||||
.into_iter()
|
||||
.map(|c| Pair {
|
||||
display: c.clone(),
|
||||
replacement: c,
|
||||
.map(|c| {
|
||||
// Nushell-style menu: show a description next to the
|
||||
// candidate when one is known.
|
||||
let display = match self.descriptions.get(&c) {
|
||||
Some(d) if !d.is_empty() => format!("{c}\t{d}"),
|
||||
_ => c.clone(),
|
||||
};
|
||||
Pair {
|
||||
display,
|
||||
replacement: c,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok((start, pairs))
|
||||
@@ -340,7 +406,7 @@ pub fn banner_box(
|
||||
" system doctor · config · completion · self-update · self-uninstall · export · import"
|
||||
.to_string(),
|
||||
));
|
||||
rows.push(inner(" shell shell · help · version · exit".to_string()));
|
||||
rows.push(inner(" shell ls · cd · shell · help · version · exit".to_string()));
|
||||
|
||||
// Stats footer.
|
||||
rows.push(blank());
|
||||
@@ -536,6 +602,20 @@ fn current_dir_string() -> 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -682,6 +762,11 @@ fn handle_line(app: &App, session: &mut ShellSession, line: &str) -> Result<bool
|
||||
shell_command(session, rest.first().map(|s| s.as_str()));
|
||||
return Ok(false);
|
||||
}
|
||||
// Nushell-style directory listing (the system ls stays available as !ls).
|
||||
if cmd == "ls" {
|
||||
ls_command(app, rest)?;
|
||||
return Ok(false);
|
||||
}
|
||||
// Everything that is not an am command is a system command.
|
||||
if !is_am_command(cmd) {
|
||||
run_system(app, session, line)?;
|
||||
@@ -825,6 +910,16 @@ fn is_am_command(word: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Nushell-style listing of the current (or given) directory.
|
||||
fn ls_command(app: &App, rest: &[String]) -> Result<()> {
|
||||
let target = match rest.first() {
|
||||
Some(p) => crate::config::expand_path(p),
|
||||
None => current_dir(),
|
||||
};
|
||||
print!("{}", nav::render_ls(&target, app.color())?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run one system command through the active shell (inherited stdio).
|
||||
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
|
||||
app.log.cmd(&format!("{}: {line}", session.current.name));
|
||||
@@ -904,12 +999,14 @@ fn print_help() {
|
||||
println!(" export [--output FILE] backup config + state");
|
||||
println!(" import <file> restore a backup");
|
||||
println!(" shell [list|<name>] show or switch the system shell");
|
||||
println!(" ls [path] list files and directories (Nushell-style table)");
|
||||
println!(" cd <dir> change directory — Tab completes folders");
|
||||
println!(" help | version | exit this help, the version, quit");
|
||||
println!();
|
||||
println!("system gateway:");
|
||||
println!(" any other input runs as a system command in the active shell");
|
||||
println!(" prefix with '!' to force system execution (e.g. '!list')");
|
||||
println!(" 'cd <dir>' changes the session directory (in-process)");
|
||||
println!(" prefix with '!' to force system execution (e.g. '!ls', '!list')");
|
||||
println!(" Tab completes commands, agents, flags and paths (cycle with Tab)");
|
||||
println!();
|
||||
println!("slash commands: /help · /version · /exit · /shell <name>");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user