Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e1b66809a | ||
|
|
d886de322c | ||
|
|
bc38cd59e5 | ||
|
|
61f4909bc6 | ||
|
|
98aa75d185 | ||
|
|
ba07f42d09 | ||
|
|
2be5f8e8b2 | ||
|
|
e513dd485d | ||
|
|
bd1d566a44 | ||
|
|
0248a7452a |
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.2.0"
|
||||
version = "0.2.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.2.0"
|
||||
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"
|
||||
|
||||
@@ -1,72 +1,37 @@
|
||||
# agent-manager (am)
|
||||
# 🚀 agent-manager — vos agents IA, gérés comme des apps
|
||||
|
||||
Gestionnaire en ligne de commande, écrit en **Rust**, pour les **agents IA de
|
||||
coding installés localement** : lister, installer, démarrer, arrêter,
|
||||
redémarrer, mettre à jour et désinstaller — avec gestion automatique des
|
||||
dépendances (Node.js, Python, Go, Rust, Bun, etc.) et un catalogue YAML de
|
||||
~70 agents prêts à l'emploi.
|
||||
**am** liste, installe, démarre, met à jour et désinstalle vos agents IA de
|
||||
coding (Claude Code, Codex, Aider, jcode, Prime Agent… **72 agents connus**)
|
||||
en une ligne de commande — avec la gestion des dépendances (Node.js, Python,
|
||||
Go, Rust…) prise en charge pour vous.
|
||||
|
||||
> ✅ Rust · ✅ Windows, Linux, macOS · ✅ aucune dépendance runtime
|
||||
|
||||
---
|
||||
|
||||
## Sommaire
|
||||
## ✨ Pourquoi am ?
|
||||
|
||||
1. [Fonctionnalités](#fonctionnalités)
|
||||
2. [Installation](#installation)
|
||||
3. [Démarrage rapide](#démarrage-rapide)
|
||||
4. [Commandes](#commandes)
|
||||
5. [Options globales](#options-globales)
|
||||
6. [Fichier de configuration](#fichier-de-configuration)
|
||||
7. [Ajouter un agent](#ajouter-un-agent)
|
||||
8. [Alias, groupes et mode interactif](#alias-groupes-et-mode-interactif)
|
||||
9. [Gestion des processus](#gestion-des-processus)
|
||||
10. [Sécurité](#sécurité)
|
||||
11. [Tests](#tests)
|
||||
12. [Complétions shell](#complétions-shell)
|
||||
13. [Structure du projet](#structure-du-projet)
|
||||
14. [Limites et feuille de route](#limites-et-feuille-de-route)
|
||||
- 🗂️ **Catalogue de 72 agents prêts à l'emploi** — descriptions, méthodes
|
||||
d'installation, dépendances et commandes de démarrage déjà configurées.
|
||||
- 📦 **9 méthodes d'installation** — npm, pip, uv, cargo, go, bun, script
|
||||
d'installation, binaire (GitHub Releases/Gitea) et dépôt git — toujours
|
||||
dans un répertoire local, jamais dans le système.
|
||||
- 🧩 **Dépendances gérées pour vous** — avant d'installer un agent, am
|
||||
vérifie les outils requis et, s'il en manque, **détecte votre OS** et
|
||||
propose la commande exacte (scoop/winget, apt/dnf/pacman, brew) — il peut
|
||||
même l'exécuter à votre place.
|
||||
- 🚦 **Gestion des processus** — démarrage en avant-plan ou détaché
|
||||
(--background) avec PID enregistré et logs ; arrêt propre SIGTERM puis
|
||||
SIGKILL.
|
||||
- ⚡ **Rapide** — sondes de version en parallèle + cache : un am list
|
||||
à chaud prend ~25 ms.
|
||||
- 🧭 **Shell interactif** avec complétion **Tab** et historique.
|
||||
- 🔒 **Sûr** — mode --dry-run universel, checksums sha256, confirmations
|
||||
avant les scripts d'installation, journalisation horodatée.
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Catalogue intégré** : ~70 agents connus (Claude Code, Codex, Gemini CLI,
|
||||
OpenCode, Aider, jcode, Prime Agent, l'écosystème OpenClaw, les SaaS, etc.),
|
||||
chacun avec sa méthode d'installation et ses dépendances.
|
||||
- **9 méthodes d'installation** :
|
||||
|
||||
| type | description |
|
||||
|------|-------------|
|
||||
| npm / bun | paquet global installé dans un préfixe local (jamais en -g système) |
|
||||
| pip / uv | virtualenv privé par agent, sans toucher au site-packages système |
|
||||
| cargo | cargo install --root <répertoire-local> |
|
||||
| go | go install avec GOBIN local |
|
||||
| curl | script d'installation téléchargé puis exécuté séparément (jamais de curl pipé vers sh) |
|
||||
| binary| binaire précompilé depuis GitHub Releases ou URL directe, checksum vérifié, extraction zip/tar.gz |
|
||||
| git | clone + build (make, cargo build --release, npm install…) puis copie du binaire ou shim |
|
||||
|
||||
- **Gestion des processus** : avant-plan (stdio hérités) ou arrière-plan
|
||||
(détaché, PID enregistré, sortie vers un log) ; arrêt propre SIGTERM puis
|
||||
SIGKILL après un délai configurable.
|
||||
- **Base d'état locale** (state.json) : nom, version, chemin, binaires, PID,
|
||||
dates — plus la **détection d'agents externes** via le PATH. Les sondes de
|
||||
version sont exécutées **en parallèle** (timeout 4 s) et **mises en cache**
|
||||
(index du PATH et versions) : un am list à chaud prend ~50 ms.
|
||||
- **Dépendances** : vérification avant installation (node >= 18, etc.) avec
|
||||
messages actionnables ; installation automatique possible quand une commande
|
||||
d'installation est connue.
|
||||
- **Sécurité** : mode --dry-run universel, confirmations (--yes pour les
|
||||
sauter), validation des URL (http/https/file), vérification sha256,
|
||||
journalisation horodatée de toutes les commandes exécutées.
|
||||
- **Confort développeur** : sortie JSON stable (--json), couleurs
|
||||
(--no-color), mode verbeux, complétions bash/zsh/fish/powershell/elvish,
|
||||
alias, groupes (am start group:dev), mode interactif, export/import,
|
||||
doctor et self-update.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### En une ligne (recommandé)
|
||||
## ⚡ Installation — 30 secondes
|
||||
|
||||
**Windows** (PowerShell) :
|
||||
|
||||
@@ -76,358 +41,296 @@ dépendances (Node.js, Python, Go, Rust, Bun, etc.) et un catalogue YAML de
|
||||
|
||||
curl -fsSL https://git.dracodev.net/Projets/agent-manager/raw/branch/main/install.sh | sh
|
||||
|
||||
Le script télécharge le binaire précompilé de la **dernière release** du dépôt
|
||||
Gitea, l'installe dans un répertoire utilisateur et l'ajoute au PATH. Si aucun
|
||||
binaire ne correspond à votre plateforme, il compile automatiquement depuis
|
||||
les sources (cargo install --git). **Relancez la même commande pour mettre à
|
||||
jour.** Les scripts sont versionnés dans le dépôt (install.ps1, install.sh) —
|
||||
vous pouvez les lire avant de les exécuter.
|
||||
Le script télécharge le binaire précompilé de la dernière release
|
||||
(statique sous Linux : compatible toutes les distributions, Debian 12 et
|
||||
antérieures comprises) et l'ajoute à votre PATH. Sans binaire pour votre
|
||||
plateforme, il compile automatiquement depuis les sources.
|
||||
|
||||
### Depuis les sources
|
||||
|
||||
Prérequis : Rust 1.70+ (le programme lui-même n'a **aucune** dépendance
|
||||
runtime ; les outils comme node/python ne sont nécessaires que pour les agents
|
||||
qui les utilisent).
|
||||
|
||||
# 1. cloner et compiler (binaire dans target/release/am)
|
||||
git clone https://git.dracodev.net/Projets/agent-manager.git
|
||||
cd agent-manager
|
||||
cargo build --release
|
||||
|
||||
# 2. installer dans ~/.cargo/bin (sur le PATH)
|
||||
cargo install --path .
|
||||
|
||||
# 3. vérifier
|
||||
am --version
|
||||
am doctor
|
||||
|
||||
> Le binaire s'appelle **am** (agent-manager est le nom du paquet).
|
||||
|
||||
### Ajouter les binaires des agents au PATH
|
||||
|
||||
Les agents installés par am vivent dans
|
||||
~/.local/share/agent-manager/agents/bin (Linux),
|
||||
%LOCALAPPDATA%\agent-manager\agents\bin (Windows) ou
|
||||
~/Library/Application Support/agent-manager/agents/bin (macOS).
|
||||
Ajoutez ce répertoire au PATH pour pouvoir lancer les agents directement :
|
||||
|
||||
# Linux/macOS — dans ~/.bashrc ou ~/.zshrc
|
||||
export PATH="$HOME/.local/share/agent-manager/agents/bin:$PATH"
|
||||
|
||||
am doctor vous le rappelle si le répertoire n'est pas sur le PATH.
|
||||
> 🔄 **Mise à jour** : relancez simplement la même commande.
|
||||
|
||||
---
|
||||
|
||||
## Démarrage rapide
|
||||
## 🎬 Vos 3 premières commandes
|
||||
|
||||
am list # agents installés (gérés + externes)
|
||||
am list --all # tout le catalogue, avec l'état de chacun
|
||||
am search rust # recherche par mot-clé
|
||||
am info jcode # fiche détaillée
|
||||
am list # vos agents installés (et ceux détectés sur le PATH)
|
||||
|
||||
am install claude-code # npm + vérification de node >= 18
|
||||
am install aider --method pip # choisir la méthode 2 (pip) au lieu de uv
|
||||
am install smelt --dry-run # simuler sans rien changer
|
||||
NAME VERSION STATUS SOURCE PATH
|
||||
------------------------------------------------
|
||||
Claude Code 2.1.112 external external C:\\Users\\bruno\\.local\\bin\\claude.exe
|
||||
jcode 0.76.0 external external C:\\Users\\bruno\\AppData\\Local\\jcode\\bin\\jcode.exe
|
||||
|
||||
am start claude-code # avant-plan (Ctrl-C pour quitter)
|
||||
am start claude-code --background --env ANTHROPIC_MODEL=opus
|
||||
am status claude-code
|
||||
am stop claude-code # SIGTERM puis SIGKILL après 5 s
|
||||
am restart codex --background
|
||||
am update claude-code
|
||||
am uninstall smelt --purge
|
||||
am install jcode # installe un agent (dépendances vérifiées d'abord)
|
||||
|
||||
am run pi "explique ce fichier" # exécution directe, sans gestion de process
|
||||
am doctor --fix # vérifie l'environnement et répare
|
||||
installing jcode (type=binary repo=1jehuang/jcode)…
|
||||
installed jcode (v0.76.0) in ~/.local/share/agent-manager/agents/jcode
|
||||
|
||||
am start jcode # le lance en avant-plan (--background pour détacher)
|
||||
|
||||
---
|
||||
|
||||
## Commandes
|
||||
## 📚 Aide-mémoire des commandes
|
||||
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| am list [--all] [--category <cat>] | agents installés (défaut) ; --all pour tout le catalogue |
|
||||
| am start <agent> [--background] [--args ...] [--env K=V] [--notify] | démarre l'agent (avant-plan par défaut) |
|
||||
| am stop <agent> [--force] [--timeout <s>] | arrête un agent d'arrière-plan (PID enregistré) |
|
||||
| am restart <agent> [--background] [--force] | arrête puis redémarre |
|
||||
| am status [agent] | état d'un agent, ou de tous les agents installés |
|
||||
| am install <agent> [--method <n|type>] [--force] | installe l'agent et ses dépendances |
|
||||
| am uninstall <agent> [--purge] | désinstalle et nettoie (--purge : logs + entrée de config) |
|
||||
| am update <agent> [--all] | met à jour vers la dernière version connue |
|
||||
| am search <mot-clé> [--category <cat>] | recherche dans le catalogue |
|
||||
| am info <agent> | description, méthodes d'installation, commande, dépendances, site |
|
||||
| am config show / path / edit / validate / add <fichier> | gestion de la configuration |
|
||||
| am doctor [--fix] | outils présents, validité de la config, chemins, permissions |
|
||||
| am run <agent> [args...] | exécute la commande de l'agent directement |
|
||||
| am completion <shell> | script de complétion (bash, zsh, fish, powershell, elvish) |
|
||||
| am self-update [--check] | met à jour agent-manager depuis GitHub Releases |
|
||||
| am export / am import <fichier> | sauvegarde/restauration de la config + état |
|
||||
| am (sans arguments) | shell interactif |
|
||||
### 🔍 Découvrir
|
||||
|
||||
**Statuts affichés par list/status** : running (PID actif), installed (géré
|
||||
par am), external (trouvé sur le PATH, non géré), not-installed,
|
||||
not-installable (SaaS/desktop).
|
||||
| Commande | Rôle |
|
||||
|----------|------|
|
||||
| am list | agents installés (gérés + détectés sur le PATH) |
|
||||
| am list --all | tout le catalogue, avec l'état de chacun |
|
||||
| am search <mot-clé> | recherche par nom, description, catégorie, tag |
|
||||
| am info <agent> | fiche détaillée (installation, dépendances, site…) |
|
||||
| am status [agent] | état, version, PID, logs |
|
||||
|
||||
**Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé
|
||||
sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
|
||||
### 📦 Installer et gérer
|
||||
|
||||
| Commande | Rôle |
|
||||
|----------|------|
|
||||
| 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) — 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
|
||||
|
||||
| Commande | Rôle |
|
||||
|----------|------|
|
||||
| am start <agent> | avant-plan (Ctrl-C pour quitter) |
|
||||
| am start <agent> --background --env KEY=VALEUR | détaché, PID et log gérés |
|
||||
| am stop <agent> | arrêt gracieux puis forcé (--force : immédiat) |
|
||||
| am restart <agent> | arrête puis redémarre |
|
||||
| am run <agent> [args…] | exécution directe, sans gestion de processus |
|
||||
|
||||
### 🧰 Système
|
||||
|
||||
| Commande | Rôle |
|
||||
|----------|------|
|
||||
| am doctor | vérifie outils, config, chemins — avec commandes d'installation |
|
||||
| am doctor --fix | répare ce qui peut l'être, propose d'installer les outils manquants |
|
||||
| am config show / edit / add | gère votre configuration |
|
||||
| am completion <shell> | script de complétion (bash, zsh, fish, powershell) |
|
||||
| am self-update | met à jour am lui-même (si configuré) |
|
||||
| am self-uninstall | désinstalle am et tout ce qu'il a créé (confirmation) |
|
||||
| am | shell interactif : bannière, passerelle système, Tab et historique |
|
||||
|
||||
---
|
||||
|
||||
## Options globales
|
||||
## 🧩 Dépendances : am s'en occupe
|
||||
|
||||
Toutes les options globales fonctionnent avant **ou après** la sous-commande.
|
||||
Avant chaque installation, am vérifie les **prérequis** déclarés pour
|
||||
l'agent (ex. node >= 18 pour Claude Code). S'il manque un outil :
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| -c, --config <fichier> | fichier de configuration alternatif |
|
||||
| -v, --verbose | affiche chaque commande exécutée et les détails |
|
||||
| -q, --quiet | uniquement les erreurs |
|
||||
1. il **détecte votre système** — Windows (scoop puis winget), Linux
|
||||
(apt / dnf / pacman / apk selon la distribution), macOS (brew) ;
|
||||
2. il **propose la commande exacte** et peut l'exécuter à votre place
|
||||
(confirmation demandée, ou --yes) :
|
||||
|
||||
$ am install claude-code
|
||||
dependency 'node' is missing
|
||||
Run the detected installer for Windows? -> scoop install nodejs-lts [y/N]
|
||||
|
||||
Si vous refusez, l'installation s'arrête avec le mode d'emploi :
|
||||
|
||||
error: agent 'claude-code' has unmet dependencies:
|
||||
- node: not found on PATH (minimum: 18.0.0)
|
||||
on Windows: scoop install nodejs-lts | winget install OpenJS.NodeJS.LTS
|
||||
|
||||
am doctor affiche la même aide pour tous les outils de l'environnement.
|
||||
Les dépendances des paquets (les modules npm/pip de l'agent lui-même) sont,
|
||||
elles, installées automatiquement par npm/pip/cargo dans un **répertoire
|
||||
local isolé** (virtualenv privé, --prefix npm, GOBIN local…) — votre
|
||||
système n'est jamais touché.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Options globales
|
||||
|
||||
Disponibles avant **ou après** la sous-commande.
|
||||
|
||||
| Option | Effet |
|
||||
|--------|-------|
|
||||
| -y, --yes | répond oui à toutes les confirmations |
|
||||
| --dry-run | simule l'action (aucune écriture, aucun téléchargement) |
|
||||
| --json | sortie JSON stable sur stdout (les messages vont sur stderr) |
|
||||
| --no-color | désactive les couleurs ANSI |
|
||||
| -h, --help / -V, --version | aide / version |
|
||||
| --dry-run | simule sans rien modifier (idéal pour essayer) |
|
||||
| --json | sortie JSON stable pour vos scripts |
|
||||
| -v, --verbose | affiche chaque commande exécutée |
|
||||
| -q, --quiet | uniquement les erreurs |
|
||||
| --no-color | désactive les couleurs |
|
||||
| -c, --config <fichier> | configuration alternative |
|
||||
|
||||
---
|
||||
|
||||
## Fichier de configuration
|
||||
## 👥 Groupes, alias et shell interactif
|
||||
|
||||
### Emplacements (par ordre de priorité)
|
||||
am start cc # alias (cc -> claude-code)
|
||||
am start group:dev # démarre tout un groupe en arrière-plan
|
||||
am stop group:dev
|
||||
|
||||
1. --config <fichier> (ou la variable d'environnement AGENT_MANAGER_CONFIG)
|
||||
am # shell interactif (bannière style Hermes) :
|
||||
❯ inst<Tab> # complétion Tab : commandes, agents, groupes, options
|
||||
❯ install jcode
|
||||
❯ exit # historique persistant (flèches haut/bas)
|
||||
|
||||
### 🐚 Passerelle système
|
||||
|
||||
Le prompt interactif est aussi une **passerelle vers votre shell
|
||||
système** : toute saisie qui n'est pas une commande am est exécutée par le
|
||||
shell actif. Le **shell par défaut de l'utilisateur est détecté au
|
||||
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 # 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
|
||||
❯ !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`).
|
||||
|
||||
---
|
||||
|
||||
## 🧹 Désinstallation propre
|
||||
|
||||
Une seule commande, sur tous les OS — elle arrête d'abord les agents en
|
||||
arrière-plan, puis supprime les données, l'état (logs, historique), la
|
||||
configuration, et enfin l'exécutable lui-même :
|
||||
|
||||
am self-uninstall # avec confirmation
|
||||
am self-uninstall --yes # automatique, en une ligne
|
||||
|
||||
Sans le binaire (ou en dernier recours), supprimez les répertoires
|
||||
qu'agent-manager possède :
|
||||
|
||||
# Linux
|
||||
rm -rf ~/.local/share/agent-manager ~/.local/state/agent-manager ~/.config/agent-manager
|
||||
|
||||
# macOS
|
||||
rm -rf "$HOME/Library/Application Support/agent-manager" "$HOME/.config/agent-manager"
|
||||
|
||||
# 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"
|
||||
|
||||
(Si vous utilisiez `AGENT_MANAGER_DATA`/`AGENT_MANAGER_STATE`/`AGENT_MANAGER_CONFIG_DIR`,
|
||||
supprimez aussi ces emplacements.)
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Personnalisation
|
||||
|
||||
### Fichiers de configuration (par priorité)
|
||||
|
||||
1. --config <fichier>
|
||||
2. ./agent-manager.yaml (répertoire courant)
|
||||
3. ~/.config/agent-manager/config.yaml
|
||||
(Windows : %APPDATA%\agent-manager\config.yaml)
|
||||
4. catalogue par défaut embarqué dans le binaire
|
||||
4. catalogue embarqué (72 agents)
|
||||
|
||||
Le fichier trouvé **surcharge/complète** le catalogue embarqué : un agent
|
||||
défini par l'utilisateur remplace celui du même nom ; les autres s'ajoutent.
|
||||
Vos définitions **complètent ou surchargent** le catalogue par nom.
|
||||
|
||||
### Schéma (extrait)
|
||||
|
||||
version: "1.0"
|
||||
|
||||
settings:
|
||||
install_dir: null # défaut: ~/.local/share/agent-manager/agents
|
||||
log_dir: null # défaut: ~/.local/state/agent-manager/logs
|
||||
default_shell: null # shell pour les scripts d'installation
|
||||
auto_install_deps: true # proposer d'installer les dépendances manquantes
|
||||
confirm_before_run: true # confirmer avant d'exécuter un script d'installation
|
||||
stop_timeout_secs: 5 # délai SIGTERM -> SIGKILL
|
||||
self_update_repo: null # "owner/repo" pour am self-update
|
||||
|
||||
aliases:
|
||||
cc: claude-code # am start cc == am start claude-code
|
||||
|
||||
groups:
|
||||
dev: [claude-code, aider, codex]
|
||||
|
||||
include: # modularité : autres fichiers (chemins relatifs)
|
||||
- agents-perso.yaml
|
||||
|
||||
agents:
|
||||
- name: claude-code
|
||||
display_name: "Claude Code"
|
||||
description: "..."
|
||||
category: coding-agent
|
||||
website: https://github.com/anthropics/claude-code
|
||||
install:
|
||||
type: npm
|
||||
package: "@anthropic-ai/claude-code"
|
||||
# args: ["--omit=optional"] # arguments supplémentaires
|
||||
# env: { FOO: bar } # environnement de l'installeur
|
||||
# methods: [...] # méthodes alternatives (--method)
|
||||
# post_install: ["claude setup"] # commandes après installation
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: claude
|
||||
args: [] # arguments par défaut
|
||||
env: {} # variables d'environnement par défaut
|
||||
tags: [anthropic]
|
||||
installable: true # false = listé mais non installable (SaaS/desktop)
|
||||
note: null # explication affichée avec installable: false
|
||||
platforms: [] # linux | macos | windows (vide = tous)
|
||||
|
||||
Les chemins acceptent ~ et les variables d'environnement ($HOME, ${VAR}).
|
||||
La configuration est **validée au démarrage** avec des messages précis
|
||||
(agent 'x': 'run' is required when installable, champs inconnus rejetés,
|
||||
doublons détectés).
|
||||
|
||||
---
|
||||
|
||||
## Ajouter un agent
|
||||
|
||||
Créez ~/.config/agent-manager/config.yaml (am config edit) ou un fichier
|
||||
inclus (am config add mes-agents.yaml) puis :
|
||||
### Ajouter votre agent
|
||||
|
||||
# ~/.config/agent-manager/config.yaml (ou am config add mon-fichier.yaml)
|
||||
version: "1.0"
|
||||
agents:
|
||||
# paquet npm
|
||||
- name: mon-agent-npm
|
||||
- name: mon-agent
|
||||
display_name: "Mon Agent"
|
||||
install: { type: npm, package: mon-agent }
|
||||
dependencies:
|
||||
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
|
||||
run: mon-agent
|
||||
|
||||
# paquet pip (virtualenv privé)
|
||||
- name: mon-agent-python
|
||||
display_name: "Mon Agent Python"
|
||||
install: { type: pip, package: mon-agent-python }
|
||||
dependencies:
|
||||
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
|
||||
run: mon-agent
|
||||
|
||||
# binaire GitHub Releases (asset choisi selon plateforme/arch)
|
||||
- name: mon-binaire
|
||||
display_name: "Mon Binaire"
|
||||
install:
|
||||
type: binary
|
||||
repo: owner/repo
|
||||
binary: mon-binaire # nom de l'exécutable attendu
|
||||
checksum: null # ou "sha256:..."
|
||||
run: mon-binaire
|
||||
|
||||
# binaire via URL directe
|
||||
- name: mon-binaire-2
|
||||
display_name: "Mon Binaire 2"
|
||||
install:
|
||||
type: binary
|
||||
url: "https://example.com/dl/mon-bin.tar.gz"
|
||||
run: mon-bin
|
||||
|
||||
# script d'installation (téléchargé PUIS exécuté, jamais curl|sh)
|
||||
- name: mon-script
|
||||
display_name: "Mon Script"
|
||||
install:
|
||||
type: curl
|
||||
script: "https://example.com/install.sh"
|
||||
run: mon-script
|
||||
platforms: [linux, macos]
|
||||
|
||||
# dépôt git + build
|
||||
- name: mon-git
|
||||
display_name: "Mon Git"
|
||||
install:
|
||||
type: git
|
||||
repo: "https://github.com/owner/repo"
|
||||
build: ["cargo", "build", "--release"] # ou "make build" (chaîne)
|
||||
binary_path: target/release/mon-git # copié dans le bin dir
|
||||
run: mon-git
|
||||
|
||||
# application desktop / SaaS : listée mais non installable
|
||||
- name: mon-saas
|
||||
display_name: "Mon SaaS"
|
||||
installable: false
|
||||
note: "SaaS — voir https://example.com"
|
||||
|
||||
Puis : am config validate, am install mon-agent-npm, etc.
|
||||
Autres méthodes : pip/uv (virtualenv privé), cargo, go, bun, binaire depuis
|
||||
Gitea/GitHub Releases (repo: owner/repo), URL directe, script d'installation
|
||||
(type: curl) et dépôt git avec build (build + binary_path). Les chemins
|
||||
acceptent ~ et les variables d'environnement ($HOME, ${VAR}) ; includes
|
||||
multi-fichiers pris en charge ; configuration validée au démarrage.
|
||||
|
||||
---
|
||||
|
||||
## Alias, groupes et mode interactif
|
||||
## 🔒 Sécurité
|
||||
|
||||
am start cc # alias -> claude-code
|
||||
am start group:dev # démarre tout le groupe "dev" en arrière-plan
|
||||
am stop group:dev # arrête tout le groupe
|
||||
am # shell interactif (rustyline) :
|
||||
# am> inst<Tab> # complétion Tab : commandes, agents, alias,
|
||||
# am> install jcode # group:xxx, options (--background, ...)
|
||||
# am> start jcode --background
|
||||
# am> exit
|
||||
|
||||
Le shell interactif offre la **complétion par Tab** (commandes, noms
|
||||
d'agents, alias, groupes, options) et un **historique persistant**
|
||||
(flèches haut/bas, fichier history.txt dans le répertoire d'état).
|
||||
- les scripts d'installation sont **téléchargés puis exécutés séparément** —
|
||||
jamais de curl pipé vers sh ; --verbose les affiche ;
|
||||
- **--dry-run** simule n'importe quelle action sans rien écrire ;
|
||||
- checksums **sha256** vérifiés quand ils sont fournis ;
|
||||
- URL limitées à https/http/file ; extraction d'archives protégée contre le
|
||||
path traversal ;
|
||||
- tout vit dans vos répertoires utilisateur ; rien n'est exécuté avec des
|
||||
privilèges élevés.
|
||||
|
||||
---
|
||||
|
||||
## Gestion des processus
|
||||
## 🖥️ Complétions shell
|
||||
|
||||
- **Avant-plan** (défaut) : la commande hérite de votre terminal ; am attend
|
||||
sa fin et propage son code de sortie. Ctrl-C interrompt l'agent.
|
||||
- **Arrière-plan** (--background) : processus détaché (nouveau groupe de
|
||||
processus), PID enregistré dans state.json, stdout/stderr redirigés vers
|
||||
~/.local/state/agent-manager/logs/<agent>.log. Si l'agent crashe au
|
||||
démarrage, am affiche la fin du log et échoue.
|
||||
- **Arrêt** : am stop <agent> envoie SIGTERM (Windows : taskkill), attend
|
||||
stop_timeout_secs, puis SIGKILL (taskkill /F /T). --force court-circuite
|
||||
la phase gracieuse. Les PID périmés sont nettoyés automatiquement.
|
||||
- Toutes les commandes exécutées sont journalisées avec horodatage dans
|
||||
<log_dir>/agent-manager.log (--verbose les affiche aussi).
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Les scripts d'installation (curl) sont **téléchargés puis exécutés
|
||||
séparément** — jamais de curl pipé directement vers sh. --verbose affiche
|
||||
leur contenu, confirm_before_run demande confirmation.
|
||||
- --dry-run simule n'importe quelle action sans rien écrire.
|
||||
- URL limitées à https, http et file (tests locaux).
|
||||
- Checksums sha256 vérifiés quand ils sont fournis dans la config.
|
||||
- Rien n'est exécuté avec des privilèges élevés ; tout vit dans les
|
||||
répertoires utilisateur. Les archives sont extraites avec protection
|
||||
contre le path traversal (zip-slip).
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
cargo test # tests unitaires + intégration (config, versions,
|
||||
# dry-run, processus, doctor, installateurs)
|
||||
cargo test -- --nocapture
|
||||
|
||||
Les tests d'installation utilisent des *mocks* et le mode dry-run : aucun
|
||||
réseau, aucune installation réelle. Les tests de processus spawnent
|
||||
sleep/ping et vérifient le cycle démarrage → détection → arrêt.
|
||||
|
||||
---
|
||||
|
||||
## Complétions shell
|
||||
|
||||
am completion bash > completions/am.bash # fournies dans completions/
|
||||
am completion bash > completions/am.bash
|
||||
am completion zsh > completions/_am
|
||||
am completion fish > completions/am.fish
|
||||
am completion powershell > completions/_am.ps1
|
||||
|
||||
# bash
|
||||
source completions/am.bash
|
||||
# zsh
|
||||
fpath+=(completions) && compinit
|
||||
# fish
|
||||
cp completions/am.fish ~/.config/fish/completions/am.fish
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
**Où sont installés les agents ?**
|
||||
~/.local/share/agent-manager/agents (Linux), %LOCALAPPDATA%\\agent-manager\\agents
|
||||
(Windows) — l'état et les logs dans ~/.local/state/agent-manager (Linux) ou
|
||||
%LOCALAPPDATA%\\agent-manager (Windows).
|
||||
|
||||
**Comment mettre à jour am ?** Relancez l'installeur one-liner (ou
|
||||
am self-update si le dépôt de release est configuré).
|
||||
|
||||
**Comment désinstaller am ?** Supprimez le binaire et le répertoire
|
||||
d'état ; les agents installés restent utilisables en les supprimant avec
|
||||
am uninstall avant.
|
||||
|
||||
**Pourquoi le binaire Linux est statique ?** Pour fonctionner partout,
|
||||
Debian 12 (glibc 2.36) comprise, sans dépendance système.
|
||||
|
||||
**Un agent me demande une clé API.** am ne gère pas les secrets : il lance
|
||||
l'agent, qui fera sa propre configuration. Passez des variables avec
|
||||
--env KEY=VALEUR si besoin.
|
||||
|
||||
---
|
||||
|
||||
## Structure du projet
|
||||
## 🧑💻 Pour les développeurs
|
||||
|
||||
### Compiler et tester
|
||||
|
||||
git clone https://git.dracodev.net/Projets/agent-manager.git
|
||||
cd agent-manager
|
||||
cargo build --release # binaire dans target/release/am
|
||||
cargo test # 45+ tests (config, processus, dry-run, complétion…)
|
||||
|
||||
### Architecture
|
||||
|
||||
src/
|
||||
main.rs # point d'entrée (binaire am)
|
||||
lib.rs # crate bibliothèque (testable)
|
||||
cli.rs # définition clap des commandes
|
||||
app.rs # contexte applicatif (config, chemins, état, log)
|
||||
config.rs # schéma YAML, chargement, includes, fusion, validation
|
||||
catalog.rs # catalogue fusionné : lookup, alias, groupes, recherche
|
||||
state.rs # base locale JSON (installations, PID)
|
||||
runner.rs # exécution de commandes (réelle / mock / dry-run)
|
||||
process.rs # spawn détaché, détection, SIGTERM/SIGKILL, logs
|
||||
download.rs # téléchargement, checksums, releases GitHub, archives
|
||||
deps.rs # vérification des dépendances (node >= 18, ...)
|
||||
doctor.rs # diagnostic d'environnement + réparations
|
||||
output.rs # logger horodaté, tableaux, JSON, couleurs
|
||||
installers/ # npm, bun, pip, uv, cargo, go, curl, binary, git
|
||||
commands/ # une implémentation par commande
|
||||
repl.rs # mode interactif
|
||||
config.yaml # catalogue par défaut (~70 agents, embarqué)
|
||||
tests/ # tests d'intégration
|
||||
completions/ # scripts de complétion générés
|
||||
cli.rs # commandes clap
|
||||
config.rs # schéma YAML, includes, fusion, validation
|
||||
catalog.rs # catalogue : lookup, alias, groupes, recherche
|
||||
deps.rs # vérification des dépendances (min_version, OS)
|
||||
toolchain.rs # détection OS + commandes d'installation (scoop/apt/…)
|
||||
probe.rs # détection d'agents externes (parallèle + cache)
|
||||
installers/ # npm, pip, uv, cargo, go, bun, curl, binary, git
|
||||
process.rs # spawn détaché, signaux, logs
|
||||
state.rs # base locale JSON (installations, PID)
|
||||
runner.rs # exécution de commandes (réelle, mock, dry-run)
|
||||
|
||||
---
|
||||
### Publier une release
|
||||
|
||||
## Limites et feuille de route
|
||||
|
||||
- Les applications desktop (Warp, openyak, Kun, cc-haha) et les SaaS (Devin,
|
||||
Amp, Junie, Cortex, Tabnine, Mentat) sont **listés** avec
|
||||
installable: false et un lien — ils ne sont pas installables localement.
|
||||
- La mise à jour compare la version installée à la dernière connue (registry
|
||||
npm/PyPI/crates.io, tag GitHub) puis réinstalle.
|
||||
- Pistes futures : plugins d'installation dynamiques, notifications Windows
|
||||
natives, intégration tmux, tests d'intégration avec des registres simulés.
|
||||
Les binaires des releases sont construits en conteneur : Windows (MSVC)
|
||||
nativement, **Linux en statique musl** (jamais de binaire glibc récent —
|
||||
voir l'erreur GLIBC). La procédure complète est dans
|
||||
[RELEASING.md](RELEASING.md).
|
||||
|
||||
+14
-5
@@ -8,11 +8,19 @@ fonctionnel).
|
||||
|
||||
## 1. Bumper la version
|
||||
|
||||
Dans Cargo.toml (ex. `0.2.0`), puis :
|
||||
Dans Cargo.toml (ex. `0.2.1`), puis mettre à jour Cargo.lock
|
||||
(`cargo check`) et compiler :
|
||||
|
||||
cargo build --release # binaire Windows (am.exe) sur Windows
|
||||
# sur une machine Linux :
|
||||
cargo build --release # binaire Linux (am)
|
||||
|
||||
# binaire Linux STATIQUE (musl) : compatible toutes distributions,
|
||||
# y compris Debian 12 (glibc 2.36) — ne JAMAIS publier un binaire glibc
|
||||
# compilé sur une base récente.
|
||||
docker run --rm -v "${PWD}:/work" -w /work -e CARGO_TARGET_DIR=/tmp/target-musl \
|
||||
rust:1.94-alpine sh -c "apk add --no-cache build-base && \
|
||||
rustup target add x86_64-unknown-linux-musl && \
|
||||
cargo build --release --locked --target x86_64-unknown-linux-musl && \
|
||||
cp /tmp/target-musl/x86_64-unknown-linux-musl/release/am dist/am-linux-x86_64"
|
||||
|
||||
## 2. Créer les archives des binaires
|
||||
|
||||
@@ -20,9 +28,10 @@ Windows (PowerShell) :
|
||||
|
||||
Compress-Archive target\release\am.exe am-windows-x86_64.zip
|
||||
|
||||
Linux :
|
||||
Linux (l'archive doit contenir un fichier nommé « am ») :
|
||||
|
||||
tar -czf am-linux-x86_64.tar.gz -C target/release am
|
||||
docker run --rm -v "${PWD}:/work" -w /work rust:1.94-alpine sh -c \
|
||||
"cd dist && cp am-linux-x86_64 am && tar -czf am-linux-x86_64.tar.gz am && rm am"
|
||||
|
||||
## 3. Créer la release sur Gitea
|
||||
|
||||
|
||||
+14
-1
@@ -14,7 +14,7 @@ version: "1.0"
|
||||
settings:
|
||||
install_dir: null # default: ~/.local/share/agent-manager/agents
|
||||
log_dir: null # default: ~/.local/state/agent-manager/logs
|
||||
default_shell: null # default: sh on Unix
|
||||
default_shell: null # install scripts + shell gateway (default: user's shell)
|
||||
auto_install_deps: true # offer to install missing dependencies
|
||||
confirm_before_run: true # confirm before running install scripts
|
||||
stop_timeout_secs: 5 # SIGTERM grace period before SIGKILL
|
||||
@@ -56,6 +56,19 @@ agents:
|
||||
run: claude
|
||||
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
|
||||
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)."
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ function Install-FromSource {
|
||||
Write-Host "Installation depuis les sources (cargo install --git $BaseUrl.git) ..."
|
||||
cargo install --git "$BaseUrl.git" --locked
|
||||
if ($LASTEXITCODE -ne 0) { Fail "cargo install a echoue" }
|
||||
$bin = Join-Path $env:USERPROFILE ".cargoinam.exe"
|
||||
$bin = Join-Path $env:USERPROFILE ".cargo\binam.exe"
|
||||
Write-Host ""
|
||||
Write-Host "OK - am est installe dans $bin"
|
||||
Write-Host "Essayez : am list | am doctor (mise a jour : relancez ce script)"
|
||||
@@ -32,7 +32,7 @@ function Install-FromSource {
|
||||
}
|
||||
|
||||
$arch = if ($env:PROCESSOR_ARCHITECTURE -match "ARM") { "arm64" } else { "x86_64" }
|
||||
$binDir = if ($InstallDir) { $InstallDir } else { Join-Path $env:LOCALAPPDATA "agent-managerin" }
|
||||
$binDir = if ($InstallDir) { $InstallDir } else { Join-Path $env:LOCALAPPDATA "agent-manager\bin" }
|
||||
|
||||
if ($arch -eq "x86_64") {
|
||||
$ok = $false
|
||||
|
||||
+7
-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,
|
||||
@@ -163,6 +163,12 @@ pub enum Command {
|
||||
#[arg(long, value_name = "FILE")]
|
||||
to: Option<PathBuf>,
|
||||
},
|
||||
/// Remove agent-manager and everything it created from this machine
|
||||
///
|
||||
/// Stops every managed background agent, then deletes the data, state and
|
||||
/// configuration directories, and finally the executable itself. Combine
|
||||
/// with --yes for a fully automated one-liner.
|
||||
SelfUninstall,
|
||||
/// Export the configuration and installation state (backup)
|
||||
Export {
|
||||
/// Output file (default: am-export.json)
|
||||
|
||||
@@ -12,6 +12,10 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
|
||||
for r in &repaired {
|
||||
app.log.success(r);
|
||||
}
|
||||
let runner = SystemRunner::new(false, app.cli.verbose, &app.log);
|
||||
for r in doctor::install_missing_tools(app, &runner)? {
|
||||
app.log.success(r);
|
||||
}
|
||||
app.log.info("run 'am doctor' again to verify");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
+134
-48
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::output::print_json;
|
||||
use colored::Colorize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -101,51 +102,102 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
println!("{}", agent.title());
|
||||
if agent.name != agent.title() {
|
||||
println!(" slug: {}", agent.name);
|
||||
}
|
||||
if let Some(d) = &agent.description {
|
||||
println!(" description: {d}");
|
||||
}
|
||||
let color = app.color();
|
||||
let dim = |s: &str| -> String {
|
||||
if color {
|
||||
s.dimmed().to_string()
|
||||
} else {
|
||||
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 {
|
||||
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 {
|
||||
println!(" website: {w}");
|
||||
}
|
||||
if !agent.tags.is_empty() {
|
||||
println!(" tags: {}", agent.tags.join(", "));
|
||||
}
|
||||
if !agent.platforms.is_empty() {
|
||||
println!(" platforms: {}", agent.platforms.join(", "));
|
||||
println!();
|
||||
println!(" {} {}", dim("website"), w);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Status block
|
||||
let status = if running {
|
||||
format!("running (pid {})", entry.as_ref().and_then(|e| e.pid).unwrap_or(0))
|
||||
// --- Status --------------------------------------------------------------
|
||||
let external = external_path(agent);
|
||||
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() {
|
||||
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 {
|
||||
"not installable locally".to_string()
|
||||
} else if external_path(agent).is_some() {
|
||||
"found on PATH (external — not managed)".to_string()
|
||||
("not-installable", String::new())
|
||||
} else if external.is_some() {
|
||||
("external", " (found on PATH — not managed)".to_string())
|
||||
} 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 {
|
||||
let args = if agent.args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", agent.args.join(" "))
|
||||
};
|
||||
println!(" run: {run}{args}");
|
||||
println!();
|
||||
println!(" {} {}", dim("▶ run"), format!("{run}{args}"));
|
||||
}
|
||||
if !agent.env.is_empty() {
|
||||
println!(
|
||||
" env: {}",
|
||||
" {} {}",
|
||||
dim("⚙ env"),
|
||||
agent
|
||||
.env
|
||||
.iter()
|
||||
@@ -155,38 +207,72 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Installation ---------------------------------------------------------
|
||||
if let Some(spec) = &agent.install {
|
||||
println!(" install methods:");
|
||||
println!();
|
||||
println!(" {}", bold("install"));
|
||||
for (i, m) in spec.all_methods().iter().enumerate() {
|
||||
let note = m
|
||||
.note
|
||||
.as_deref()
|
||||
.map(|n| format!(" ({n})"))
|
||||
.unwrap_or_default();
|
||||
println!(" {}) {}{}", i + 1, describe_method(m), note);
|
||||
println!(" {}) {}", i + 1, describe_method(m));
|
||||
if let Some(n) = &m.note {
|
||||
println!(" {}", dim(n));
|
||||
}
|
||||
}
|
||||
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() {
|
||||
println!(" dependencies:");
|
||||
for d in &agent.dependencies {
|
||||
let min = d
|
||||
.min_version
|
||||
.as_deref()
|
||||
.map(|m| format!(" >= {m}"))
|
||||
.unwrap_or_default();
|
||||
let hint = d
|
||||
.install_hint
|
||||
.as_deref()
|
||||
.map(|h| format!(" — {h}"))
|
||||
.unwrap_or_default();
|
||||
println!(" - {}{}{}", d.name, min, hint);
|
||||
println!();
|
||||
println!(" {}", bold("dependencies"));
|
||||
let runner = crate::runner::SystemRunner::new(false, app.cli.verbose, &app.log);
|
||||
let checks = crate::deps::check_agent(agent, &runner);
|
||||
for c in &checks {
|
||||
let mark = if c.ok { "✓" } else { "✗" };
|
||||
let mark = if color {
|
||||
if c.ok {
|
||||
mark.green().to_string()
|
||||
} else {
|
||||
mark.red().to_string()
|
||||
}
|
||||
} 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 {
|
||||
println!(" note: {n}");
|
||||
println!();
|
||||
println!(" {}", dim(&format!("note {n}")));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod install_cmd;
|
||||
pub mod list_cmd;
|
||||
pub mod run_cmd;
|
||||
pub mod search_cmd;
|
||||
pub mod self_uninstall;
|
||||
pub mod self_update;
|
||||
pub mod status_cmd;
|
||||
pub mod uninstall_cmd;
|
||||
@@ -59,6 +60,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
Command::Run { agent, args } => run_cmd::run(app, agent, args),
|
||||
Command::Completion { shell } => completion_cmd::run(*shell),
|
||||
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
|
||||
Command::SelfUninstall => self_uninstall::run(app),
|
||||
Command::Export { output } => export_import::export_cmd(app, output),
|
||||
Command::Import { file } => export_import::import_cmd(app, file),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! self-uninstall: remove agent-manager and everything it created from this
|
||||
//! machine — background agents are stopped first, then the data, state and
|
||||
//! configuration directories are deleted, then the executable itself.
|
||||
|
||||
use crate::app::{self, App};
|
||||
use anyhow::{bail, Result};
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context;
|
||||
use colored::Colorize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(windows)]
|
||||
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)]
|
||||
#[link(name = "Kernel32")]
|
||||
extern "system" {
|
||||
fn MoveFileExW(
|
||||
lpExistingFileName: *const u16,
|
||||
lpNewFileName: *const u16,
|
||||
dwFlags: u32,
|
||||
) -> i32;
|
||||
fn GetFileAttributesW(lpFileName: *const u16) -> u32;
|
||||
fn SetFileAttributesW(lpFileName: *const u16, dwFileAttributes: u32) -> i32;
|
||||
}
|
||||
|
||||
pub fn run(app: &App) -> Result<i32> {
|
||||
// 1. Stop every managed background agent.
|
||||
let state = app.state.load()?;
|
||||
for (name, entry) in &state.installed {
|
||||
if let Some(pid) = entry.pid {
|
||||
if crate::process::is_running(pid) {
|
||||
app.log.info(&format!("stopping agent '{name}' (pid {pid})"));
|
||||
crate::process::stop_pid(pid, false, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Collect the directories agent-manager owns.
|
||||
let data_root = app::data_dir();
|
||||
let state_root = app::state_dir();
|
||||
let mut roots: Vec<(&str, PathBuf)> = Vec::new();
|
||||
if data_root.is_dir() {
|
||||
roots.push(("data", data_root.clone()));
|
||||
}
|
||||
if state_root != data_root && state_root.is_dir() {
|
||||
roots.push(("state", state_root));
|
||||
}
|
||||
if let Some(dir) = crate::config::user_config_dir() {
|
||||
if dir.is_dir() {
|
||||
roots.push(("config", dir));
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
} else {
|
||||
app.log.info("agent-manager will remove:");
|
||||
for (label, path) in &roots {
|
||||
println!(" {label}: {}", path.display());
|
||||
}
|
||||
if !app.confirm("Remove everything listed above?")? {
|
||||
app.log.info("cancelled — nothing was removed");
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
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.
|
||||
// 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 {
|
||||
remove_tree(path, &mut locked);
|
||||
}
|
||||
for (_, path) in &roots {
|
||||
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, then sweep the directories left
|
||||
// empty behind it.
|
||||
let cleanup_roots: Vec<PathBuf> = roots.iter().map(|(_, p)| p.clone()).collect();
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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, renamed: Option<&Path>, roots: &[PathBuf]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::fs::remove_file(exe)
|
||||
.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!(
|
||||
"{} {}",
|
||||
"agent-manager removed:".green(),
|
||||
exe.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
// 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();
|
||||
}
|
||||
// Belt and braces: if something keeps the file locked, the standard
|
||||
// uninstaller mechanism deletes it at the next reboot.
|
||||
let wide: Vec<u16> = target
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let at_reboot = unsafe {
|
||||
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
|
||||
};
|
||||
if bat.is_none() && !at_reboot {
|
||||
bail!(
|
||||
"cannot schedule deletion of {} — delete it manually",
|
||||
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)))]
|
||||
{
|
||||
bail!(
|
||||
"automatic removal of the executable is not supported on this platform; delete {} manually",
|
||||
exe.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
//! entry; --purge also removes logs and the user-config definition.
|
||||
//! External agents (installed outside agent-manager) are removed through
|
||||
//! their package manager when the catalog knows it, or by deleting the
|
||||
//! executables found on PATH otherwise.
|
||||
|
||||
use super::*;
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
||||
let agent = require_agent(app, agent_name)?;
|
||||
let entry = app.state.get(&agent.name).ok().flatten();
|
||||
let Some(entry) = entry else {
|
||||
app.log.info(&format!(
|
||||
"agent '{}' is not managed by agent-manager — nothing to uninstall",
|
||||
agent.name
|
||||
));
|
||||
return Ok(0);
|
||||
return uninstall_external(app, agent);
|
||||
};
|
||||
|
||||
// Stop it first when running.
|
||||
@@ -65,6 +65,130 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Build the package-manager uninstall command for an external agent whose
|
||||
/// install method is a known package manager and whose package is recorded.
|
||||
fn package_plan(agent: &AgentDef) -> Option<Vec<String>> {
|
||||
use crate::config::InstallType;
|
||||
let spec = agent.install.as_ref()?;
|
||||
for m in spec.all_methods() {
|
||||
let Some(pkg) = m.package.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let plan = match m.kind {
|
||||
InstallType::Npm => {
|
||||
vec!["npm", "uninstall", "--global", pkg]
|
||||
}
|
||||
InstallType::Pip => {
|
||||
if which::which("pip").is_ok() {
|
||||
vec!["pip", "uninstall", "-y", pkg]
|
||||
} else if which::which("python3").is_ok() {
|
||||
vec!["python3", "-m", "pip", "uninstall", "-y", pkg]
|
||||
} else if which::which("python").is_ok() {
|
||||
vec!["python", "-m", "pip", "uninstall", "-y", pkg]
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
InstallType::Uv => vec!["uv", "tool", "uninstall", pkg],
|
||||
InstallType::Cargo => vec!["cargo", "uninstall", pkg],
|
||||
InstallType::Bun => vec!["bun", "remove", "--global", pkg],
|
||||
_ => continue,
|
||||
};
|
||||
return Some(plan.into_iter().map(|s| s.to_string()).collect());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Best-effort removal of an agent installed outside agent-manager: first the
|
||||
/// package-manager command when the catalog knows one, then the executable
|
||||
/// files left on PATH (and their directory when it is dedicated to the
|
||||
/// agent, e.g. AppData\Local\atomic-agent).
|
||||
fn uninstall_external(app: &App, agent: &AgentDef) -> Result<i32> {
|
||||
let first_token = agent.first_token();
|
||||
|
||||
// 1. Package-manager plan (npm, pip, uv, cargo, bun).
|
||||
if let Some(tokens) = package_plan(agent) {
|
||||
if which::which(&tokens[0]).is_ok() {
|
||||
app.log.info(&format!(
|
||||
"agent '{}' is external (installed outside agent-manager) — uninstall with: {}",
|
||||
agent.name,
|
||||
tokens.join(" ")
|
||||
));
|
||||
if !app.confirm("Run this command?")? {
|
||||
app.log.info("cancelled — nothing was uninstalled");
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would run: {}", tokens.join(" ")));
|
||||
} else {
|
||||
// resolve_program handles Windows .cmd/.bat shims (npm,
|
||||
// pip, ...) which std::process::Command cannot launch.
|
||||
let (prog, prefix) = crate::runner::resolve_program(&tokens[0]);
|
||||
let mut args = prefix;
|
||||
args.extend(tokens[1..].iter().cloned());
|
||||
let status = ProcessCommand::new(&prog)
|
||||
.args(&args)
|
||||
.status()
|
||||
.with_context(|| format!("failed to run {}", tokens.join(" ")))?;
|
||||
if !status.success() {
|
||||
app.log.warn("the command failed — removing the files it left behind");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Whatever executable is still on PATH, delete it directly (and its
|
||||
// directory when it belongs to the agent).
|
||||
let still = first_token.as_deref().and_then(|t| which::which(t).ok());
|
||||
if let Some(path) = still {
|
||||
let parent = path.parent().map(|p| p.to_path_buf());
|
||||
let dedicated = parent
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().eq_ignore_ascii_case(&agent.name))
|
||||
.unwrap_or(false);
|
||||
let target: PathBuf = if dedicated {
|
||||
parent.clone().unwrap_or_else(|| path.clone())
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
app.log.info(&format!(
|
||||
"remove leftover {} {}",
|
||||
if dedicated { "directory" } else { "file" },
|
||||
target.display()
|
||||
));
|
||||
if !app.confirm("Delete it?")? {
|
||||
app.log.info(&format!("'{}' left in place", target.display()));
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!("would remove {}", target.display()));
|
||||
} else if dedicated {
|
||||
std::fs::remove_dir_all(&target)
|
||||
.with_context(|| format!("cannot remove {}", target.display()))?;
|
||||
} else {
|
||||
std::fs::remove_file(&target)
|
||||
.with_context(|| format!("cannot remove {}", target.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Final verification.
|
||||
let gone = first_token
|
||||
.as_deref()
|
||||
.and_then(|t| which::which(t).ok())
|
||||
.is_none();
|
||||
if gone {
|
||||
app.log
|
||||
.success(&format!("uninstalled {} (external)", agent.title()));
|
||||
} else {
|
||||
app.log.warn(&format!(
|
||||
"{} is still present on PATH — remove it manually",
|
||||
agent.title()
|
||||
));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Remove the agent entry from the user config file (--purge).
|
||||
fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
||||
let Some(path) = &app.config_origin else {
|
||||
@@ -111,3 +235,80 @@ fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn agent_from_yaml(yaml: &str) -> AgentDef {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Wrap {
|
||||
agents: Vec<AgentDef>,
|
||||
}
|
||||
let w: Wrap = serde_yaml::from_str(yaml).unwrap();
|
||||
w.agents.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_plan_npm() {
|
||||
let a = agent_from_yaml(
|
||||
r#"
|
||||
agents:
|
||||
- name: x
|
||||
install:
|
||||
type: npm
|
||||
package: "@scope/pkg"
|
||||
run: x
|
||||
"#,
|
||||
);
|
||||
let plan = package_plan(&a).unwrap();
|
||||
assert_eq!(plan, vec!["npm", "uninstall", "--global", "@scope/pkg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_plan_cargo() {
|
||||
let a = agent_from_yaml(
|
||||
r#"
|
||||
agents:
|
||||
- name: x
|
||||
install:
|
||||
type: cargo
|
||||
package: my-crate
|
||||
run: x
|
||||
"#,
|
||||
);
|
||||
let plan = package_plan(&a).unwrap();
|
||||
assert_eq!(plan, vec!["cargo", "uninstall", "my-crate"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_plan_binary_is_none() {
|
||||
let a = agent_from_yaml(
|
||||
r#"
|
||||
agents:
|
||||
- name: y
|
||||
install:
|
||||
type: binary
|
||||
repo: owner/repo
|
||||
binary: y
|
||||
run: y
|
||||
"#,
|
||||
);
|
||||
assert!(package_plan(&a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_plan_unknown_method_is_none() {
|
||||
let a = agent_from_yaml(
|
||||
r#"
|
||||
agents:
|
||||
- name: z
|
||||
install:
|
||||
type: git
|
||||
repo: https://example.com/z.git
|
||||
run: z
|
||||
"#,
|
||||
);
|
||||
assert!(package_plan(&a).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -50,7 +50,8 @@ pub struct Settings {
|
||||
/// Directory for logs.
|
||||
#[serde(default)]
|
||||
pub log_dir: Option<String>,
|
||||
/// Shell used to execute install scripts (defaults to sh on Unix).
|
||||
/// Shell used to execute install scripts and to run system commands
|
||||
/// in the interactive prompt (defaults to the user's default shell).
|
||||
#[serde(default)]
|
||||
pub default_shell: Option<String>,
|
||||
/// Try to install missing dependencies automatically.
|
||||
@@ -623,6 +624,7 @@ mod tests {
|
||||
let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
|
||||
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 == "deepseek-harness"));
|
||||
assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
|
||||
}
|
||||
|
||||
|
||||
+46
-18
@@ -111,34 +111,54 @@ pub fn check_agent(agent: &AgentDef, runner: &dyn Runner) -> Vec<DepCheck> {
|
||||
}
|
||||
|
||||
/// Fail with an actionable message when dependencies are missing.
|
||||
/// With auto_install_deps enabled, dependencies that define an install_cmd
|
||||
/// are offered for automatic installation first.
|
||||
///
|
||||
/// With auto_install_deps enabled (the default), missing tools are offered
|
||||
/// for installation right away: an explicit install_cmd from the config
|
||||
/// first, otherwise the command detected for the current OS
|
||||
/// (scoop/winget on Windows, apt/dnf/pacman/apk on Linux, brew on macOS).
|
||||
pub fn ensure_deps(app: &App, agent: &AgentDef, runner: &dyn Runner) -> Result<()> {
|
||||
let mut checks = check_agent(agent, runner);
|
||||
if checks.iter().all(|c| c.ok) {
|
||||
return Ok(());
|
||||
}
|
||||
let os = crate::toolchain::OsInfo::detect();
|
||||
if app.config.settings.auto_install_deps() && !app.dry_run() {
|
||||
for dep in &agent.dependencies {
|
||||
if dep.install_cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if check_dep(runner, dep).ok {
|
||||
continue;
|
||||
}
|
||||
let cmd_line = dep.install_cmd.join(" ");
|
||||
app.log.warn(&format!("dependency '{}' is missing", dep.name));
|
||||
let proceed = app
|
||||
.confirm(&format!("Run '{cmd_line}' to install it?"))
|
||||
.unwrap_or(false);
|
||||
if proceed {
|
||||
let prog = dep.install_cmd[0].clone();
|
||||
let args: Vec<String> = dep.install_cmd[1..].to_vec();
|
||||
let empty = BTreeMap::new();
|
||||
let out = runner.run(&prog, &args, &empty, None, false)?;
|
||||
if out.code != 0 {
|
||||
app.log
|
||||
.warn(&format!("automatic installation of '{}' failed", dep.name));
|
||||
// 1) Explicit install command from the configuration.
|
||||
if !dep.install_cmd.is_empty() {
|
||||
let cmd_line = dep.install_cmd.join(" ");
|
||||
if app.confirm(&format!("Run '{cmd_line}' to install it?"))? {
|
||||
let prog = dep.install_cmd[0].clone();
|
||||
let args: Vec<String> = dep.install_cmd[1..].to_vec();
|
||||
let empty = BTreeMap::new();
|
||||
let out = runner.run(&prog, &args, &empty, None, false)?;
|
||||
if out.code != 0 {
|
||||
app.log.warn(&format!(
|
||||
"automatic installation of '{}' failed (exit code {})",
|
||||
dep.name, out.code
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 2) Command detected for the current OS.
|
||||
let cmds = crate::toolchain::install_commands(&dep.name, &os);
|
||||
if let Some(cmd) = cmds.first() {
|
||||
let proceed = app
|
||||
.confirm(&format!("Run the detected installer for {}? -> {cmd}", os.name))?;
|
||||
if proceed {
|
||||
let code = crate::toolchain::run_install_command(runner, cmd)?;
|
||||
crate::toolchain::refresh_path();
|
||||
if code != 0 {
|
||||
app.log.warn(&format!(
|
||||
"installation of '{}' failed (exit code {code})",
|
||||
dep.name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +173,15 @@ pub fn ensure_deps(app: &App, agent: &AgentDef, runner: &dyn Runner) -> Result<(
|
||||
if let Some(min) = &c.min {
|
||||
line.push_str(&format!(" (minimum: {min})"));
|
||||
}
|
||||
if let Some(dep) = agent.dependencies.iter().find(|d| d.name == c.name) {
|
||||
let cmds = crate::toolchain::install_commands(&c.name, &os);
|
||||
if !cmds.is_empty() {
|
||||
line.push_str(&format!(
|
||||
"
|
||||
on {}: {}",
|
||||
os.name,
|
||||
cmds.join(" | ")
|
||||
));
|
||||
} else if let Some(dep) = agent.dependencies.iter().find(|d| d.name == c.name) {
|
||||
if let Some(hint) = &dep.install_hint {
|
||||
line.push_str(&format!(" — install via {hint}"));
|
||||
}
|
||||
|
||||
+49
-7
@@ -63,8 +63,14 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
|
||||
("uv", false),
|
||||
("docker", false),
|
||||
];
|
||||
let os = crate::toolchain::OsInfo::detect();
|
||||
for (tool, required) in tools {
|
||||
match runner.which(tool) {
|
||||
let found = if *tool == "python" {
|
||||
runner.which("python3").or_else(|| runner.which("python"))
|
||||
} else {
|
||||
runner.which(tool)
|
||||
};
|
||||
match found {
|
||||
Some(path) => {
|
||||
let mut detail = path.display().to_string();
|
||||
let empty = BTreeMap::new();
|
||||
@@ -84,14 +90,20 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
|
||||
});
|
||||
}
|
||||
None => {
|
||||
let (status, detail) = if *required {
|
||||
(
|
||||
Status::Error,
|
||||
"required but not found on PATH".to_string(),
|
||||
)
|
||||
let mut detail = if *required {
|
||||
"required but not found on PATH".to_string()
|
||||
} else {
|
||||
(Status::Ok, "not installed".to_string())
|
||||
"not installed".to_string()
|
||||
};
|
||||
let cmds = crate::toolchain::install_commands(tool, &os);
|
||||
if !cmds.is_empty() {
|
||||
detail.push_str(&format!(
|
||||
" — on {}: {}",
|
||||
os.name,
|
||||
cmds.join(" | ")
|
||||
));
|
||||
}
|
||||
let status = if *required { Status::Error } else { Status::Ok };
|
||||
checks.push(Check {
|
||||
name: tool.to_string(),
|
||||
status,
|
||||
@@ -199,6 +211,36 @@ fn is_on_path(dir: &std::path::Path) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Offer to install the missing optional tools (node, python, ...) with
|
||||
/// the command detected for the current OS. Returns what was repaired.
|
||||
pub fn install_missing_tools(app: &App, runner: &dyn Runner) -> Result<Vec<String>> {
|
||||
let mut installed = Vec::new();
|
||||
let os = crate::toolchain::OsInfo::detect();
|
||||
for tool in ["git", "node", "python", "cargo", "go", "bun", "uv", "docker"] {
|
||||
if crate::toolchain::tool_is_present(tool, runner) {
|
||||
continue;
|
||||
}
|
||||
let cmds = crate::toolchain::install_commands(tool, &os);
|
||||
let Some(cmd) = cmds.first() else {
|
||||
continue;
|
||||
};
|
||||
if !app.confirm(&format!(
|
||||
"'{tool}' is missing — run the detected installer for {}? -> {cmd}",
|
||||
os.name
|
||||
))? {
|
||||
continue;
|
||||
}
|
||||
let code = crate::toolchain::run_install_command(runner, cmd)?;
|
||||
crate::toolchain::refresh_path();
|
||||
installed.push(if code == 0 {
|
||||
format!("installed '{tool}' ({cmd})")
|
||||
} else {
|
||||
format!("failed to install '{tool}' (exit code {code})")
|
||||
});
|
||||
}
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
/// Repair what can be repaired: create directories, fix a corrupt state file.
|
||||
pub fn fix(app: &App) -> Result<Vec<String>> {
|
||||
let mut repaired = Vec::new();
|
||||
|
||||
+17
-4
@@ -136,11 +136,24 @@ pub fn sh_inherit(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Require a tool on the PATH, with an actionable error message.
|
||||
/// Require a tool on the PATH, with an actionable error message that
|
||||
/// includes the install command detected for the current OS.
|
||||
pub fn require_tool(runner: &dyn Runner, tool: &str, hint: &str) -> Result<PathBuf> {
|
||||
runner
|
||||
.which(tool)
|
||||
.ok_or_else(|| anyhow!("'{tool}' not found on PATH — install it first ({hint})"))
|
||||
if let Some(p) = runner.which(tool) {
|
||||
return Ok(p);
|
||||
}
|
||||
let os = crate::toolchain::OsInfo::detect();
|
||||
let mut msg = format!("'{tool}' not found on PATH — install it first ({hint})");
|
||||
let cmds = crate::toolchain::install_commands(tool, &os);
|
||||
if !cmds.is_empty() {
|
||||
msg.push_str(&format!(
|
||||
"
|
||||
on {}: {}",
|
||||
os.name,
|
||||
cmds.join(" | ")
|
||||
));
|
||||
}
|
||||
Err(anyhow!(msg))
|
||||
}
|
||||
|
||||
/// Detect a binary's version via --version, then -V.
|
||||
|
||||
@@ -20,12 +20,15 @@ pub mod deps;
|
||||
pub mod doctor;
|
||||
pub mod download;
|
||||
pub mod installers;
|
||||
pub mod nav;
|
||||
pub mod output;
|
||||
pub mod probe;
|
||||
pub mod process;
|
||||
pub mod repl;
|
||||
pub mod runner;
|
||||
pub mod shell;
|
||||
pub mod state;
|
||||
pub mod toolchain;
|
||||
pub mod version;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
+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}");
|
||||
}
|
||||
}
|
||||
+779
-55
File diff suppressed because it is too large
Load Diff
+296
@@ -0,0 +1,296 @@
|
||||
//! System-shell gateway.
|
||||
//!
|
||||
//! The interactive prompt doubles as a gateway between the user and their
|
||||
//! system shell: every input that is not an am command is executed by the
|
||||
//! selected shell (pwsh, powershell, cmd, bash, zsh, fish, sh, ...).
|
||||
//!
|
||||
//! The default shell is resolved at startup from, in order: the
|
||||
//! `settings.default_shell` configuration entry, the `SHELL` environment
|
||||
//! variable, `COMSPEC` (Windows), then the first shell found on PATH among
|
||||
//! the platform's preferred shells. `shell <name>` switches at runtime.
|
||||
|
||||
use crate::config::Config;
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
/// One supported system shell and how to invoke it with a command line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ShellSpec {
|
||||
/// Canonical short name ("pwsh", "cmd", "bash", ...).
|
||||
pub name: &'static str,
|
||||
/// Human-readable title ("PowerShell 7", "Command Prompt", ...).
|
||||
pub title: &'static str,
|
||||
/// Executable to spawn.
|
||||
pub program: &'static str,
|
||||
/// Arguments placed before the user's command line.
|
||||
pub args: &'static [&'static str],
|
||||
/// Executable probed on PATH to decide whether the shell is available.
|
||||
pub probe: &'static str,
|
||||
}
|
||||
|
||||
/// Every shell the gateway understands, in display order.
|
||||
pub static KNOWN_SHELLS: &[ShellSpec] = &[
|
||||
ShellSpec {
|
||||
name: "pwsh",
|
||||
title: "PowerShell 7",
|
||||
program: "pwsh",
|
||||
args: &["-NoLogo", "-NoProfile", "-Command"],
|
||||
probe: "pwsh",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "powershell",
|
||||
title: "Windows PowerShell",
|
||||
program: "powershell",
|
||||
args: &["-NoLogo", "-NoProfile", "-Command"],
|
||||
probe: "powershell",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "cmd",
|
||||
title: "Command Prompt",
|
||||
program: "cmd",
|
||||
args: &["/C"],
|
||||
probe: "cmd",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "bash",
|
||||
title: "Bash",
|
||||
program: "bash",
|
||||
args: &["-c"],
|
||||
probe: "bash",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "zsh",
|
||||
title: "Zsh",
|
||||
program: "zsh",
|
||||
args: &["-c"],
|
||||
probe: "zsh",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "fish",
|
||||
title: "Fish",
|
||||
program: "fish",
|
||||
args: &["-c"],
|
||||
probe: "fish",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "sh",
|
||||
title: "POSIX sh",
|
||||
program: "sh",
|
||||
args: &["-c"],
|
||||
probe: "sh",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "nu",
|
||||
title: "Nushell",
|
||||
program: "nu",
|
||||
args: &["-c"],
|
||||
probe: "nu",
|
||||
},
|
||||
ShellSpec {
|
||||
name: "elvish",
|
||||
title: "Elvish",
|
||||
program: "elvish",
|
||||
args: &["-c"],
|
||||
probe: "elvish",
|
||||
},
|
||||
];
|
||||
|
||||
/// Resolve a shell by canonical name or common alias, case-insensitively
|
||||
/// ("pwsh" | "powershell7" | "ps" | "cmd.exe" | "nushell" | ...).
|
||||
pub fn find(name: &str) -> Option<&'static ShellSpec> {
|
||||
let mut n = name.trim().to_ascii_lowercase();
|
||||
if let Some(stripped) = n.strip_suffix(".exe") {
|
||||
n = stripped.to_string();
|
||||
}
|
||||
KNOWN_SHELLS.iter().find(|s| {
|
||||
s.name == n
|
||||
|| match s.name {
|
||||
"pwsh" => n == "powershell7" || n == "ps7",
|
||||
"powershell" => n == "ps",
|
||||
"cmd" => n == "dos" || n == "command" || n == "command-prompt",
|
||||
"sh" => n == "posix" || n == "dash",
|
||||
"nu" => n == "nushell",
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a shell executable can be found (or, for cmd on Windows, whether
|
||||
/// COMSPEC is set).
|
||||
pub fn probe_available(spec: &ShellSpec) -> bool {
|
||||
which::which(spec.probe).is_ok()
|
||||
|| (cfg!(windows) && spec.name == "cmd" && std::env::var_os("COMSPEC").is_some())
|
||||
}
|
||||
|
||||
/// Resolve the user's default shell from the configuration, environment
|
||||
/// variables, then the shells found on PATH. Always returns a shell.
|
||||
pub fn resolve_default(
|
||||
configured: Option<&str>,
|
||||
shell_env: Option<OsString>,
|
||||
comspec: Option<OsString>,
|
||||
) -> &'static ShellSpec {
|
||||
if let Some(s) = configured.and_then(find) {
|
||||
return s;
|
||||
}
|
||||
if let Some(s) = shell_env.and_then(|v| from_env_path(&v)) {
|
||||
return s;
|
||||
}
|
||||
if let Some(s) = comspec.and_then(|v| from_env_path(&v)) {
|
||||
return s;
|
||||
}
|
||||
let preferred: &[&str] = if cfg!(windows) {
|
||||
&["pwsh", "powershell", "cmd"]
|
||||
} else {
|
||||
&["bash", "zsh", "fish", "sh"]
|
||||
};
|
||||
for name in preferred {
|
||||
if let Some(s) = find(name) {
|
||||
if probe_available(s) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Last resort: the platform's built-in shell.
|
||||
find(if cfg!(windows) { "cmd" } else { "sh" }).expect("built-in shell must exist")
|
||||
}
|
||||
|
||||
/// Extract a shell name from an environment value such as SHELL or COMSPEC.
|
||||
fn from_env_path(value: &OsString) -> Option<&'static ShellSpec> {
|
||||
let text = value.to_string_lossy();
|
||||
let name = Path::new(text.as_ref())
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
find(&name)
|
||||
}
|
||||
|
||||
/// The gateway's runtime state: which shell executes system commands now,
|
||||
/// which one was detected as the user's default, and which are installed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShellSession {
|
||||
/// Shell executing system commands right now.
|
||||
pub current: &'static ShellSpec,
|
||||
/// The shell detected as the user's default at startup.
|
||||
pub default: &'static ShellSpec,
|
||||
/// Shells found on this machine.
|
||||
pub available: Vec<&'static ShellSpec>,
|
||||
}
|
||||
|
||||
impl ShellSession {
|
||||
/// Build a session: detect the default shell and probe availability.
|
||||
pub fn start(config: &Config) -> Self {
|
||||
let default = resolve_default(
|
||||
config.settings.default_shell.as_deref(),
|
||||
std::env::var_os("SHELL"),
|
||||
std::env::var_os("COMSPEC"),
|
||||
);
|
||||
let available = KNOWN_SHELLS
|
||||
.iter()
|
||||
.filter(|s| probe_available(s))
|
||||
.collect();
|
||||
Self {
|
||||
current: default,
|
||||
default,
|
||||
available,
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the active shell. Fails for unknown or unavailable shells.
|
||||
pub fn switch(&mut self, name: &str) -> Result<&'static ShellSpec> {
|
||||
let spec = find(name).ok_or_else(|| anyhow!("unknown shell '{name}'"))?;
|
||||
if !self.available.iter().any(|s| s.name == spec.name) {
|
||||
return Err(anyhow!(
|
||||
"shell '{}' is not available on this machine (available: {})",
|
||||
spec.name,
|
||||
self.available
|
||||
.iter()
|
||||
.map(|s| s.name)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
self.current = spec;
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
/// Whether the active shell is the user's default shell.
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.current.name == self.default.name
|
||||
}
|
||||
|
||||
/// Execute one system command line in the current shell with inherited
|
||||
/// stdio, so interactive programs keep working. Returns the exit code.
|
||||
pub fn run(&self, line: &str) -> Result<i32> {
|
||||
let status = ProcessCommand::new(self.current.program)
|
||||
.args(self.current.args)
|
||||
.arg(line)
|
||||
.status()
|
||||
.map_err(|e| anyhow!("cannot run {}: {e}", self.current.program))?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn find_resolves_aliases() {
|
||||
assert_eq!(find("pwsh").unwrap().name, "pwsh");
|
||||
assert_eq!(find("PowerShell7").unwrap().name, "pwsh");
|
||||
assert_eq!(find("ps").unwrap().name, "powershell");
|
||||
assert_eq!(find("cmd.exe").unwrap().name, "cmd");
|
||||
assert_eq!(find("CMD").unwrap().name, "cmd");
|
||||
assert_eq!(find("nushell").unwrap().name, "nu");
|
||||
assert_eq!(find("dash").unwrap().name, "sh");
|
||||
assert!(find("blorp").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_default_prefers_configured_shell() {
|
||||
let s = resolve_default(Some("zsh"), None, None);
|
||||
assert_eq!(s.name, "zsh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_default_uses_environment_values() {
|
||||
// SHELL wins over COMSPEC when the configured value is absent.
|
||||
let s = resolve_default(
|
||||
None,
|
||||
Some(OsString::from("/usr/bin/bash")),
|
||||
Some(OsString::from("C:/Windows/system32/cmd.exe")),
|
||||
);
|
||||
assert_eq!(s.name, "bash");
|
||||
// COMSPEC alone is honored.
|
||||
let s = resolve_default(
|
||||
None,
|
||||
None,
|
||||
Some(OsString::from("C:/Windows/system32/cmd.exe")),
|
||||
);
|
||||
assert_eq!(s.name, "cmd");
|
||||
// Unrecognized values fall through to a probe — result is still a
|
||||
// known shell.
|
||||
let s = resolve_default(None, Some(OsString::from("/usr/bin/weird")), None);
|
||||
assert!(KNOWN_SHELLS.iter().any(|k| k.name == s.name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_validates_availability() {
|
||||
let sh = find("sh").unwrap();
|
||||
let bash = find("bash").unwrap();
|
||||
let mut session = ShellSession {
|
||||
current: sh,
|
||||
default: sh,
|
||||
available: vec![sh, bash],
|
||||
};
|
||||
assert_eq!(session.switch("bash").unwrap().name, "bash");
|
||||
assert_eq!(session.current.name, "bash");
|
||||
assert!(!session.is_default());
|
||||
let err = session.switch("zsh").unwrap_err();
|
||||
assert!(err.to_string().contains("not available"), "{err}");
|
||||
let err = session.switch("blorp").unwrap_err();
|
||||
assert!(err.to_string().contains("unknown shell"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
//! OS detection and concrete installation commands for missing toolchains.
|
||||
//!
|
||||
//! When an agent requires a tool (node, python, go, ...) that is missing,
|
||||
//! agent-manager detects the operating system — and the package manager
|
||||
//! already present (scoop/winget on Windows, apt/dnf/pacman/apk on Linux,
|
||||
//! brew on macOS) — and proposes the exact command to install it.
|
||||
|
||||
use crate::runner::Runner;
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Family {
|
||||
Windows,
|
||||
Macos,
|
||||
Linux,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OsInfo {
|
||||
pub name: String,
|
||||
pub family: Family,
|
||||
/// Package manager found on Linux: apt, dnf, pacman, apk or zypper.
|
||||
pub linux_pkg: Option<String>,
|
||||
pub has_scoop: bool,
|
||||
pub has_winget: bool,
|
||||
}
|
||||
|
||||
impl Default for OsInfo {
|
||||
fn default() -> Self {
|
||||
Self::detect()
|
||||
}
|
||||
}
|
||||
|
||||
impl OsInfo {
|
||||
/// Detect the current operating system and available package managers.
|
||||
pub fn detect() -> Self {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let has_scoop = which::which("scoop").is_ok();
|
||||
let has_winget = which::which("winget").is_ok();
|
||||
return Self {
|
||||
name: "Windows".to_string(),
|
||||
family: Family::Windows,
|
||||
linux_pkg: None,
|
||||
has_scoop,
|
||||
has_winget,
|
||||
};
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return Self {
|
||||
name: "macOS".to_string(),
|
||||
family: Family::Macos,
|
||||
linux_pkg: None,
|
||||
has_scoop: false,
|
||||
has_winget: false,
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
{
|
||||
let id = linux_distro_id();
|
||||
let (name, pkg) = linux_pkg_for_id(&id);
|
||||
return Self {
|
||||
name: name.to_string(),
|
||||
family: Family::Linux,
|
||||
linux_pkg: pkg.map(|s| s.to_string()),
|
||||
has_scoop: false,
|
||||
has_winget: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
fn linux_distro_id() -> String {
|
||||
let Ok(text) = std::fs::read_to_string("/etc/os-release") else {
|
||||
return String::new();
|
||||
};
|
||||
for line in text.lines() {
|
||||
if let Some(v) = line.strip_prefix("ID=") {
|
||||
return v.trim().trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Map a distro id (from /etc/os-release) to (display name, package manager).
|
||||
pub fn linux_pkg_for_id(id: &str) -> (&'static str, Option<&'static str>) {
|
||||
match id {
|
||||
"debian" | "ubuntu" | "linuxmint" | "pop" | "raspbian" | "elementary" => {
|
||||
("Debian/Ubuntu", Some("apt"))
|
||||
}
|
||||
"fedora" | "rhel" | "centos" | "rocky" | "almalinux" | "ol" => {
|
||||
("Fedora/RHEL", Some("dnf"))
|
||||
}
|
||||
"arch" | "manjaro" | "endeavouros" | "artix" => ("Arch Linux", Some("pacman")),
|
||||
"alpine" => ("Alpine", Some("apk")),
|
||||
"opensuse-tumbleweed" | "opensuse-leap" | "suse" => ("openSUSE", Some("zypper")),
|
||||
_ => ("Linux", None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Concrete install commands for a tool on the detected OS, best option
|
||||
/// first (scoop before winget when both exist, etc.).
|
||||
pub fn install_commands(tool: &str, os: &OsInfo) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
match os.family {
|
||||
Family::Windows => {
|
||||
if os.has_scoop {
|
||||
if let Some(c) = scoop_command(tool) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if os.has_winget || out.is_empty() {
|
||||
if let Some(c) = winget_command(tool) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Family::Macos => {
|
||||
if let Some(c) = brew_command(tool) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
Family::Linux => {
|
||||
if let Some(pkg) = os.linux_pkg.as_deref() {
|
||||
if let Some(c) = linux_command(tool, pkg) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run an installer command line. Commands containing shell syntax
|
||||
/// (pipes, redirections, ...) are executed through the shell; plain
|
||||
/// commands are spawned directly.
|
||||
pub fn run_install_command(runner: &dyn Runner, cmd: &str) -> Result<i32> {
|
||||
let needs_shell = cmd.contains('|')
|
||||
|| cmd.contains('>')
|
||||
|| cmd.contains('<')
|
||||
|| cmd.contains('&')
|
||||
|| cmd.contains(';');
|
||||
let (prog, args) = if needs_shell {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
("cmd".to_string(), vec!["/C".to_string(), cmd.to_string()])
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
("sh".to_string(), vec!["-c".to_string(), cmd.to_string()])
|
||||
}
|
||||
} else {
|
||||
let tokens = shell_words::split(cmd)
|
||||
.unwrap_or_else(|_| cmd.split_whitespace().map(String::from).collect());
|
||||
if tokens.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
(tokens[0].clone(), tokens[1..].to_vec())
|
||||
};
|
||||
let empty = std::collections::BTreeMap::new();
|
||||
let out = runner.run(&prog, &args, &empty, None, false)?;
|
||||
Ok(out.code)
|
||||
}
|
||||
|
||||
/// After installers that modify ~/.profile or ~/.zshrc (rustup, bun, uv),
|
||||
/// make the newly installed tools visible to the current process so the
|
||||
/// dependency re-check can find them.
|
||||
pub fn refresh_path() {
|
||||
let mut extra: Vec<PathBuf> = Vec::new();
|
||||
if let Some(home) = crate::config::home_dir() {
|
||||
extra.push(home.join(".cargo").join("bin"));
|
||||
extra.push(home.join(".bun").join("bin"));
|
||||
extra.push(home.join(".local").join("bin"));
|
||||
}
|
||||
let mut parts: Vec<PathBuf> = extra.into_iter().filter(|p| p.is_dir()).collect();
|
||||
if let Some(current) = std::env::var_os("PATH") {
|
||||
parts.extend(std::env::split_paths(¤t));
|
||||
}
|
||||
if let Ok(joined) = std::env::join_paths(parts) {
|
||||
std::env::set_var("PATH", joined);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a tool considered present? Python is satisfied by python3 too
|
||||
/// (Debian ships python3 without a python symlink).
|
||||
pub fn tool_is_present(tool: &str, runner: &dyn Runner) -> bool {
|
||||
if tool == "python" {
|
||||
runner.which("python3").is_some() || runner.which("python").is_some()
|
||||
} else {
|
||||
runner.which(tool).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn scoop_command(tool: &str) -> Option<String> {
|
||||
let bucket = match tool {
|
||||
"git" => "git",
|
||||
"node" | "npm" => "nodejs-lts",
|
||||
"python" | "python3" | "pip" => "python",
|
||||
"cargo" | "rust" => "rustup",
|
||||
"go" => "go",
|
||||
"bun" => "bun",
|
||||
"uv" => "uv",
|
||||
"docker" => "docker",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("scoop install {bucket}"))
|
||||
}
|
||||
|
||||
fn winget_command(tool: &str) -> Option<String> {
|
||||
let package = match tool {
|
||||
"git" => "Git.Git",
|
||||
"node" | "npm" => "OpenJS.NodeJS.LTS",
|
||||
"python" | "python3" | "pip" => "Python.Python.3.12",
|
||||
"cargo" | "rust" => "Rustlang.Rustup",
|
||||
"go" => "GoLang.Go",
|
||||
"bun" => "Oven-sh.Bun",
|
||||
"uv" => "astral-sh.uv",
|
||||
"docker" => "Docker.DockerDesktop",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"winget install --accept-source-agreements --accept-package-agreements {package}"
|
||||
))
|
||||
}
|
||||
|
||||
fn brew_command(tool: &str) -> Option<String> {
|
||||
Some(
|
||||
match tool {
|
||||
"git" => "brew install git".to_string(),
|
||||
"curl" => "brew install curl".to_string(),
|
||||
"node" | "npm" => "brew install node".to_string(),
|
||||
"python" | "python3" | "pip" => "brew install python".to_string(),
|
||||
"cargo" | "rust" => "brew install rustup".to_string(),
|
||||
"go" => "brew install go".to_string(),
|
||||
"bun" => "brew install oven-sh/bun/bun".to_string(),
|
||||
"uv" => "brew install uv".to_string(),
|
||||
"docker" => "brew install --cask docker".to_string(),
|
||||
_ => return None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn linux_command(tool: &str, pkg: &str) -> Option<String> {
|
||||
Some(
|
||||
match (tool, pkg) {
|
||||
("git", "apt") => "sudo apt install -y git",
|
||||
("git", "dnf") => "sudo dnf install -y git",
|
||||
("git", "pacman") => "sudo pacman -S --noconfirm git",
|
||||
("git", "apk") => "sudo apk add git",
|
||||
("git", "zypper") => "sudo zypper install -y git",
|
||||
("curl", "apt") => "sudo apt install -y curl",
|
||||
("curl", "dnf") => "sudo dnf install -y curl",
|
||||
("curl", "pacman") => "sudo pacman -S --noconfirm curl",
|
||||
("curl", "apk") => "sudo apk add curl",
|
||||
("curl", "zypper") => "sudo zypper install -y curl",
|
||||
("node" | "npm", "apt") => "sudo apt install -y nodejs npm",
|
||||
("node" | "npm", "dnf") => "sudo dnf install -y nodejs npm",
|
||||
("node" | "npm", "pacman") => "sudo pacman -S --noconfirm nodejs npm",
|
||||
("node" | "npm", "apk") => "sudo apk add nodejs npm",
|
||||
("node" | "npm", "zypper") => "sudo zypper install -y nodejs npm",
|
||||
("python" | "python3", "apt") => "sudo apt install -y python3 python3-venv python3-pip",
|
||||
("python" | "python3", "dnf") => "sudo dnf install -y python3 python3-pip",
|
||||
("python" | "python3", "pacman") => "sudo pacman -S --noconfirm python python-pip",
|
||||
("python" | "python3", "apk") => "sudo apk add python3 py3-pip",
|
||||
("python" | "python3", "zypper") => "sudo zypper install -y python3 python3-pip",
|
||||
("pip", "apt") => "sudo apt install -y python3-pip",
|
||||
("pip", "dnf") => "sudo dnf install -y python3-pip",
|
||||
("pip", "pacman") => "sudo pacman -S --noconfirm python-pip",
|
||||
("pip", "apk") => "sudo apk add py3-pip",
|
||||
("pip", "zypper") => "sudo zypper install -y python3-pip",
|
||||
("cargo" | "rust", "apt") => {
|
||||
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"
|
||||
}
|
||||
("cargo" | "rust", "dnf") => "sudo dnf install -y rust cargo",
|
||||
("cargo" | "rust", "pacman") => "sudo pacman -S --noconfirm rust",
|
||||
("cargo" | "rust", "apk") => "sudo apk add rust cargo",
|
||||
("cargo" | "rust", "zypper") => "sudo zypper install -y rust cargo",
|
||||
("go", "apt") => "sudo apt install -y golang-go",
|
||||
("go", "dnf") => "sudo dnf install -y golang",
|
||||
("go", "pacman") => "sudo pacman -S --noconfirm go",
|
||||
("go", "apk") => "sudo apk add go",
|
||||
("go", "zypper") => "sudo zypper install -y go",
|
||||
("bun", "apt") => "curl -fsSL https://bun.sh/install | bash",
|
||||
("bun", "dnf") => "curl -fsSL https://bun.sh/install | bash",
|
||||
("bun", "pacman") => "curl -fsSL https://bun.sh/install | bash",
|
||||
("bun", "apk") => "sudo apk add bun",
|
||||
("bun", "zypper") => "curl -fsSL https://bun.sh/install | bash",
|
||||
("uv", "apt") => "curl -LsSf https://astral.sh/uv/install.sh | sh",
|
||||
("uv", "dnf") => "sudo dnf install -y uv",
|
||||
("uv", "pacman") => "sudo pacman -S --noconfirm uv",
|
||||
("uv", "apk") => "sudo apk add uv",
|
||||
("uv", "zypper") => "sudo zypper install -y uv",
|
||||
("docker", "apt") => "sudo apt install -y docker.io",
|
||||
("docker", "dnf") => "sudo dnf install -y docker",
|
||||
("docker", "pacman") => "sudo pacman -S --noconfirm docker",
|
||||
("docker", "apk") => "sudo apk add docker",
|
||||
("docker", "zypper") => "sudo zypper install -y docker",
|
||||
_ => return None,
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn distro_mapping() {
|
||||
assert_eq!(linux_pkg_for_id("debian").1, Some("apt"));
|
||||
assert_eq!(linux_pkg_for_id("ubuntu").1, Some("apt"));
|
||||
assert_eq!(linux_pkg_for_id("fedora").1, Some("dnf"));
|
||||
assert_eq!(linux_pkg_for_id("arch").1, Some("pacman"));
|
||||
assert_eq!(linux_pkg_for_id("alpine").1, Some("apk"));
|
||||
assert_eq!(linux_pkg_for_id("opensuse-tumbleweed").1, Some("zypper"));
|
||||
assert_eq!(linux_pkg_for_id("").1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_commands() {
|
||||
assert_eq!(scoop_command("node").as_deref(), Some("scoop install nodejs-lts"));
|
||||
assert_eq!(scoop_command("uv").as_deref(), Some("scoop install uv"));
|
||||
assert!(winget_command("git").unwrap().contains("Git.Git"));
|
||||
assert!(winget_command("python").unwrap().contains("--accept-source-agreements"));
|
||||
assert!(scoop_command("unknown-tool").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_commands() {
|
||||
assert!(linux_command("node", "apt").unwrap().contains("nodejs"));
|
||||
assert!(linux_command("cargo", "apt").unwrap().contains("rustup"));
|
||||
assert!(linux_command("go", "pacman").unwrap().contains("pacman -S"));
|
||||
assert!(linux_command("python", "apk").unwrap().contains("python3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_commands() {
|
||||
assert_eq!(brew_command("node").as_deref(), Some("brew install node"));
|
||||
assert!(brew_command("docker").unwrap().contains("--cask"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_commands_run_through_shell() {
|
||||
let runner = crate::runner::MockRunner::new();
|
||||
let code = run_install_command(&runner, "curl -sSf https://x | sh").unwrap();
|
||||
assert_eq!(code, 0);
|
||||
let calls = runner.snapshot();
|
||||
assert_eq!(calls.len(), 1);
|
||||
if cfg!(windows) {
|
||||
assert!(calls[0].starts_with("cmd /C "));
|
||||
} else {
|
||||
assert!(calls[0].starts_with("sh -c "));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_commands_spawn_directly() {
|
||||
let runner = crate::runner::MockRunner::new();
|
||||
run_install_command(&runner, "apt install -y git").unwrap();
|
||||
let calls = runner.snapshot();
|
||||
assert!(calls[0].starts_with("apt install -y git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python3_satisfies_python() {
|
||||
let runner = crate::runner::MockRunner::new().with_found("python3");
|
||||
assert!(tool_is_present("python", &runner));
|
||||
let runner = crate::runner::MockRunner::new();
|
||||
assert!(!tool_is_present("python", &runner));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_aware_selection() {
|
||||
// Windows without scoop falls back to winget.
|
||||
let os = OsInfo {
|
||||
name: "Windows".into(),
|
||||
family: Family::Windows,
|
||||
linux_pkg: None,
|
||||
has_scoop: false,
|
||||
has_winget: true,
|
||||
};
|
||||
let cmds = install_commands("node", &os);
|
||||
assert!(!cmds.is_empty());
|
||||
assert!(cmds[0].contains("winget"));
|
||||
// Debian-like systems get apt commands.
|
||||
let deb = OsInfo {
|
||||
name: "Debian".into(),
|
||||
family: Family::Linux,
|
||||
linux_pkg: Some("apt".into()),
|
||||
has_scoop: false,
|
||||
has_winget: false,
|
||||
};
|
||||
let cmds = install_commands("go", &deb);
|
||||
assert!(!cmds.is_empty());
|
||||
assert!(cmds[0].contains("apt"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user